|
| 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