Skip to content

Commit 21c1f74

Browse files
feat(cli): os mcp connect registers your environment's MCP server with Claude Code (#31149)
<!-- claude-projects-attribution: {"github_login":"marchtian"} --> _Requested by **田騏** · [project thread](https://claude.ai/code/project/chan_01VmuUhXU9SSUzqJYyXSmYSi?thread=cmsg_01VmuUhXU9SSUzqJYyXSmYSiUfC8G7ZWNaCdELoQcXkaCC)_ Fixes #30889 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:新增 `os mcp connect`:自动找到 `os deploy` 用的那个云端环境(`--env` → 当前激活环境 → 组织唯一环境),读出它的地址,执行 `claude mcp add --transport http 环境名 https://环境域名/api/v1/mcp`;`--print` 只打印不执行,`--url` 连自托管或本地服务。`os dev` 横幅的 Connect 行改为用同一个拼写函数。 - **为什么改**:以前开发者要连云端环境的 MCP,得自己找环境地址、手拼命令;本地 `os dev` 有提示,云端没有。现在一条命令完成,登录授权交给 Claude Code 按环境自己的 OAuth 在浏览器里做。 - **风险与回滚**:命令不保存、不写入任何 MCP token,只把 URL 交给客户端;环境解析复用 deploy 的同一函数,多个环境且未给 `--env` 时同样拒绝。回滚即 revert 本 PR,`os dev` 横幅输出字节不变。 - **先看**:`packages/cli/src/commands/mcp/connect.ts`(命名与 `--url` 规则、执行 `claude` 的分支);`packages/cli/src/utils/served-version.ts`(环境详情读取改成可报错的 `readCloudEnvironment`,deploy 的 `fetchEnvironmentOrigin` 包在它外面);`packages/cli/test/mcp-connect.test.ts`。 - **两分钟验证**:`pnpm --filter @objectstack/cli exec vitest run test/mcp-connect.test.ts`,预期 14 个用例全过;或本地 `os dev` 起服务后跑 `os mcp connect --url http://localhost:3000 --print`,打印的 Connect 行与 `os dev` 横幅那行一字不差。 - **需要你做的**:无,席位落地。 ## What changes - **New command `os mcp connect`** (`packages/cli/src/commands/mcp/connect.ts`). - Target: `resolveInstallTarget`, the function `os deploy` and `os package publish --install` use (`--env` / `$OS_ENVIRONMENT_ID`, then the active environment for this control plane, then the organization's only environment). None or several ⇒ the same refusal naming `--env`, exit 1. The only edit to it is an optional `purpose` so the refusal reads "needs an environment to connect to"; the default wording is unchanged. - URL: the environment's `hostname` from `GET /api/v1/cloud/environments/ID`, plus `/api/v1/mcp`. The read is `readCloudEnvironment` in `utils/served-version.ts`; deploy's `fetchEnvironmentOrigin` now wraps it, so the hostname-to-origin rule stays in one place. An unreadable environment or one with no hostname is refused before anything runs. - Name: the environment's slug, else its display name, else its id, lower-cased with other characters folded to `-`. So several environments can be registered side by side. `--name` overrides it and must be letters, digits, `-` and `_`. - `--url BASE` skips the control plane (self-hosted or local `os dev`). The name defaults to `objectstack`, the name `os dev`'s banner and the served `SKILL.md` already use. A URL that already ends in `/api/v1/mcp`, carries a query, or is not http(s) is refused rather than guessed. - Client: Claude Code. Without `--print` it runs `claude mcp add --transport http NAME URL` (argv, no shell except on Windows for the `.cmd` shim). `claude` missing ⇒ exit 1 with the line to paste. `claude` failing ⇒ its exit code. `--print` prints the endpoint and the line and runs nothing. `--client` accepts only `claude-code` today. - After registering it prints: authenticate with `/mcp` in Claude Code (the browser shows the environment's sign-in and consent; no token was stored); revoke in the environment's console under Account → Connect an AI Assistant; and the epic's model line (MCP reads the environment for context; a managed package's content changes through its source and `os deploy`, not through MCP). - **No token anywhere.** The CLI's cloud credential is used only to list and read the environment. It is never put in the client argv or printed, and the command writes no file. - **`os dev`'s Connect row** now prints through the same `claudeMcpAddLine` builder and `MCP_ENDPOINT_PATH`. Its output is byte-identical. The existing source pin in `dev-mcp-connect-hint-origin.test.ts` now asserts the builder call instead of the inline literal. - Changeset: `@objectstack/cli` patch. ## Tests `packages/cli/test/mcp-connect.test.ts`, 14 cases. It uses the same harness shape as `test/deploy.test.ts`: the real command, real `cloud.json` under a redirected `$HOME`, `fetch` as the control plane, and the client runner standing in for `claude`. - Acceptance: one environment + `--print` ⇒ that environment's MCP URL and `claude mcp add` line, named for the environment, nothing run. - Acceptance: `--url http://localhost:3000` ⇒ the local line, no control-plane call. The test also asserts that `printMcpConnectHint({ boundPort: 3000 })` prints the identical line. - Acceptance: two environments and no `--env` ⇒ exit 1 with deploy's refusal, nothing registered. - Acceptance: the run path hands `claude` exactly `mcp add --transport http acme https://acme.example.test/api/v1/mcp`. No argument carries the cloud token or a header, the output never prints the token, and every file under `$HOME` and the working directory is byte-identical before and after. - Also covered: `--env` with the name from the detail read, no hostname / 404 refusals, not logged in, `claude` not on PATH, `claude` exit-code passthrough, `--name` override and refusal, and the name / base-URL helpers. ## Acceptance notes - **Other clients are a follow-up.** No config shape for another client (Cursor, VS Code, Claude Desktop, Codex) was written, because none could be cited from that client's docs inside this run. `--print` covers them: add the printed endpoint as a remote HTTP MCP server in the client's settings. Adding one later means a new `--client` value with a cited config shape. - **The staging manual check was not run.** This container has no staging login. Steps: `os cloud login` against staging, then `os mcp connect`, then `/mcp` in Claude Code and one browser consent; Claude Code should list the environment's tools. Then revoke the assistant under Account → Connect an AI Assistant; the next tool call should fail. - **Docs are a follow-up.** `content/docs/deployment/cli.mdx` is not touched because it is outside the claim's file surface. The command's `--help` carries the description, the examples and every flag. - **The revoke hint names a console path, not a URL.** It points to Account → Connect an AI Assistant, where `AuthorizedAccessList` lists and revokes the person's authorized assistants. No stable console URL for that page could be confirmed. ## Verification Local runs on head df58740 (all exit codes captured before any pipe): <details><summary>Derived gate families: 63 of 63 exit 0 on df58740 (dispatch-gates --ran: 63 run, 0 NOT-MEASURED, a derived zero)</summary> ```text node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0 node scripts/check-adr-0087-registration.mjs --self-test :: exit 0 node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0 node scripts/check-closing-keyword-parity.mjs :: exit 0 node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0 node scripts/check-comment-mask-adoption.mjs :: exit 0 node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0 node scripts/check-comment-mask-corpus.mjs :: exit 0 node scripts/check-dts-emitted.mjs --self-test :: exit 0 node scripts/check-empty-changeset.mjs --base origin/main :: exit 0 node scripts/check-empty-changeset.mjs --self-test :: exit 0 node scripts/check-issue-citations.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs :: exit 0 node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0 node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0 node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0 node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0 node scripts/ci/snapshot-next.mjs --self-test :: exit 0 node scripts/docs-audit/check-affected-docs.mjs :: exit 0 node scripts/docs-audit/check-drift-comment.mjs :: exit 0 node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0 node scripts/release-pending-publish.mjs --self-test :: exit 0 pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0 pnpm check:changeset-gate-self-tests :: exit 0 pnpm check:ci-filter-parity :: exit 0 pnpm check:cli-test-child-env :: exit 0 pnpm check:closed-ceremony-text :: exit 0 pnpm check:cross-package-test-inputs :: exit 0 pnpm check:dispatcher-error-vocabulary :: exit 0 pnpm check:doc-authoring :: exit 0 pnpm check:driver-memory-census :: exit 0 pnpm check:dts-closure :: exit 0 pnpm check:dual-build-cjs-loads :: exit 0 pnpm check:engine-double-contract :: exit 0 pnpm check:gitlink-declared :: exit 0 pnpm check:i18n :: exit 0 pnpm check:i18n-coverage :: exit 0 pnpm check:i18n-walk-parity :: exit 0 pnpm check:issue-citations :: exit 0 pnpm check:keyed-text-bounds :: exit 0 pnpm check:lean-entry-closure :: exit 0 pnpm check:logger-receiver-detach :: exit 0 pnpm check:nul-bytes :: exit 0 pnpm check:objectql-double-limit :: exit 0 pnpm check:org-identifier :: exit 0 pnpm check:page-declaration-shape :: exit 0 pnpm check:platform-object-tenancy-census :: exit 0 pnpm check:published-files :: exit 0 pnpm check:query-options-erasure :: exit 0 pnpm check:refd-timer-probe :: exit 0 pnpm check:registry-log-declared :: exit 0 pnpm check:rest-log-spy-declared :: exit 0 pnpm check:slot-lookup :: exit 0 pnpm check:sourcemap-no-sources-content :: exit 0 pnpm check:system-context-census :: exit 0 pnpm check:test-source-alias :: exit 0 pnpm check:tier-file-adoption :: exit 0 pnpm check:type-check-coverage :: exit 0 pnpm check:type-check-debt :: exit 0 pnpm check:undeclared-dep-imports :: exit 0 pnpm check:vouch-for-reference-callers :: exit 0 pnpm check:watch-hint-literal :: exit 0 pnpm check:where-matcher :: exit 0 ``` </details> <details><summary>Package runs on df58740</summary> ```text pnpm --filter @objectstack/cli exec vitest run --project unit --maxWorkers=2 :: exit 0 — Test Files 397 passed (397), Tests 5593 passed (5593) pnpm --filter @objectstack/cli typecheck :: exit 0 — check:test-typecheck OK, 3 file(s) / 28 error(s) held in test-typecheck-debt.json (unchanged) node --stack-size=4000 node_modules/eslint/bin/eslint.js packages/cli --no-inline-config :: exit 0 ``` The integration tier is left to CI: the diff touches no integration-tier file and no spawn entry. </details> 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4bf30da commit 21c1f74

7 files changed

Lines changed: 686 additions & 18 deletions

File tree

‎.changeset/30889-os-mcp-connect.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/cli': patch
3+
---
4+
5+
feat(cli): `os mcp connect` registers your environment's MCP server with Claude Code in one command
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
`os mcp connect` finds the environment `os deploy` deploys to (`--env`, then the active environment for this control plane, then your organization's only one; several with no `--env` is refused, as deploy refuses) and runs `claude mcp add --transport http NAME https://ENV-HOST/api/v1/mcp`. The server is named after the environment (its slug, else its display name, else its id), so several environments can be registered side by side; `--name` overrides it.
11+
12+
- `--print` prints the endpoint and the `claude mcp add` line and runs nothing. For any other MCP client, add that endpoint as a remote HTTP MCP server in the client's own settings.
13+
- `--url http://localhost:3000` connects a self-hosted or local server instead, named `objectstack` like the `os dev` banner's Connect row. The banner now prints its line through the same builder, so the two stay one spelling.
14+
- No token is stored or handed to the client. Claude Code runs the environment's own OAuth: run `/mcp` in Claude Code and the browser shows the environment's sign-in and consent. The command prints where to revoke that grant (Account → Connect an AI Assistant in the environment's console).

‎packages/cli/src/commands/dev-mcp-connect-hint-origin.test.ts‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,21 @@ import { OBJECTSTACK_MCP_SERVER_NAME, renderSkillMarkdown } from '@objectstack/m
3838
import { printMcpConnectHint } from './dev.js';
3939
import { AUTH_BASE_URL_ENV_NAMES, resolveAuthBaseUrl } from './serve.js';
4040
import { printServerReady, type ServerReadyOptions } from '../utils/format.js';
41+
import McpConnect from './mcp/connect.js';
42+
43+
// Pay the first load of what `serve.ts` reaches through dynamic `import()`s at
44+
// MODULE LOAD, outside any clocked case (`pnpm check:test-source-alias`).
45+
import '@objectstack/metadata-protocol';
46+
import '@objectstack/metadata';
47+
import '@objectstack/objectql';
48+
import '@objectstack/plugin-hono-server';
49+
import '@objectstack/rest';
50+
import '@objectstack/service-settings';
51+
import '@objectstack/service-datasource';
52+
import '@objectstack/driver-turso';
53+
import '@objectstack/driver-sql';
54+
import '@objectstack/platform-objects/plugin';
55+
import '@objectstack/plugin-security';
4156

4257
const HERE = dirname(fileURLToPath(import.meta.url));
4358
const DEV_SOURCE = readFileSync(resolve(HERE, 'dev.ts'), 'utf8');
@@ -371,11 +386,23 @@ describe('os dev MCP connect hint — origin (#16734)', () => {
371386
expect(skill).toEqual([OBJECTSTACK_MCP_SERVER_NAME]);
372387
});
373388

389+
it('`os mcp connect --url` for the same origin prints the very line the banner prints', async () => {
390+
const banner = boot(3000).split('\n').find((l) => l.includes('Connect claude mcp add'));
391+
expect(banner).toBeDefined();
392+
393+
lines = [];
394+
await McpConnect.run(['--url', 'http://localhost:3000', '--print']);
395+
const connect = lines.find((l) => l.includes('Connect claude mcp add'));
396+
397+
expect(connect?.trim()).toBe(banner!.trim());
398+
});
399+
374400
it('dev reads that constant and derives no name of its own', () => {
375401
// The name used to be the folder `os dev` ran in, so a `hotcrm/` checkout
376402
// registered the server as `hotcrm` while the skill said `objectstack`.
377403
expect(DEV_SOURCE).toContain("import { OBJECTSTACK_MCP_SERVER_NAME } from '@objectstack/mcp';");
378-
expect(DEV_SOURCE).toContain('claude mcp add --transport http ${OBJECTSTACK_MCP_SERVER_NAME} ');
404+
// The line is `os mcp connect`'s builder, fed that constant: one spelling for both commands.
405+
expect(DEV_SOURCE).toContain('claudeMcpAddLine(OBJECTSTACK_MCP_SERVER_NAME, ');
379406
expect(DEV_SOURCE).not.toMatch(/basename\(process\.cwd\(\)\)/);
380407
});
381408
});

‎packages/cli/src/commands/dev.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import {
3535
import { artifactObjectNames } from '../utils/stack-collections.js';
3636
import { readEnvWithDeprecation, isMcpServerEnabled } from '@objectstack/types';
3737
import { OBJECTSTACK_MCP_SERVER_NAME } from '@objectstack/mcp';
38+
import { claudeMcpAddLine, MCP_ENDPOINT_PATH } from './mcp/connect.js';
3839
// The ONE port contract, shared with `start` and with the `serve` child this
3940
// command spawns (#12673). ⛔ Nothing about ports is declared in this file —
4041
// no range, no reader, no wording; a second copy of the bound is exactly what
@@ -194,6 +195,9 @@ export function prepareDevDatabaseDirectory(
194195
* used to be the current folder's basename, so one endpoint was registered as
195196
* the folder's name from the banner and as `objectstack` from the skill and
196197
* the console: two entries for one server in the agent's config.
198+
*
199+
* The line itself is `os mcp connect`'s ({@link claudeMcpAddLine}), so
200+
* `os mcp connect --url <this origin>` prints and runs exactly this row.
197201
*/
198202
export function printMcpConnectHint(
199203
opts: { boundPort: number | string; boundProtocol?: ListenerProtocol },
@@ -202,9 +206,9 @@ export function printMcpConnectHint(
202206
if (baseOrigin === null) return;
203207
console.log();
204208
console.log(chalk.cyan(' 🤖 MCP server — connect a coding agent:'));
205-
console.log(` Endpoint ${baseOrigin}/api/v1/mcp`);
206-
console.log(` Skill ${baseOrigin}/api/v1/mcp/skill`);
207-
console.log(chalk.dim(` Connect claude mcp add --transport http ${OBJECTSTACK_MCP_SERVER_NAME} ${baseOrigin}/api/v1/mcp`));
209+
console.log(` Endpoint ${baseOrigin}${MCP_ENDPOINT_PATH}`);
210+
console.log(` Skill ${baseOrigin}${MCP_ENDPOINT_PATH}/skill`);
211+
console.log(chalk.dim(` Connect ${claudeMcpAddLine(OBJECTSTACK_MCP_SERVER_NAME, `${baseOrigin}${MCP_ENDPOINT_PATH}`)}`));
208212
console.log(chalk.dim(' Disable OS_MCP_SERVER_ENABLED=false'));
209213
}
210214

Lines changed: 292 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,292 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `os mcp connect` — register your environment's MCP server with your MCP
5+
* client in one command. The client then runs the environment's own OAuth.
6+
*
7+
* ## Which server
8+
*
9+
* The environment `os deploy` deploys to, resolved by the same function
10+
* (`resolveInstallTarget`): `--env`, then the active environment recorded for
11+
* this control plane, then your organization's only environment. None or
12+
* several is a refusal naming `--env`, exactly as deploy refuses. Its URL is
13+
* the control plane's environment read (`readCloudEnvironment`, the read
14+
* deploy's wait uses for the environment's origin) plus {@link MCP_ENDPOINT_PATH}.
15+
* `--url` skips the control plane: a self-hosted server or a local `os dev`.
16+
*
17+
* ## Which name
18+
*
19+
* The environment's own (its slug, else its display name, else its id), made
20+
* safe for a client's server name, so several environments can be registered
21+
* side by side. A `--url` server is named {@link OBJECTSTACK_MCP_SERVER_NAME},
22+
* the name `os dev`'s banner and the served `SKILL.md` give it, so a local
23+
* server is never registered twice under two names. `--name` overrides both.
24+
*
25+
* ## No token, anywhere
26+
*
27+
* ⛔ The command stores no MCP token and writes none into a client config.
28+
* It hands the client the URL alone; on first use the client follows the
29+
* environment's OAuth discovery, registers itself, and the browser shows the
30+
* environment's sign-in and consent. A token copied into a config file would
31+
* expire within the hour, skip the client's refresh, and outlive a revoked
32+
* grant. Reusing the CLI's ObjectOS Cloud login for that consent is out of
33+
* scope: it waits on the decision whether the control plane becomes the one
34+
* authorization server for the CLI and MCP.
35+
*
36+
* ## Which client
37+
*
38+
* Claude Code, by running `claude mcp add --transport http NAME URL`
39+
* ({@link claudeMcpAddArgs}, the one spelling `os dev`'s banner prints too).
40+
* `--print` prints that line and the endpoint and runs nothing, which is also
41+
* how to connect any other client: add the endpoint as a remote HTTP MCP server
42+
* in its own settings.
43+
*/
44+
45+
import { spawnSync, type SpawnSyncReturns } from 'node:child_process';
46+
import { Command, Flags } from '@oclif/core';
47+
import { OBJECTSTACK_MCP_SERVER_NAME } from '@objectstack/mcp';
48+
import { printError, printHeader, printStep, printSuccess, isExitSignal } from '../../utils/format.js';
49+
import { DEFAULT_CLOUD_URL, resolveCloudCredential } from '../../utils/cloud-config.js';
50+
import { describeEnvironment, resolveInstallTarget } from '../../utils/install-target.js';
51+
import { readCloudEnvironment } from '../../utils/served-version.js';
52+
53+
/** Where every ObjectStack server mounts its MCP endpoint. */
54+
export const MCP_ENDPOINT_PATH = '/api/v1/mcp';
55+
56+
/** The clients this command can register with. */
57+
export const MCP_CLIENTS = ['claude-code'] as const;
58+
59+
/** A server name every client accepts: letters, digits, `-` and `_`. */
60+
const SERVER_NAME = /^[A-Za-z0-9_-]+$/;
61+
62+
/** `claude`'s arguments that register `url` as the remote HTTP MCP server `name`. */
63+
export function claudeMcpAddArgs(name: string, url: string): string[] {
64+
return ['mcp', 'add', '--transport', 'http', name, url];
65+
}
66+
67+
/** The `claude mcp add` line, as `os dev`'s banner and this command print it. */
68+
export function claudeMcpAddLine(name: string, url: string): string {
69+
return ['claude', ...claudeMcpAddArgs(name, url)].join(' ');
70+
}
71+
72+
/**
73+
* The environment's name as a client's server name: its slug, else its
74+
* display name, else its id, lower-cased with every run of other characters
75+
* turned into one `-`. A value with nothing left (a display name in another
76+
* script) falls through to the next.
77+
*/
78+
export function environmentServerName(env: { id: string; slug?: string; displayName?: string }): string {
79+
for (const candidate of [env.slug, env.displayName, env.id]) {
80+
const safe = (candidate ?? '').toLowerCase().replace(/[^a-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '');
81+
if (safe) return safe;
82+
}
83+
return OBJECTSTACK_MCP_SERVER_NAME;
84+
}
85+
86+
/**
87+
* `--url` as a server base URL: `http(s)://host[:port][/prefix]`, with no
88+
* query and no fragment. The MCP path is added here, so a URL that already
89+
* ends in it is refused rather than doubled.
90+
*/
91+
export function mcpUrlFromBase(raw: string): { ok: true; mcpUrl: string } | { ok: false; reason: string } {
92+
let parsed: URL;
93+
try {
94+
parsed = new URL(raw.trim());
95+
} catch {
96+
return { ok: false, reason: `\`--url ${raw}\` is not a URL. Pass the server's base URL, such as http://localhost:3000.` };
97+
}
98+
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
99+
return { ok: false, reason: `\`--url\` must be an http or https URL, not ${parsed.protocol}` };
100+
}
101+
if (parsed.search || parsed.hash) {
102+
return { ok: false, reason: '`--url` takes the server\'s base URL, with no query or fragment.' };
103+
}
104+
const path = parsed.pathname.replace(/\/+$/, '');
105+
if (path === MCP_ENDPOINT_PATH || path.endsWith(MCP_ENDPOINT_PATH)) {
106+
return {
107+
ok: false,
108+
reason: `\`--url\` takes the server's base URL; this command adds ${MCP_ENDPOINT_PATH} itself. Pass ${parsed.origin}${path.slice(0, -MCP_ENDPOINT_PATH.length)}.`,
109+
};
110+
}
111+
return { ok: true, mcpUrl: `${parsed.origin}${path}${MCP_ENDPOINT_PATH}` };
112+
}
113+
114+
/** Runs a client's own CLI. A seam, so a test can stand in for `claude`. */
115+
export const mcpClientRunner = {
116+
run(command: string, args: string[]): SpawnSyncReturns<Buffer> {
117+
// `claude` is a `.cmd` shim on Windows, which only a shell resolves.
118+
return spawnSync(command, args, { stdio: 'inherit', shell: process.platform === 'win32' });
119+
},
120+
};
121+
122+
export default class McpConnect extends Command {
123+
static override description =
124+
"Register your environment's MCP server with your MCP client (Claude Code); the client then runs the environment's own sign-in and consent";
125+
126+
static override examples = [
127+
'$ os mcp connect',
128+
'$ os mcp connect --print # print the endpoint and the `claude mcp add` line, run nothing',
129+
'$ os mcp connect --env env_abc123 # your account reaches several environments',
130+
'$ os mcp connect --url http://localhost:3000 # a self-hosted or local server',
131+
];
132+
133+
static override flags = {
134+
env: Flags.string({
135+
description:
136+
'Environment id to connect to. Defaults to the environment `os environments switch` recorded '
137+
+ "for this control plane, then to your organization's only environment",
138+
env: 'OS_ENVIRONMENT_ID',
139+
}),
140+
url: Flags.string({
141+
description: 'Base URL of a self-hosted or local server (e.g. http://localhost:3000); skips ObjectOS Cloud',
142+
}),
143+
name: Flags.string({
144+
description: "Server name in the client (default: the environment's name; `objectstack` with --url)",
145+
}),
146+
client: Flags.string({
147+
description: 'MCP client to register with. For any other client, use --print and add the endpoint in its settings',
148+
options: [...MCP_CLIENTS],
149+
default: 'claude-code',
150+
}),
151+
print: Flags.boolean({
152+
description: 'Print the endpoint and the command that registers it; run nothing',
153+
default: false,
154+
}),
155+
server: Flags.string({
156+
char: 's',
157+
description: 'ObjectOS Cloud control-plane URL (override for self-hosted control planes)',
158+
env: 'OS_CLOUD_URL',
159+
default: DEFAULT_CLOUD_URL,
160+
}),
161+
token: Flags.string({
162+
char: 't',
163+
description: 'API key for ObjectOS Cloud (bearer token; defaults to OS_CLOUD_API_KEY / OS_TOKEN, then `os cloud login`). Used only to find the environment; never handed to the MCP client',
164+
env: 'OS_CLOUD_API_KEY',
165+
}),
166+
};
167+
168+
async run(): Promise<void> {
169+
const { flags } = await this.parse(McpConnect);
170+
printHeader('MCP connect');
171+
172+
try {
173+
if (flags.name !== undefined && !SERVER_NAME.test(flags.name)) {
174+
printError(`\`--name ${flags.name}\` is not a server name: use letters, digits, \`-\` and \`_\` only.`);
175+
this.exit(1);
176+
return;
177+
}
178+
179+
let mcpUrl: string;
180+
let name: string;
181+
let where: string;
182+
if (flags.url !== undefined) {
183+
const local = mcpUrlFromBase(flags.url);
184+
if (!local.ok) {
185+
printError(local.reason);
186+
this.exit(1);
187+
return;
188+
}
189+
mcpUrl = local.mcpUrl;
190+
name = flags.name ?? OBJECTSTACK_MCP_SERVER_NAME;
191+
where = new URL(mcpUrl).origin;
192+
} else {
193+
const { token, baseUrl } = await resolveCloudCredential({ token: flags.token, server: flags.server });
194+
if (!token) {
195+
printError(
196+
'Not logged in to ObjectOS Cloud. Run `os cloud login` first, then `os mcp connect` again '
197+
+ '(or pass `--url` for a self-hosted or local server).',
198+
);
199+
this.exit(1);
200+
return;
201+
}
202+
const target = await resolveInstallTarget({
203+
subject: '`os mcp connect`',
204+
purpose: 'to connect to',
205+
explicit: flags.env,
206+
baseUrl,
207+
token,
208+
timeoutMs: 30_000,
209+
});
210+
if (target.kind === 'refused') {
211+
printError(target.message);
212+
this.exit(1);
213+
return;
214+
}
215+
const env = await readCloudEnvironment({ baseUrl, token, environmentId: target.environmentId });
216+
const displayName = target.displayName ?? (env.ok ? env.displayName : undefined);
217+
where = describeEnvironment({ id: target.environmentId, displayName });
218+
if (!env.ok) {
219+
printError(
220+
`${where}'s address could not be read from ${baseUrl}`
221+
+ `${env.status ? ` (${env.status})` : ''}: ${env.error}. Nothing was registered.`,
222+
);
223+
this.exit(1);
224+
return;
225+
}
226+
if (!env.origin) {
227+
printError(
228+
`${baseUrl} reports no hostname for ${where}, so it has no MCP URL to register. `
229+
+ 'Open the environment in the ObjectOS Cloud console to check it is provisioned. Nothing was registered.',
230+
);
231+
this.exit(1);
232+
return;
233+
}
234+
mcpUrl = `${env.origin}${MCP_ENDPOINT_PATH}`;
235+
name = flags.name ?? environmentServerName({ id: target.environmentId, slug: env.slug, displayName });
236+
printStep(
237+
target.source === 'only'
238+
? `Connecting to ${where}, your organization's only environment`
239+
: `Connecting to ${where}`,
240+
);
241+
}
242+
243+
const line = claudeMcpAddLine(name, mcpUrl);
244+
console.log(` Endpoint ${mcpUrl}`);
245+
console.log(` Name ${name}`);
246+
console.log(` Connect ${line}`);
247+
248+
if (!flags.print) {
249+
console.log('');
250+
printStep(`Registering ${name} with Claude Code...`);
251+
const ran = mcpClientRunner.run('claude', claudeMcpAddArgs(name, mcpUrl));
252+
if (ran.error) {
253+
const missing = (ran.error as NodeJS.ErrnoException).code === 'ENOENT';
254+
printError(
255+
missing
256+
? 'Claude Code\'s `claude` command was not found on your PATH. Install Claude Code, then run the Connect line above '
257+
+ '(or `os mcp connect` again). For another client, add the Endpoint above as a remote HTTP MCP server in its settings.'
258+
: `\`claude\` could not be started: ${ran.error.message}. Run the Connect line above yourself.`,
259+
);
260+
this.exit(1);
261+
return;
262+
}
263+
if (ran.status !== 0) {
264+
printError(
265+
`\`claude mcp add\` exited ${ran.status ?? `on ${ran.signal}`}; its reason is above. `
266+
+ `If ${name} is already registered, remove it with \`claude mcp remove ${name}\` or pick another --name.`,
267+
);
268+
this.exit(ran.status || 1);
269+
return;
270+
}
271+
printSuccess(`Registered ${name} with Claude Code.`);
272+
}
273+
274+
console.log('');
275+
console.log(
276+
` Sign-in In Claude Code, run /mcp and authenticate ${name}: your browser shows ${where}'s sign-in and consent. `
277+
+ 'This command stored no token.',
278+
);
279+
console.log(
280+
` Revoke In ${where}'s console: Account → Connect an AI Assistant → your authorized assistants → Revoke.`,
281+
);
282+
console.log(
283+
' Model MCP reads the environment for context. A managed package\'s content changes through its source '
284+
+ 'and `os deploy`, not through MCP.',
285+
);
286+
} catch (error) {
287+
if (isExitSignal(error)) throw error;
288+
printError((error as Error).message);
289+
this.exit(1);
290+
}
291+
}
292+
}

0 commit comments

Comments
 (0)