From f5a98b284dcee52102f180f181cfe36954bad0aa Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Sun, 17 May 2026 23:46:44 +0800 Subject: [PATCH 1/9] feat(core/cli): add Model Context Protocol (MCP) client support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds end-to-end MCP client support so users can plug community / custom MCP servers into xc with the same `mcpServers` config shape used by Claude Code and Gemini CLI. Transports - stdio (local subprocess; ~90% of community servers) - Streamable HTTP (remote servers, with optional OAuth 2.1) Primitives - Tools: every connected server's tools are exposed under `mcp____` and routed through the existing permission / loop-guard / abortSignal pipeline. - Resources: surfaced via two built-in tools (`listMcpResources`, `readMcpResource`) so the model pulls them on demand instead of auto-injecting into context. - Prompts: intentionally not wired up (rarely used in the wild). Trust & security - Project-level `.x-code/config.json` requires explicit consent on first launch (readline-based dialog showing the actual command strings). Trusted paths persisted to `~/.x-code/trusted-projects.json`. - Per-tool permission: every MCP tool starts at ask; "always allow" choices persist to `~/.x-code/mcp-permissions.json`. - OAuth tokens kept in `~/.x-code/mcp-auth.json` (mode 0600). - Plan mode denies all MCP tools (opaque side effects). Slash commands /mcp list / tools / auth / logout / refresh System prompt `{mcpCapabilities}` placeholder added to BASE_SYSTEM_PROMPT — expands to "" when no MCP is configured, keeping prefix-cache byte-stable for the existing single-provider flow. Tests - 6 vitest files, 45 unit + integration tests (uses a real Node child process implementing the protocol). - One e2e scenario (24-mcp-stdio) drives `xc -p` against an inline mock MCP server and asserts the model quotes a server-stamped marker. --- packages/cli/src/index.ts | 94 ++- packages/cli/src/ui/components/App.tsx | 109 ++++ .../cli/tests/e2e/scenarios/24-mcp-stdio.ts | 101 ++++ packages/core/package.json | 1 + packages/core/src/agent/loop.ts | 22 + packages/core/src/agent/system-prompt.ts | 62 +- packages/core/src/agent/tool-execution.ts | 187 ++++++ packages/core/src/config/index.ts | 12 + packages/core/src/index.ts | 19 + packages/core/src/mcp/client.ts | 274 +++++++++ packages/core/src/mcp/config-schema.ts | 95 +++ packages/core/src/mcp/expand-env.ts | 51 ++ packages/core/src/mcp/loader.ts | 277 +++++++++ packages/core/src/mcp/name-mangling.ts | 91 +++ .../core/src/mcp/oauth/callback-server.ts | 160 +++++ packages/core/src/mcp/oauth/provider.ts | 217 +++++++ packages/core/src/mcp/oauth/token-storage.ts | 173 ++++++ packages/core/src/mcp/permissions.ts | 120 ++++ packages/core/src/mcp/registry.ts | 112 ++++ packages/core/src/mcp/resources.ts | 42 ++ packages/core/src/mcp/tool-bridge.ts | 60 ++ packages/core/src/mcp/trust.ts | 130 ++++ packages/core/src/mcp/types.ts | 80 +++ packages/core/src/types/index.ts | 15 + .../core/tests/fixtures/mock-mcp-server.mjs | 123 ++++ packages/core/tests/mcp-config-schema.test.ts | 59 ++ packages/core/tests/mcp-expand-env.test.ts | 64 ++ packages/core/tests/mcp-integration.test.ts | 100 ++++ packages/core/tests/mcp-name-mangling.test.ts | 59 ++ packages/core/tests/mcp-permissions.test.ts | 72 +++ packages/core/tests/mcp-trust.test.ts | 83 +++ pnpm-lock.yaml | 556 ++++++++++++++++++ 32 files changed, 3618 insertions(+), 2 deletions(-) create mode 100644 packages/cli/tests/e2e/scenarios/24-mcp-stdio.ts create mode 100644 packages/core/src/mcp/client.ts create mode 100644 packages/core/src/mcp/config-schema.ts create mode 100644 packages/core/src/mcp/expand-env.ts create mode 100644 packages/core/src/mcp/loader.ts create mode 100644 packages/core/src/mcp/name-mangling.ts create mode 100644 packages/core/src/mcp/oauth/callback-server.ts create mode 100644 packages/core/src/mcp/oauth/provider.ts create mode 100644 packages/core/src/mcp/oauth/token-storage.ts create mode 100644 packages/core/src/mcp/permissions.ts create mode 100644 packages/core/src/mcp/registry.ts create mode 100644 packages/core/src/mcp/resources.ts create mode 100644 packages/core/src/mcp/tool-bridge.ts create mode 100644 packages/core/src/mcp/trust.ts create mode 100644 packages/core/src/mcp/types.ts create mode 100644 packages/core/tests/fixtures/mock-mcp-server.mjs create mode 100644 packages/core/tests/mcp-config-schema.test.ts create mode 100644 packages/core/tests/mcp-expand-env.test.ts create mode 100644 packages/core/tests/mcp-integration.test.ts create mode 100644 packages/core/tests/mcp-name-mangling.test.ts create mode 100644 packages/core/tests/mcp-permissions.test.ts create mode 100644 packages/core/tests/mcp-trust.test.ts diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts index 7cfe8a6..87603b5 100644 --- a/packages/cli/src/index.ts +++ b/packages/cli/src/index.ts @@ -7,20 +7,24 @@ import fs from 'node:fs' import path from 'node:path' import { + McpPermissionStore, PROVIDER_DETECTION_ORDER, PROVIDER_KEY_URLS, createModelRegistry, + createOAuthProviderFactory, createSubAgentRegistry, debugLog, getAvailableProviders, getEnvVarName, + getTokenStorage, listSessions, + loadMcpFromDisk, loadSession, loadUserConfig, pickLatestSession, resolveModelId, } from '@x-code-cli/core' -import type { AgentOptions, LoadedSession } from '@x-code-cli/core' +import type { AgentOptions, LoadedSession, McpRegistry } from '@x-code-cli/core' import { getCleanupFn, getSessionExitInfo, startApp } from './app.js' import { detectShell, formatPersistCommand } from './shell.js' @@ -82,6 +86,12 @@ function checkNodeVersion(): void { // and the delayed stdout flush made it appear after the shell prompt, // confusing users. let shutdownInProgress = false +/** Captured at startup so gracefulShutdown can close MCP servers + * (kill stdio child processes, terminate HTTP transports) on the way + * out. Without this, stdio servers would linger until they noticed + * their parent's stdin closed — usually fine, but explicit shutdown + * is faster and less surprising. */ +let mcpRegistryForShutdown: McpRegistry | null = null // Belt-and-suspenders terminal restore. Runs synchronously before exit so even // if Ink's unmount is partially broken (e.g. a useEffect cleanup threw, or the @@ -118,6 +128,13 @@ async function gracefulShutdown(exitCode: number): Promise { const cleanup = getCleanupFn() if (cleanup) cleanup().catch(() => undefined) + // Fire-and-forget MCP shutdown. Stdio servers also clean themselves up + // when their stdin closes, so even if process.exit beats this promise + // the OS reaps the children — this just makes it explicit / faster. + if (mcpRegistryForShutdown) { + mcpRegistryForShutdown.shutdown().catch(() => undefined) + } + resetTerminal() // Print AFTER resetTerminal so the line lands cleanly above the // shell prompt — colors are reset, raw mode is off, cursor is @@ -276,6 +293,35 @@ async function main() { const model = providerRegistry.languageModel(modelId as `${string}:${string}`) const subAgentRegistry = await createSubAgentRegistry() + // MCP: load servers, run trust dialog if project-level config is + // unfamiliar. Done BEFORE Ink mounts so the readline-based trust + // prompt has a clean terminal. The MCP machinery is opt-in: a user + // with no mcpServers in their config pays a single fs.stat (one for + // user config, one for project config) and that's it. + const tokenStorage = getTokenStorage() + const mcpPermissionStore = new McpPermissionStore() + const mcpLoadResult = await loadMcpFromDisk({ + cwd: process.cwd(), + askUser: (question, opts) => askInTerminal(question, opts), + oauthProviderFor: createOAuthProviderFactory(tokenStorage, (server, url) => { + console.error(chalk.cyan(`[mcp] Opening browser for ${server}: ${url}`)) + }), + onExitRequested: () => process.exit(0), + }) + mcpRegistryForShutdown = mcpLoadResult.registry + + if (mcpLoadResult.configErrors.length > 0) { + for (const e of mcpLoadResult.configErrors) { + console.error(chalk.yellow(`[mcp] config error in ${e.name}: ${e.message}`)) + } + } + if (mcpLoadResult.projectSkipped) { + console.error(chalk.yellow(`[mcp] Project-level MCP servers skipped (not trusted).`)) + } + // Preload the always-allow list so the first tool call doesn't pay + // the file-read latency. + await mcpPermissionStore.preload() + const options: AgentOptions = { modelId, trustMode: argv.trust, @@ -294,6 +340,8 @@ async function main() { permissionMode: argv.plan ? 'plan' : 'default', modelRegistry: providerRegistry, subAgentRegistry, + mcpRegistry: mcpLoadResult.registry, + mcpPermissionStore, } // Resume / continue. Three resume entry points: @@ -487,6 +535,50 @@ function printNoWebSearchKeyHint(): void { console.error(` ${dim(`(${shell})`)} ${code(cmd)}\n`) } +/** Plain-terminal prompt used during startup, before Ink mounts. + * Currently the only caller is the MCP project-level trust dialog — + * loader.ts hands its `askUser` callback an arbitrary list of options + * and expects one of the option labels back. + * + * Falls back gracefully when stdin isn't a TTY (piped input, CI, + * `--print` mode): we return the option whose label looks like + * "skip" if present, otherwise the second option (loader's convention + * is index 1 == safe default). This guarantees we never block waiting + * for input that will never arrive. */ +async function askInTerminal( + question: string, + options: Array<{ label: string; description: string }>, +): Promise { + const safeDefault = options.find((o) => /skip/i.test(o.label))?.label ?? options[1]?.label ?? options[0]?.label ?? '' + + if (!process.stdin.isTTY || !process.stdout.isTTY) { + return safeDefault + } + + const readline = await import('node:readline/promises') + + // Render to stderr so the prompt body lands in the same stream as + // other CLI status messages; this keeps stdout clean if someone is + // capturing it (rare during interactive startup but better-safe). + process.stderr.write('\n' + chalk.yellow(question) + '\n') + for (let i = 0; i < options.length; i++) { + const o = options[i] + process.stderr.write(` ${chalk.bold(`${i + 1}.`)} ${o.label} — ${chalk.gray(o.description)}\n`) + } + + const rl = readline.createInterface({ input: process.stdin, output: process.stderr }) + try { + const answer = await rl.question(`\nChoose [1-${options.length}]: `) + const idx = parseInt(answer.trim(), 10) - 1 + if (Number.isFinite(idx) && idx >= 0 && idx < options.length) { + return options[idx].label + } + return safeDefault + } finally { + rl.close() + } +} + function readStdin(): Promise { return new Promise((resolve) => { let data = '' diff --git a/packages/cli/src/ui/components/App.tsx b/packages/cli/src/ui/components/App.tsx index 4319a2e..23d3b13 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -11,6 +11,7 @@ import { getAutoMemory, getAvailableProviders, getContextWindow, + getTokenStorage, listSessions, loadSession, loadUserConfig, @@ -66,6 +67,7 @@ export const SLASH_COMMANDS = [ { name: '/usage', description: 'Show current-session token usage (input/output/cache)' }, { name: '/usage-history', description: 'List past sessions in this project' }, { name: '/memory', description: 'Show auto-memory entries (project + global)' }, + { name: '/mcp', description: 'Manage MCP servers (list / tools / auth / logout / refresh)' }, { name: '/exit', description: 'Exit (flushes session)' }, ] as const @@ -573,6 +575,10 @@ export function App({ handleMemory() return + case 'mcp': + await handleMcp(text, arg) + return + case 'exit': await cleanup() exit() @@ -1021,6 +1027,109 @@ export function App({ addInfoMessage(sections.join('\n')) } + /** /mcp — manage MCP servers (list / tools / auth / logout / refresh). + * + * Most subcommands are pure-read against `options.mcpRegistry`, which + * is the frozen snapshot from CLI startup. `auth` / `refresh` / + * config changes all require a CLI restart to take effect because + * the system prompt cache (and provider prefix caches) are stable + * for the session — same constraint as sub-agents (see CLAUDE.md). + * `logout` is the only mutator that takes effect immediately: it + * just deletes a token from disk; the actual reconnect happens at + * next launch. */ + async function handleMcp(text: string, arg: string) { + const argTrimmed = arg.trim() + const sub = (argTrimmed.split(/\s+/)[0] ?? '').toLowerCase() + const subArg = argTrimmed.slice(sub.length).trim() + const registry = options.mcpRegistry + + switch (sub) { + case '': + case 'list': { + const statuses = registry?.serverStatus() ?? [] + if (statuses.length === 0) { + addCommandMessage(text, 'No MCP servers configured. Add `mcpServers` to ~/.x-code/config.json then restart.') + return + } + const lines = ['MCP servers:'] + const namePad = Math.max(...statuses.map((s) => s.name.length), 8) + 2 + for (const s of statuses) { + let badge = '' + switch (s.status.kind) { + case 'connected': + badge = `connected — ${s.status.toolCount} tool${s.status.toolCount === 1 ? '' : 's'}, ${s.status.resourceCount} resource${s.status.resourceCount === 1 ? '' : 's'}` + break + case 'disabled': + badge = 'disabled' + break + case 'connecting': + badge = 'connecting…' + break + case 'needs_auth': + badge = `needs auth — run /mcp logout ${s.name} and restart to retry` + break + case 'failed': + badge = `failed — ${s.status.error}` + break + } + lines.push(` ${s.name.padEnd(namePad)} ${badge}`) + } + addCommandMessage(text, lines.join('\n')) + return + } + case 'tools': { + const all = registry?.list() ?? [] + const filtered = subArg ? all.filter((t) => t.serverName === subArg) : all + if (filtered.length === 0) { + addCommandMessage(text, subArg ? `No tools on server "${subArg}".` : 'No MCP tools available.') + return + } + const lines = [subArg ? `MCP tools on ${subArg}:` : 'All MCP tools:'] + for (const t of filtered) { + const desc = t.description ? ` — ${t.description.slice(0, 160).replace(/\s+/g, ' ').trim()}` : '' + lines.push(` ${t.callableName}${desc}`) + } + addCommandMessage(text, lines.join('\n')) + return + } + case 'auth': { + if (!subArg) { + addCommandMessage(text, 'Usage: /mcp auth ') + return + } + addCommandMessage( + text, + `OAuth runs automatically the first time "${subArg}" connects. If you have stale tokens, run /mcp logout ${subArg} first, then restart the CLI.`, + ) + return + } + case 'logout': { + if (!subArg) { + addCommandMessage(text, 'Usage: /mcp logout ') + return + } + try { + await getTokenStorage().clear(subArg) + addCommandMessage(text, `Removed stored OAuth tokens for "${subArg}". Restart the CLI to authenticate again.`) + } catch (err) { + addCommandMessage(text, `Failed to clear tokens: ${err instanceof Error ? err.message : String(err)}`) + } + return + } + case 'refresh': { + addCommandMessage( + text, + 'Config changes require a restart. Quit the CLI and run `xc` again to pick up new mcpServers entries.', + ) + return + } + default: { + addCommandMessage(text, `Unknown subcommand: /mcp ${sub}. Available: list, tools, auth, logout, refresh.`) + return + } + } + } + // RENDERING ARCHITECTURE // // `ChatInput` owns the ENTIRE terminal region below the initial header: diff --git a/packages/cli/tests/e2e/scenarios/24-mcp-stdio.ts b/packages/cli/tests/e2e/scenarios/24-mcp-stdio.ts new file mode 100644 index 0000000..83156a1 --- /dev/null +++ b/packages/cli/tests/e2e/scenarios/24-mcp-stdio.ts @@ -0,0 +1,101 @@ +import path from 'node:path' + +import type { Scenario } from '../framework/types.js' + +// Minimal stdio MCP server, inlined as source so the scenario is +// self-contained (no cross-package file references). Implements the +// handful of methods McpClient.connect → listTools → callTool needs; +// the rest are answered with method-not-found so the SDK falls back. +// The `greet` tool stamps an opaque marker into its response so we can +// assert from the assistant text that the call actually round-tripped +// — a plain "Hello, World!" could be hallucinated. +const MOCK_SERVER_SRC = String.raw`#!/usr/bin/env node +let buf = '' +process.stdin.setEncoding('utf-8') +process.stdin.on('data', (chunk) => { + buf += chunk + let nl + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl) + buf = buf.slice(nl + 1) + if (line.trim()) try { handle(JSON.parse(line)) } catch (e) { process.stderr.write(String(e) + '\n') } + } +}) +function send(m) { process.stdout.write(JSON.stringify(m) + '\n') } +function handle(msg) { + const { method, id, params } = msg + if (method === 'initialize') { + send({ jsonrpc: '2.0', id, result: { protocolVersion: '2024-11-05', capabilities: { tools: {} }, serverInfo: { name: 'mock-e2e', version: '1.0.0' } } }) + } else if (method === 'tools/list') { + send({ jsonrpc: '2.0', id, result: { tools: [ + { name: 'greet', description: 'Greet a person by name and return a friendly hello.', inputSchema: { type: 'object', properties: { name: { type: 'string' } }, required: ['name'] } } + ] } }) + } else if (method === 'tools/call') { + const name = params && params.name + const args = (params && params.arguments) || {} + if (name === 'greet') { + const who = typeof args.name === 'string' ? args.name : 'stranger' + send({ jsonrpc: '2.0', id, result: { content: [{ type: 'text', text: 'Hello, ' + who + '! [MCP_MARKER_AB12CD34]' }] } }) + } else if (typeof id !== 'undefined') { + send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Unknown tool: ' + name } }) + } + } else if (method === 'resources/list') { + send({ jsonrpc: '2.0', id, result: { resources: [] } }) + } else if (typeof id !== 'undefined') { + send({ jsonrpc: '2.0', id, error: { code: -32601, message: 'Method not found: ' + method } }) + } +} +` + +const scenario: Scenario = { + id: '24-mcp-stdio', + name: 'MCP stdio: model calls mcp__mock__greet and quotes the server-stamped marker', + async run(ctx) { + // 1. Write the inline mock MCP server into the tmpDir and the user + // config that points to it. The harness already isolates + // X_CODE_HOME under /.x-code-home, so the same scenario + // can run in parallel without trampling on the real ~/.x-code. + await ctx.writeFile('mock-server.mjs', MOCK_SERVER_SRC) + const serverPath = path.join(ctx.tmpDir, 'mock-server.mjs') + + await ctx.mkdir('.x-code-home') + await ctx.writeFile( + '.x-code-home/config.json', + JSON.stringify( + { + // X_CODE_MODEL is set by the harness, so we don't need `model`. + mcpServers: { + mock: { + command: process.execPath, + args: [serverPath], + }, + }, + }, + null, + 2, + ), + ) + + // 2. Run the CLI. --trust short-circuits the per-tool ask prompt so + // the model can call mcp__mock__greet without onAskPermission + // blocking print-mode (no UI to answer the dialog). + const r = await ctx.runCli( + [ + 'There is an MCP server named "mock" connected. It exposes a tool', + 'mcp__mock__greet that takes { name: string } and returns a greeting', + 'string. Call it with name="World" and then quote the EXACT text the', + 'tool returned in your reply.', + ].join(' '), + { args: ['--trust', '--max-turns', '5'] }, + ) + + ctx.expect.exitCode(r, 0) + ctx.expect.toolCalled(r, 'mcp__mock__greet', { name: 'World' }) + // The marker is a random-looking token the server stamps in. Models + // that didn't actually wait for the tool result can't reproduce it. + ctx.expect.assistantMentions(r, /MCP_MARKER_AB12CD34/) + ctx.expect.noToolErrors(r) + }, +} + +export default scenario diff --git a/packages/core/package.json b/packages/core/package.json index 2d284ea..c907c76 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -32,6 +32,7 @@ "@ai-sdk/openai": "^3.0.0", "@ai-sdk/openai-compatible": "^2.0.0", "@ai-sdk/xai": "^3.0.0", + "@modelcontextprotocol/sdk": "^1.29.0", "@tavily/core": "^0.7.0", "@vscode/ripgrep": "^1.17.0", "ai": "^6.0.0", diff --git a/packages/core/src/agent/loop.ts b/packages/core/src/agent/loop.ts index 5eeb2bd..a23a59a 100644 --- a/packages/core/src/agent/loop.ts +++ b/packages/core/src/agent/loop.ts @@ -9,6 +9,8 @@ import { streamText } from 'ai' import type { LanguageModel, UserContent } from 'ai' import { buildKnowledgeContext } from '../knowledge/loader.js' +import { listMcpResources, readMcpResource } from '../mcp/resources.js' +import { bridgeMcpTool, toSystemPromptEntries } from '../mcp/tool-bridge.js' import { applyCacheControl } from '../providers/cache-control.js' import { getThinkingProviderOptions, mergeThinkingOptions } from '../providers/thinking.js' import { toolRegistry, truncateToolResult } from '../tools/index.js' @@ -208,6 +210,20 @@ function buildTools(options: AgentOptions) { tools.task = createTaskTool(options.subAgentRegistry) } + // MCP tools: declared without `execute` so the AI SDK leaves them in + // `result.toolCalls` for processToolCalls to hand-dispatch through the + // permission / loop-guard / abortSignal pipeline. + if (options.mcpRegistry) { + // Two universal MCP-aware built-ins. Only registered when MCP is + // active so a model without any MCP context doesn't see them and + // start hallucinating resource URIs. + tools.listMcpResources = listMcpResources + tools.readMcpResource = readMcpResource + for (const entry of options.mcpRegistry.list()) { + tools[entry.callableName] = bridgeMcpTool(entry) + } + } + const filter = options.toolFilter if (filter) { if (filter.allow) { @@ -493,6 +509,12 @@ export async function agentLoop( isGitRepo, planMode: state.permissionMode === 'plan', planFilePath: state.currentPlanPath ?? undefined, + // Pass MCP tools so the `## MCP Tools` section is appended. + // Empty / absent registry → buildSystemPrompt's placeholder + // resolves to "" and the prompt is byte-identical to the + // pre-MCP shape, preserving prefix-cache for sessions + // without MCP configured. + mcpTools: options.mcpRegistry ? toSystemPromptEntries(options.mcpRegistry.list()) : undefined, }) } const systemPrompt = state.systemPromptCache diff --git a/packages/core/src/agent/system-prompt.ts b/packages/core/src/agent/system-prompt.ts index 52c1546..bad4f01 100644 --- a/packages/core/src/agent/system-prompt.ts +++ b/packages/core/src/agent/system-prompt.ts @@ -21,7 +21,7 @@ You have access to these tools: - webFetch: Fetch and extract content from URLs - askUser: Ask the user clarifying questions with choices - todoWrite: Track multi-step tasks with a live checklist visible to the user -- task: Delegate a task to a specialized sub-agent (explore, plan, review, general-purpose) +- task: Delegate a task to a specialized sub-agent (explore, plan, review, general-purpose){mcpCapabilities} ## Sub-agent Delegation Use the task tool to delegate research, exploration, planning, or review tasks to a specialized sub-agent. Sub-agents run in isolated context — they don't see your conversation history and their intermediate tool calls never pollute your context window. Only the final conclusion comes back. @@ -223,6 +223,60 @@ ${options.knowledgeContext || '(none)'} - IMPORTANT: You MUST NOT use any emojis, icons, or special Unicode symbols in your responses.` } +/** Describes one MCP tool well enough for the system prompt. The + * description is truncated to ~200 chars upstream so it doesn't bloat + * the prompt — overly verbose server descriptions are a real problem + * in the wild. */ +export interface SystemPromptMcpTool { + callableName: string + serverName: string + description: string +} + +/** Format the optional MCP tools block. Returns "" when no tools AND + * no registry are passed, so the byte layout of BASE_SYSTEM_PROMPT + * after substitution exactly matches the pre-MCP version — preserves + * prefix-cache hits for sessions without any MCP configuration. + * + * When MCP is active the block always lists the two built-in + * resource tools (listMcpResources / readMcpResource) at the top + * even if no server-specific tools exist — because the resource + * tools only get registered when MCP is active, so their advertising + * must travel with this same block. */ +function formatMcpCapabilities(mcpTools: readonly SystemPromptMcpTool[] | undefined): string { + if (mcpTools === undefined) return '' + + const lines: string[] = [ + '', + '', + '## MCP Tools', + 'These tools come from connected MCP servers. Prefer internal tools when both fit; use these for capabilities only the server provides.', + '- listMcpResources: List resources exposed by connected MCP servers (with optional `server` filter).', + '- readMcpResource: Read the contents of an MCP resource by URI (URIs come from listMcpResources).', + ] + + if (mcpTools.length === 0) { + return lines.join('\n') + } + + // Group by server for readability. Within a group, preserve incoming + // order (the registry hands them out in a stable order). + const byServer = new Map() + for (const t of mcpTools) { + const list = byServer.get(t.serverName) ?? [] + list.push(t) + byServer.set(t.serverName, list) + } + for (const [server, tools] of byServer) { + lines.push('', `### Server: ${server}`) + for (const t of tools) { + const desc = t.description ? `: ${t.description}` : '' + lines.push(`- ${t.callableName}${desc}`) + } + } + return lines.join('\n') +} + /** Build the full system prompt with dynamic values and optional knowledge context */ export function buildSystemPrompt(options?: { knowledgeContext?: string @@ -235,6 +289,11 @@ export function buildSystemPrompt(options?: { /** Absolute path to the session's plan file. Required when * `planMode === true`; ignored otherwise. */ planFilePath?: string + /** Optional MCP tool surface. When provided, an additional + * `## MCP Tools` section is appended to `## Capabilities`. When + * absent or empty, the prompt body is byte-identical to the + * pre-MCP version. */ + mcpTools?: readonly SystemPromptMcpTool[] }): string { const shellProvider = getShellProvider() @@ -243,6 +302,7 @@ export function buildSystemPrompt(options?: { .replace(/\{cwd\}/g, process.cwd()) .replace(/\{model\}/g, options?.modelId ?? 'unknown') .replace(/\{isGitRepo\}/g, options?.isGitRepo ? 'yes' : 'no') + .replace(/\{mcpCapabilities\}/g, formatMcpCapabilities(options?.mcpTools)) if (options?.knowledgeContext) { prompt += '\n\n' + options.knowledgeContext diff --git a/packages/core/src/agent/tool-execution.ts b/packages/core/src/agent/tool-execution.ts index 0200df4..0446334 100644 --- a/packages/core/src/agent/tool-execution.ts +++ b/packages/core/src/agent/tool-execution.ts @@ -4,6 +4,8 @@ import path from 'node:path' import type { ModelMessage } from 'ai' +import { isMcpCallableName } from '../mcp/name-mangling.js' +import { classifyDecision } from '../mcp/permissions.js' import { checkPermission } from '../permissions/index.js' import { truncateToolResult } from '../tools/index.js' import { clearProgressReporter, reportProgress } from '../tools/progress.js' @@ -18,6 +20,18 @@ import { isToolErrorString, toolErrorFromUnknown, toolErrorString, toolResultMes import { handleEnterPlanMode, handleExitPlanMode, handleTodoWrite } from './plan-tools.js' import { runSubAgent } from './sub-agents/runner.js' +/** Detect AbortError from any source. Kept local (duplicates the helper + * in loop.ts) because making it a shared utility would force a new + * module just for six lines. Same logic both places. */ +function isAbortError(err: unknown, signal: AbortSignal | undefined): boolean { + if (signal?.aborted) return true + if (err instanceof Error) { + if (err.name === 'AbortError') return true + if (/aborted|AbortError/i.test(err.message)) return true + } + return false +} + /** Count occurrences of a substring without creating intermediate arrays. */ function countOccurrences(content: string, search: string): number { let count = 0 @@ -255,6 +269,76 @@ async function handleTask(ctx: HandlerCtx): Promise { pushToolResult(state, callbacks, toolCallId, toolName, `${result.resultText}\n${statsLine}`) } +/** ── listMcpResources ── + * Pure read against the in-memory registry; no side effects, no need + * for loop-guard or permission. Server filter is optional. */ +async function handleListMcpResources(ctx: HandlerCtx): Promise { + const { input, toolCallId, toolName, state, options, callbacks } = ctx + const registry = options.mcpRegistry + if (!registry) { + pushToolResult(state, callbacks, toolCallId, toolName, toolErrorString('MCP not configured'), true) + return + } + const filter = (input.server as string | undefined)?.trim() || undefined + const items = registry.listResources().filter((r) => !filter || r.serverName === filter) + if (items.length === 0) { + pushToolResult( + state, + callbacks, + toolCallId, + toolName, + filter ? `No resources on server "${filter}".` : 'No resources from any connected MCP server.', + ) + return + } + const lines = items.map((r) => { + const mime = r.mimeType ? ` (${r.mimeType})` : '' + const desc = r.description ? `\n ${r.description}` : '' + return `${r.uri}\t[${r.serverName}] ${r.name}${mime}${desc}` + }) + pushToolResult(state, callbacks, toolCallId, toolName, lines.join('\n')) +} + +/** ── readMcpResource ── + * Forwards to the owning server's client. Errors / abort handled the + * same way as MCP tool calls. */ +async function handleReadMcpResource(ctx: HandlerCtx): Promise { + const { input, toolCallId, toolName, state, options, callbacks } = ctx + const registry = options.mcpRegistry + if (!registry) { + pushToolResult(state, callbacks, toolCallId, toolName, toolErrorString('MCP not configured'), true) + return + } + const uri = (input.uri as string | undefined) ?? '' + if (!uri) { + pushToolResult(state, callbacks, toolCallId, toolName, toolErrorString('Missing `uri` argument'), true) + return + } + const client = registry.resourceServer(uri) + if (!client) { + pushToolResult( + state, + callbacks, + toolCallId, + toolName, + toolErrorString(`Resource URI not known: ${uri} — call listMcpResources first`), + true, + ) + return + } + reportProgress(toolCallId, `Reading ${uri}`) + try { + const result = await client.readResource(uri, options.abortSignal) + pushToolResult(state, callbacks, toolCallId, toolName, truncateToolResult(result.text)) + } catch (err) { + if (isAbortError(err, options.abortSignal)) { + pushToolResult(state, callbacks, toolCallId, toolName, '[Tool execution interrupted by user]', true) + return + } + pushToolResult(state, callbacks, toolCallId, toolName, toolErrorFromUnknown(err), true) + } +} + /** Manual tools that bypass the loop guard and the writeFile/edit/shell * permission + execution pipeline below. Each handler owns its own * pushToolResult call. Adding a new bypass tool is a one-line entry here. */ @@ -267,6 +351,8 @@ const BYPASS_LOOP_GUARD_HANDLERS: Record = { handleEnterPlanMode(input, toolCallId, state, options, callbacks, pushToolResult), exitPlanMode: ({ input, toolCallId, state, callbacks }) => handleExitPlanMode(input, toolCallId, state, callbacks, pushToolResult), + listMcpResources: handleListMcpResources, + readMcpResource: handleReadMcpResource, } /** Run the loop-guard machinery for a non-bypass tool. Returns true if the @@ -408,6 +494,15 @@ async function handleToolCall( return } + // MCP tools route through their own permission path (per-tool ask + + // always-allow file) rather than the writeFile/edit/shell rules. They + // still go through the loop-guard so the model can't spin on a + // failing MCP call indefinitely. + if (isMcpCallableName(ctx.toolName)) { + await handleMcpToolCall(ctx, deferred) + return + } + if (await applyLoopGuard(ctx, deferred)) return if (!(await checkWriteOrShellPermission(ctx))) return @@ -417,6 +512,98 @@ async function handleToolCall( pushToolResult(state, callbacks, ctx.toolCallId, ctx.toolName, truncateToolResult(result.output), result.isError) } +/** Dispatch an MCP tool call. Sits parallel to the writeFile/edit/shell + * pipeline above — same loop-guard, same abort handling, but using the + * per-tool permission store and the MCP registry's callTool. */ +async function handleMcpToolCall(ctx: HandlerCtx, deferred: ModelMessage[]): Promise { + const { toolName, input, toolCallId, state, options, callbacks } = ctx + const registry = options.mcpRegistry + const permissions = options.mcpPermissionStore + + if (!registry) { + pushToolResult( + state, + callbacks, + toolCallId, + toolName, + toolErrorString(`MCP not configured; tool ${toolName} unavailable`), + true, + ) + return + } + + const entry = registry.get(toolName) + if (!entry) { + pushToolResult(state, callbacks, toolCallId, toolName, toolErrorString(`MCP tool not found: ${toolName}`), true) + return + } + + // Loop-guard FIRST: even denied-by-mode calls count as the model + // "attempting" something, and we want to catch a loop of denials too. + if (await applyLoopGuard(ctx, deferred)) return + + // Plan mode: MCP tools are opaque (we don't know if they write or + // not), so the only safe stance is "no". The model will see the + // denial as a tool result and should call exitPlanMode if it really + // needs external tools to proceed. + if (state.permissionMode === 'plan') { + pushToolResult( + state, + callbacks, + toolCallId, + toolName, + 'MCP tools are disabled in plan mode. Call exitPlanMode first if you need this tool.', + true, + ) + return + } + + // Permission gate. trustMode bypasses everything; otherwise consult + // the store (session + persisted), and fall back to asking the user. + let approved = options.trustMode + if (!approved && permissions) approved = await permissions.isApproved(toolName) + + if (!approved) { + let decision: 'yes' | 'always' | 'no' + try { + decision = await callbacks.onAskPermission({ toolCallId, toolName, input }) + } catch (err) { + if (isAbortError(err, options.abortSignal)) { + pushToolResult(state, callbacks, toolCallId, toolName, '[Tool execution interrupted by user]', true) + return + } + throw err + } + if (options.abortSignal?.aborted) { + pushToolResult(state, callbacks, toolCallId, toolName, '[Tool execution interrupted by user]', true) + return + } + const choice = classifyDecision(decision) + if (choice === 'deny') { + pushToolResult(state, callbacks, toolCallId, toolName, 'Permission denied by user.') + return + } + if (permissions) { + if (choice === 'allow-always') await permissions.approvePermanently(toolName) + else permissions.approveForSession(toolName) + } + } + + // Execute. abortSignal threaded all the way down to the SDK request + // so Esc immediately cancels in-flight MCP calls. + reportProgress(toolCallId, `Calling ${entry.serverName}/${entry.rawName}`) + try { + const result = await registry.callTool(toolName, input, options.abortSignal) + pushToolResult(state, callbacks, toolCallId, toolName, truncateToolResult(result.text), result.isError) + } catch (err) { + if (isAbortError(err, options.abortSignal)) { + pushToolResult(state, callbacks, toolCallId, toolName, '[Tool execution interrupted by user]', true) + return + } + pushToolResult(state, callbacks, toolCallId, toolName, toolErrorFromUnknown(err), true) + } +} + /** Collect every toolCallId the AI SDK actually committed to the * assistant message in this turn. The SDK's `result.toolCalls` promise * is independent of `response.messages` — when zod validation rejects diff --git a/packages/core/src/config/index.ts b/packages/core/src/config/index.ts index aadb333..9fc181f 100644 --- a/packages/core/src/config/index.ts +++ b/packages/core/src/config/index.ts @@ -97,6 +97,18 @@ export interface UserConfig { * because core doesn't depend on the CLI's theme list. Unknown * values fall back to the default ('dark') silently. */ theme?: string + /** MCP server declarations. Loose-typed here because the schema is + * validated in `mcp/config-schema.ts` — we don't want to drag a Zod + * type into the config module's surface. Loader uses + * `parseServersBlock` to validate before constructing clients. */ + mcpServers?: Record +} + +/** Path to the user config file. Exposed so other modules that want to + * read the same JSON (e.g. the MCP loader for the `mcpServers` field) + * honour the X_CODE_HOME override automatically. */ +export function getUserConfigPath(): string { + return userConfigPath() } function userConfigPath(): string { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 733486d..93a8107 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,3 +90,22 @@ export { pickLatestSession, } from './agent/session-store.js' export type { LoadedSession, SessionListEntry } from './agent/session-store.js' + +// MCP — Model Context Protocol client support. +export { McpRegistry, emptyRegistry } from './mcp/registry.js' +export type { RegisteredServer } from './mcp/registry.js' +export { loadMcpServers, loadMcpFromDisk } from './mcp/loader.js' +export type { LoadOptions as McpLoadOptions, LoadResult as McpLoadResult, OAuthProviderFactory } from './mcp/loader.js' +export { McpPermissionStore, classifyDecision } from './mcp/permissions.js' +export type { McpPermissionDecision } from './mcp/permissions.js' +export { isProjectTrusted, trustProject, promptForTrust, buildServerPreview } from './mcp/trust.js' +export type { TrustChoice } from './mcp/trust.js' +export { McpTokenStorage, getTokenStorage, setTokenStorageForTesting } from './mcp/oauth/token-storage.js' +export type { StoredServerAuth } from './mcp/oauth/token-storage.js' +export { McpOAuthProvider, createOAuthProviderFactory } from './mcp/oauth/provider.js' +export { startCallbackServer } from './mcp/oauth/callback-server.js' +export type { McpServerConfig, McpServerStatus, McpToolEntry, McpResourceEntry, McpCallResult } from './mcp/types.js' +export { isStdioConfig, isHttpConfig } from './mcp/types.js' +export { buildCallableName, isMcpCallableName, MCP_PREFIX } from './mcp/name-mangling.js' +export { expandEnvDeep, expandEnvString, EnvExpansionError } from './mcp/expand-env.js' +export { parseServersBlock, parseServerConfig, mcpServersSchema } from './mcp/config-schema.js' diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts new file mode 100644 index 0000000..1d7ef7a --- /dev/null +++ b/packages/core/src/mcp/client.ts @@ -0,0 +1,274 @@ +// @x-code-cli/core — Per-server MCP client wrapper +// +// One McpClient instance == one server connection. The class hides the +// SDK's slightly awkward two-object setup (`new Client(...)` + +// `new XxxTransport(...)` + `client.connect(transport)`) behind one +// `connect()` method, owns transport teardown on `close()`, and exposes a +// narrow surface (listTools / callTool / listResources / readResource / +// close) that the registry actually needs. +// +// abortSignal threading: every server-bound RPC method takes an optional +// AbortSignal and forwards it via `RequestOptions.signal`. When the user +// hits Esc mid-tool-call the agent loop's signal aborts the SDK request, +// which closes the JSON-RPC future without killing the underlying +// connection — the next call can reuse the same transport. +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' +import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' + +import { Stream } from 'node:stream' + +import { debugLog } from '../utils.js' +import { + type McpCallResult, + type McpResourceEntry, + type McpServerConfig, + isHttpConfig, + isStdioConfig, +} from './types.js' + +/** How many tail lines of stderr to keep around for diagnostics. + * When a stdio server dies on startup or fails mid-call, surfacing the + * last bit of its stderr in `/mcp list` is the difference between a + * meaningful error and a useless "exit code 1". */ +const STDERR_TAIL_LINES = 20 + +const CLIENT_INFO = { name: 'x-code-cli', version: '0.2.10' } + +/** Default first-connect timeout (ms). Overridable per-server via the + * config's `timeout` field. 30s is generous — community stdio servers + * are usually up in 100-500ms; the budget is for slow npx installs on + * cold cache, not normal operation. */ +const DEFAULT_CONNECT_TIMEOUT_MS = 30_000 + +export interface ConnectInfo { + toolCount: number + resourceCount: number +} + +export class McpClient { + /** SDK client. Only present after a successful connect. */ + private client: Client | null = null + /** SDK transport. Owned by us so we can `close()` it cleanly. */ + private transport: Transport | null = null + /** Rolling tail of stderr (stdio servers only). */ + private stderrTail: string[] = [] + /** Cached results from the last connect, served to the registry. */ + private cachedTools: Array<{ name: string; description?: string; inputSchema: Record }> = [] + private cachedResources: McpResourceEntry[] = [] + + constructor( + public readonly serverName: string, + private readonly config: McpServerConfig, + /** Optional OAuth provider for HTTP servers. Stdio servers ignore this. */ + private readonly authProvider?: OAuthClientProvider, + ) {} + + /** Spawn / dial the server and complete the MCP initialize handshake. + * On success, populates internal tool + resource caches. On failure, + * cleans up the transport (no zombie subprocess) and re-throws. */ + async connect(): Promise { + const timeout = this.config.timeout ?? DEFAULT_CONNECT_TIMEOUT_MS + + this.transport = this.buildTransport() + this.client = new Client(CLIENT_INFO, { capabilities: {} }) + + // SDK's connect() runs the initialize roundtrip and resolves once the + // server has acknowledged. Race it against an explicit timer because + // a stuck stdio child (e.g. npx hanging on registry fetch) wouldn't + // surface as an error otherwise — it'd just sit there. + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), timeout) + try { + await this.client.connect(this.transport, { signal: ctrl.signal }) + } catch (err) { + // Tear down the half-open transport so we don't leak a child process. + await this.safeClose() + throw this.enrichError(err) + } finally { + clearTimeout(timer) + } + + // Discover capabilities. Tools/resources are independent — a server + // can offer one without the other — and we tolerate either listing + // throwing (some servers reject `listResources` if they have none). + try { + const tools = await this.client.listTools() + this.cachedTools = (tools.tools ?? []).map((t) => ({ + name: t.name, + description: t.description ?? '', + inputSchema: (t.inputSchema as Record) ?? {}, + })) + } catch (err) { + debugLog('mcp.listTools-failed', `${this.serverName}: ${String(err)}`) + this.cachedTools = [] + } + + try { + const resources = await this.client.listResources() + this.cachedResources = (resources.resources ?? []).map((r) => ({ + uri: r.uri, + name: r.name ?? r.uri, + description: r.description, + mimeType: r.mimeType, + serverName: this.serverName, + })) + } catch (err) { + debugLog('mcp.listResources-failed', `${this.serverName}: ${String(err)}`) + this.cachedResources = [] + } + + return { + toolCount: this.cachedTools.length, + resourceCount: this.cachedResources.length, + } + } + + /** Tools discovered at connect time. Stable for the connection lifetime; + * refresh by calling connect() again on a fresh McpClient. */ + tools(): ReadonlyArray<{ name: string; description?: string; inputSchema: Record }> { + return this.cachedTools + } + + resources(): ReadonlyArray { + return this.cachedResources + } + + async callTool(name: string, args: unknown, signal?: AbortSignal): Promise { + if (!this.client) throw new Error(`MCP server "${this.serverName}" is not connected`) + const result = await this.client.callTool( + { name, arguments: args as Record | undefined }, + undefined, + { signal }, + ) + return flattenCallResult(result) + } + + async readResource(uri: string, signal?: AbortSignal): Promise<{ text: string; mimeType?: string }> { + if (!this.client) throw new Error(`MCP server "${this.serverName}" is not connected`) + const result = await this.client.readResource({ uri }, { signal }) + // Resources return an array of content blocks; concatenate text + // representations, preserving the first mimeType for the caller. + const parts: string[] = [] + let mimeType: string | undefined + for (const c of result.contents ?? []) { + mimeType ??= (c as { mimeType?: string }).mimeType + const text = (c as { text?: string }).text + if (typeof text === 'string') parts.push(text) + else if ((c as { blob?: string }).blob !== undefined) { + parts.push(`[binary content omitted, mimeType=${mimeType ?? 'unknown'}]`) + } + } + return { text: parts.join('\n'), mimeType } + } + + /** Snapshot the last N stderr lines for diagnostics. Empty for HTTP. */ + stderr(): string { + return this.stderrTail.join('\n') + } + + async close(): Promise { + await this.safeClose() + } + + // ── internals ────────────────────────────────────────────────────────── + + private buildTransport(): Transport { + if (isStdioConfig(this.config)) { + const t = new StdioClientTransport({ + command: this.config.command, + args: this.config.args, + env: this.config.env, + cwd: this.config.cwd, + // Pipe stderr so we can capture diagnostics. Default "inherit" + // would dump the child's noise into the parent CLI's terminal, + // scrambling our cell-buffer UI. + stderr: 'pipe', + }) + const stderr: Stream | null = t.stderr + if (stderr) { + stderr.on('data', (chunk: Buffer | string) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString('utf8') + for (const line of text.split(/\r?\n/)) { + if (!line) continue + this.stderrTail.push(line) + if (this.stderrTail.length > STDERR_TAIL_LINES) this.stderrTail.shift() + } + }) + } + return t + } + + if (isHttpConfig(this.config)) { + return new StreamableHTTPClientTransport(new URL(this.config.url), { + requestInit: this.config.headers ? { headers: this.config.headers } : undefined, + authProvider: this.authProvider, + }) + } + + // Schema validation upstream should prevent this, but be defensive. + throw new Error(`mcp server "${this.serverName}": unrecognised config shape`) + } + + private async safeClose(): Promise { + // SDK's Client.close() also closes the transport. We try client first + // because it sends a proper shutdown notification; falling back to + // transport.close() if the client was never built (e.g. constructor + // threw before assignment). + try { + if (this.client) { + await this.client.close() + } else if (this.transport) { + await this.transport.close() + } + } catch (err) { + debugLog('mcp.close-error', `${this.serverName}: ${String(err)}`) + } finally { + this.client = null + this.transport = null + } + } + + /** Attach stderr tail (if any) to a connect error so /mcp list shows + * something more useful than "Connection closed". */ + private enrichError(err: unknown): Error { + const base = err instanceof Error ? err : new Error(String(err)) + if (this.stderrTail.length === 0) return base + const tail = this.stderrTail.slice(-5).join(' | ') + const enriched = new Error(`${base.message} — stderr: ${tail}`) + enriched.stack = base.stack + return enriched + } +} + +/** Flatten MCP call result content blocks into a single string. + * MCP responses are an array of `{ type: "text" | "image" | ... }` + * blocks. For tool_result we only care about the text; images/audio are + * noted but not actually surfaced (the agent loop doesn't ingest images + * from tool results, only from user input). */ +function flattenCallResult(result: unknown): McpCallResult { + const r = result as { content?: Array; isError?: boolean } + const blocks = Array.isArray(r.content) ? r.content : [] + const parts: string[] = [] + for (const b of blocks) { + const block = b as { type?: string; text?: string; data?: unknown; mimeType?: string } + if (block.type === 'text' && typeof block.text === 'string') { + parts.push(block.text) + } else if (block.type === 'image') { + parts.push(`[image content omitted, mimeType=${block.mimeType ?? 'unknown'}]`) + } else if (block.type === 'resource') { + // Embedded resource — surface a one-line marker + any nested text. + const nested = (block as { resource?: { text?: string; uri?: string } }).resource + if (nested?.text) parts.push(nested.text) + else if (nested?.uri) parts.push(`[resource: ${nested.uri}]`) + } else if (block.type) { + parts.push(`[${block.type} content]`) + } + } + return { + text: parts.join('\n').trim() || '(empty response)', + isError: r.isError === true, + } +} diff --git a/packages/core/src/mcp/config-schema.ts b/packages/core/src/mcp/config-schema.ts new file mode 100644 index 0000000..7f12bb5 --- /dev/null +++ b/packages/core/src/mcp/config-schema.ts @@ -0,0 +1,95 @@ +// @x-code-cli/core — MCP config Zod schema +// +// Validates the `mcpServers` field of ~/.x-code/config.json (and the +// project-level .x-code/config.json). One schema covers both stdio and +// streamable-http servers; the union discriminator is field presence: +// `command` → stdio, `url` → http. Configs that have neither (or both) +// are rejected before we try to spawn anything. +import { z } from 'zod' + +import type { McpServerConfig } from './types.js' + +/** Single permissive schema covering both transports. Field presence + * (`command` vs `url`) is the discriminator, enforced via superRefine + * rather than z.union — union's per-variant validation hides our + * "exactly one of" rule when neither field is present (Zod just says + * "Invalid input" because no variant matched). With one flat schema + * + superRefine we get readable error messages for every misshape. */ +const serverSchema = z + .object({ + command: z.string().min(1).optional(), + args: z.array(z.string()).optional(), + env: z.record(z.string(), z.string()).optional(), + cwd: z.string().optional(), + url: z.string().url().optional(), + headers: z.record(z.string(), z.string()).optional(), + timeout: z.number().int().positive().optional(), + enabled: z.boolean().optional(), + }) + .superRefine((v, ctx) => { + const hasCommand = typeof v.command === 'string' + const hasUrl = typeof v.url === 'string' + if (hasCommand && hasUrl) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'mcpServers entry has both `command` and `url` — set only one', + }) + } + if (!hasCommand && !hasUrl) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: 'mcpServers entry must set either `command` (stdio) or `url` (http)', + }) + } + // Cross-field validation: HTTP-only fields with stdio config, and + // vice versa. Not strictly required (extra fields are ignored at + // runtime) but the error message catches typos early. + if (hasCommand && typeof v.headers !== 'undefined') { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: '`headers` is only valid for HTTP servers' }) + } + if (hasUrl && (v.args || v.env || v.cwd)) { + ctx.addIssue({ code: z.ZodIssueCode.custom, message: '`args`/`env`/`cwd` are only valid for stdio servers' }) + } + }) + +export const mcpServersSchema = z.record(z.string().min(1), serverSchema) + +/** Validate a single server config; throw with a context-tagged message + * if it fails. Server name is included so the error tells the user which + * entry in their config.json is broken. */ +export function parseServerConfig(name: string, raw: unknown): McpServerConfig { + const result = serverSchema.safeParse(raw) + if (!result.success) { + const issues = result.error.issues.map((i) => i.message).join('; ') + throw new Error(`mcpServers.${name}: ${issues}`) + } + return result.data as McpServerConfig +} + +/** Validate the entire `mcpServers` block. Returns a partial result: + * every entry that parsed cleanly is included; broken ones surface in + * `errors` so the loader can mark them `failed` without aborting the + * whole config. */ +export function parseServersBlock(raw: unknown): { + servers: Record + errors: Array<{ name: string; message: string }> +} { + const servers: Record = {} + const errors: Array<{ name: string; message: string }> = [] + + if (raw === undefined || raw === null) return { servers, errors } + if (typeof raw !== 'object' || Array.isArray(raw)) { + errors.push({ name: '(root)', message: 'mcpServers must be an object' }) + return { servers, errors } + } + + for (const [name, entry] of Object.entries(raw as Record)) { + try { + servers[name] = parseServerConfig(name, entry) + } catch (err) { + errors.push({ name, message: err instanceof Error ? err.message : String(err) }) + } + } + + return { servers, errors } +} diff --git a/packages/core/src/mcp/expand-env.ts b/packages/core/src/mcp/expand-env.ts new file mode 100644 index 0000000..e4af7e7 --- /dev/null +++ b/packages/core/src/mcp/expand-env.ts @@ -0,0 +1,51 @@ +// @x-code-cli/core — Environment variable expansion for MCP configs +// +// Supports two forms inside any string field of an MCP server config: +// ${VAR} — expand or throw if VAR is unset +// ${VAR:-fallback} — expand or use the literal fallback +// +// We intentionally do NOT support arbitrary shell expansion (no `$VAR` +// without braces, no command substitution, no nested `${${A}}`). Anything +// fancier should be done in user-land before X-Code launches. + +/** Thrown when a ${VAR} reference can't be resolved. The loader catches + * this and marks the server `failed` so the rest of the CLI keeps going. */ +export class EnvExpansionError extends Error { + constructor(public varName: string) { + super(`Required environment variable not set: ${varName}`) + this.name = 'EnvExpansionError' + } +} + +const REF_RE = /\$\{([A-Za-z_][A-Za-z0-9_]*)(?::-([^}]*))?\}/g + +/** Expand all ${VAR} references in a single string. */ +export function expandEnvString(input: string, env: NodeJS.ProcessEnv = process.env): string { + return input.replace(REF_RE, (match, name: string, fallback?: string) => { + const v = env[name] + if (v !== undefined && v !== '') return v + if (fallback !== undefined) return fallback + throw new EnvExpansionError(name) + }) +} + +/** Recursively walk a config value and expand strings. Arrays / plain + * objects are traversed; numbers/booleans/null pass through unchanged. + * Returns a deep copy — never mutates the input (important: the input + * may come straight from a cached parsed config object). */ +export function expandEnvDeep(value: T, env: NodeJS.ProcessEnv = process.env): T { + if (typeof value === 'string') { + return expandEnvString(value, env) as unknown as T + } + if (Array.isArray(value)) { + return value.map((v) => expandEnvDeep(v, env)) as unknown as T + } + if (value !== null && typeof value === 'object') { + const out: Record = {} + for (const [k, v] of Object.entries(value as Record)) { + out[k] = expandEnvDeep(v, env) + } + return out as unknown as T + } + return value +} diff --git a/packages/core/src/mcp/loader.ts b/packages/core/src/mcp/loader.ts new file mode 100644 index 0000000..249ab79 --- /dev/null +++ b/packages/core/src/mcp/loader.ts @@ -0,0 +1,277 @@ +// @x-code-cli/core — MCP startup loader +// +// One-shot orchestration called from the CLI entry: read user + project +// configs, apply the trust gate to anything project-level, expand env +// vars, spawn / dial every enabled server in parallel, build a frozen +// registry. Failures on individual servers are recorded but never abort +// the boot — `/mcp list` is the user's window into what went wrong. +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' + +import fs from 'node:fs/promises' +import path from 'node:path' + +import { getUserConfigPath } from '../config/index.js' +import { XCODE_DIR, debugLog } from '../utils.js' +import { McpClient } from './client.js' +import { parseServersBlock } from './config-schema.js' +import { EnvExpansionError, expandEnvDeep } from './expand-env.js' +import { buildCallableName } from './name-mangling.js' +import { McpRegistry, type RegisteredServer, emptyRegistry } from './registry.js' +import { type TrustChoice, buildServerPreview, isProjectTrusted, promptForTrust, trustProject } from './trust.js' +import { type McpResourceEntry, type McpServerConfig, type McpToolEntry, isHttpConfig } from './types.js' + +/** Resolve the OAuth provider for a single HTTP server. Returns + * undefined for stdio (auth is the server's problem) or when no auth + * has been set up yet — the first connect will then 401 and the user + * is told to run `/mcp auth `. */ +export type OAuthProviderFactory = (serverName: string, serverUrl: string) => OAuthClientProvider | undefined + +export interface LoadOptions { + /** mcpServers from ~/.x-code/config.json. Trusted implicitly. */ + userServers: Record | undefined + /** mcpServers from /.x-code/config.json. Requires consent. */ + projectServers: Record | undefined + /** Absolute project path (cwd at CLI start). Used as the trust key. */ + projectPath: string + /** Renders the trust dialog. Same shape as `AgentCallbacks.onAskUser`. */ + askUser: (question: string, options: Array<{ label: string; description: string }>) => Promise + /** Factory for OAuth providers. Optional — pass undefined to disable + * OAuth (HTTP servers requiring auth will be marked `needs_auth`). */ + oauthProviderFor?: OAuthProviderFactory + /** Called after the loader decides to terminate the process — the CLI + * layer wires this to a clean shutdown path. Defaults to no-op + * (caller is responsible). */ + onExitRequested?: () => void +} + +export interface LoadResult { + registry: McpRegistry + /** Configuration / parse errors collected before any server was even + * contacted. Surfaced in `/mcp list` so users see typos in their + * config alongside actual connection failures. */ + configErrors: Array<{ name: string; message: string }> + /** True iff project-level mcpServers were skipped because the user + * declined trust. The CLI uses this to print a heads-up message. */ + projectSkipped: boolean +} + +/** Load the standard config files from disk + invoke the loader. + * Convenience wrapper used by the CLI entry point so it doesn't have + * to know about file paths. */ +export async function loadMcpFromDisk(opts: { + cwd: string + askUser: LoadOptions['askUser'] + oauthProviderFor?: OAuthProviderFactory + onExitRequested?: () => void +}): Promise { + const userServers = await readMcpServersFromFile(getUserConfigPath()) + const projectServers = await readMcpServersFromFile(path.join(opts.cwd, XCODE_DIR, 'config.json')) + return loadMcpServers({ + userServers, + projectServers, + projectPath: opts.cwd, + askUser: opts.askUser, + oauthProviderFor: opts.oauthProviderFor, + onExitRequested: opts.onExitRequested, + }) +} + +/** Pure loader (no disk I/O on configs — caller injects them). + * Easier to test and lets the CLI control config sourcing. */ +export async function loadMcpServers(options: LoadOptions): Promise { + const configErrors: Array<{ name: string; message: string }> = [] + let projectSkipped = false + + // Validate both blocks up front. parseServersBlock tolerates `undefined` + // and returns empty maps + zero errors in that case, so users with no + // mcpServers configured pay nothing. + const userParsed = parseServersBlock(options.userServers) + configErrors.push(...userParsed.errors.map((e) => ({ name: `user:${e.name}`, message: e.message }))) + + const projectParsed = parseServersBlock(options.projectServers) + configErrors.push(...projectParsed.errors.map((e) => ({ name: `project:${e.name}`, message: e.message }))) + + // Project-level trust gate. If the project has zero servers we skip the + // prompt entirely — there's nothing to consent to. + let projectServersToUse = projectParsed.servers + const projectServerNames = Object.keys(projectServersToUse) + if (projectServerNames.length > 0) { + const trusted = await isProjectTrusted(options.projectPath) + if (!trusted) { + const choice = await askForTrust(options, projectServersToUse) + if (choice === 'exit') { + options.onExitRequested?.() + // Even if the CLI doesn't shut down, returning an empty registry + // keeps the rest of the loader well-defined. + return { registry: emptyRegistry(), configErrors, projectSkipped: true } + } + if (choice === 'skip') { + projectServersToUse = {} + projectSkipped = true + } + if (choice === 'trust') { + await trustProject(options.projectPath).catch((err) => { + debugLog('mcp.trust-write-failed', String(err)) + }) + } + } + } + + // Merge user + project. Project-level entries shadow user-level entries + // on name conflict (project wins by design — user explicitly trusted it). + const merged: Record = { ...userParsed.servers, ...projectServersToUse } + + // No servers configured anywhere → fast-path with an empty registry. + if (Object.keys(merged).length === 0) { + return { registry: emptyRegistry(), configErrors, projectSkipped } + } + + // Spawn / dial in parallel. Each per-server promise is wrapped in + // .then/.catch so one timeout doesn't trip the whole boot. + const tasks = Object.entries(merged).map(async ([name, rawConfig]) => { + const result = await connectOneServer(name, rawConfig, options.oauthProviderFor) + return result + }) + const results = await Promise.all(tasks) + + // Assemble the registry. Tool name collisions are resolved in + // insertion order (first wins; subsequent get hash suffixes), so we + // sort by server name for stability — otherwise the order would + // depend on which connect() resolved first. + results.sort((a, b) => a.server.name.localeCompare(b.server.name)) + + const tools: McpToolEntry[] = [] + const resources: McpResourceEntry[] = [] + const taken = new Set() + + for (const r of results) { + for (const t of r.tools) { + const callable = buildCallableName(r.server.name, t.name, taken) + taken.add(callable) + tools.push({ + callableName: callable, + rawName: t.name, + serverName: r.server.name, + description: t.description ?? '', + inputSchema: t.inputSchema, + }) + } + for (const res of r.resources) resources.push(res) + } + + const registry = new McpRegistry({ + servers: results.map((r) => r.server), + tools, + resources, + }) + + return { registry, configErrors, projectSkipped } +} + +async function askForTrust( + options: LoadOptions, + projectServers: Record, +): Promise { + const summaries = Object.entries(projectServers).map(([name, cfg]) => ({ + name, + preview: buildServerPreview(cfg as { command?: string; args?: string[]; url?: string }), + })) + try { + return await promptForTrust(options.projectPath, summaries, options.askUser) + } catch (err) { + // If the prompt machinery itself fails (no TTY etc.), err on the + // safe side: skip project config. Logged for debugging. + debugLog('mcp.trust-prompt-failed', String(err)) + return 'skip' + } +} + +interface ConnectResult { + server: RegisteredServer + tools: ReadonlyArray<{ name: string; description?: string; inputSchema: Record }> + resources: ReadonlyArray +} + +async function connectOneServer( + name: string, + rawConfig: McpServerConfig, + oauthFactory: OAuthProviderFactory | undefined, +): Promise { + // Honour the `enabled: false` switch — register the server but skip + // the connection. Shows up in /mcp list as `disabled`. + if (rawConfig.enabled === false) { + const client = new McpClient(name, rawConfig) + return { + server: { name, client, status: { kind: 'disabled' } }, + tools: [], + resources: [], + } + } + + // Expand ${VAR} references. Done AFTER schema validation but BEFORE + // constructing the client — the client should never see literal + // unexpanded references. + let expanded: McpServerConfig + try { + expanded = expandEnvDeep(rawConfig) + } catch (err) { + const msg = err instanceof EnvExpansionError ? err.message : err instanceof Error ? err.message : String(err) + const client = new McpClient(name, rawConfig) + return { + server: { name, client, status: { kind: 'failed', error: msg } }, + tools: [], + resources: [], + } + } + + const authProvider = oauthFactory && isHttpConfig(expanded) ? oauthFactory(name, expanded.url) : undefined + + const client = new McpClient(name, expanded, authProvider) + try { + const info = await client.connect() + return { + server: { + name, + client, + status: { kind: 'connected', toolCount: info.toolCount, resourceCount: info.resourceCount }, + }, + tools: client.tools(), + resources: client.resources(), + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + // Heuristic: SDK throws `UnauthorizedError` (or similar) on 401. + // Surface that as needs_auth instead of generic failure so the UI + // can route the user to /mcp auth. + const needsAuth = /unauth|401|UnauthorizedError/i.test(msg) && isHttpConfig(expanded) + const status: RegisteredServer['status'] = needsAuth ? { kind: 'needs_auth' } : { kind: 'failed', error: msg } + return { + server: { name, client, status, stderrTail: client.stderr() || undefined }, + tools: [], + resources: [], + } + } +} + +/** Read just the `mcpServers` field out of a JSON config file. Returns + * undefined for missing file / parse error / missing field — all of + * which mean "no MCP servers configured here", never an error to + * surface upward. */ +async function readMcpServersFromFile(filePath: string): Promise | undefined> { + let raw: string + try { + raw = await fs.readFile(filePath, 'utf-8') + } catch { + return undefined + } + try { + const parsed = JSON.parse(raw) as { mcpServers?: Record } + if (parsed && typeof parsed === 'object' && parsed.mcpServers) { + return parsed.mcpServers + } + return undefined + } catch (err) { + debugLog('mcp.config-parse-failed', `${filePath}: ${String(err)}`) + return undefined + } +} diff --git a/packages/core/src/mcp/name-mangling.ts b/packages/core/src/mcp/name-mangling.ts new file mode 100644 index 0000000..080c2ec --- /dev/null +++ b/packages/core/src/mcp/name-mangling.ts @@ -0,0 +1,91 @@ +// @x-code-cli/core — MCP tool name mangling +// +// We expose MCP tools to the model under prefixed names so they can't +// collide with built-in tools (readFile, shell, ...) and so the model +// can tell at a glance "this came from server X": +// +// mcp____ +// +// Both server and tool names are sanitised: any char outside +// [A-Za-z0-9_] becomes `_`. We pick `__` (double underscore) as the +// separator so a tool whose raw name contains a single underscore +// (very common — `read_file`, `list_issues`) is unambiguous. +// +// The model-facing tool name has a hard cap at 64 chars (OpenAI's +// historical limit; Anthropic/Google are higher but 64 keeps us +// portable). Over-length names are truncated and tagged with a 6-char +// content hash so two long, similar names still differ. +// +// Cross-server name collisions are rare in practice but possible +// (two servers both expose `read_file`). We resolve them by hashing +// the server name into a 4-char suffix on whichever entry was added +// second. +import { createHash } from 'node:crypto' + +export const MCP_PREFIX = 'mcp__' +export const MCP_MAX_NAME_LEN = 64 + +function sanitize(part: string): string { + // Replace any run of disallowed chars with a single `_`. Trim leading + // / trailing underscores so we don't end up with `mcp___server__tool_`. + const cleaned = part.replace(/[^A-Za-z0-9_]+/g, '_').replace(/^_+|_+$/g, '') + // Empty after sanitisation (e.g. all-CJK server name) → fall back to a + // hash so we still produce a stable, valid identifier. + if (cleaned === '') { + return shortHash(part, 6) + } + return cleaned +} + +function shortHash(input: string, len: number): string { + return createHash('sha256').update(input).digest('hex').slice(0, len) +} + +/** Build the model-facing tool name for one MCP tool. + * + * `existing` is the set of names already taken in the current registry — + * if the new name collides, we append a 4-char hash of the server name + * to disambiguate. (Hashing the server, not the tool, is intentional: + * the tool name carries the semantic meaning the model relies on; the + * server name is the part the user picked, so the disambiguator is + * more meaningful keyed to it.) */ +export function buildCallableName(serverName: string, rawToolName: string, existing: ReadonlySet): string { + const s = sanitize(serverName) + const t = sanitize(rawToolName) + + let name = `${MCP_PREFIX}${s}__${t}` + + // Over-length: truncate while preserving the prefix + a content hash so + // truncated-different names don't collapse to the same string. + if (name.length > MCP_MAX_NAME_LEN) { + const hash = shortHash(`${serverName}::${rawToolName}`, 6) + const room = MCP_MAX_NAME_LEN - MCP_PREFIX.length - 1 /* underscore */ - hash.length + name = `${MCP_PREFIX}${(s + '__' + t).slice(0, room)}_${hash}` + } + + // Collision: append a 4-char server-name hash. If THAT still collides + // (theoretically possible across many servers), bump the hash length + // until unique — bounded by MCP_MAX_NAME_LEN. + if (existing.has(name)) { + for (let extra = 4; extra <= 12; extra++) { + const suffix = '_' + shortHash(serverName, extra) + const candidate = + name.length + suffix.length <= MCP_MAX_NAME_LEN + ? name + suffix + : name.slice(0, MCP_MAX_NAME_LEN - suffix.length) + suffix + if (!existing.has(candidate)) { + return candidate + } + } + // Pathological: just append a random-ish suffix and hope. + return name.slice(0, MCP_MAX_NAME_LEN - 9) + '_' + shortHash(name + Date.now(), 8) + } + + return name +} + +/** True iff a name looks like one of ours. + * Used by tool-execution to route `mcp__*` calls down the MCP path. */ +export function isMcpCallableName(name: string): boolean { + return name.startsWith(MCP_PREFIX) +} diff --git a/packages/core/src/mcp/oauth/callback-server.ts b/packages/core/src/mcp/oauth/callback-server.ts new file mode 100644 index 0000000..39560ac --- /dev/null +++ b/packages/core/src/mcp/oauth/callback-server.ts @@ -0,0 +1,160 @@ +// @x-code-cli/core — Local OAuth callback receiver +// +// Spins up an ephemeral HTTP server on 127.0.0.1:/callback, +// waits for the user's authorization-server redirect, returns the +// captured `code` + `state` (or error). Auto-closes after the first +// request (or on timeout). +// +// Why ephemeral & random-port: +// - A fixed port collides if two CLIs run concurrently. +// - Random ports require the OAuth provider to be told the URL after +// the listener is up — we expose `start()` returning the actual URL +// before resolving any callbacks. +// +// Security: +// - Bound to 127.0.0.1 only, never 0.0.0.0 — the listener should not +// be reachable from other machines. +// - We only accept the first matching request; subsequent hits return +// a friendly "auth complete, you can close this window" page. +// - We do NOT validate `state` here — that's the SDK's job. We just +// forward whatever the auth server sent back. +import http from 'node:http' +import { AddressInfo } from 'node:net' + +import { debugLog } from '../../utils.js' + +export interface CallbackResult { + code: string + state?: string +} + +export interface RunningCallbackServer { + /** The full redirect URL to advertise to the auth server. */ + url: string + /** Resolves with the code/state on the first valid callback request, + * or rejects on timeout / OAuth error response. */ + waitForCallback: () => Promise + /** Stop accepting new connections and free the port. Idempotent. */ + close: () => void +} + +export interface StartOptions { + /** Max time to wait (ms). Default 5 minutes. */ + timeoutMs?: number + /** Path on which the auth server should redirect. + * Default '/callback'. */ + path?: string +} + +const DEFAULT_TIMEOUT_MS = 5 * 60 * 1000 +const DEFAULT_PATH = '/callback' + +/** Start the listener and return control to the caller so it can hand + * the URL to the auth provider. The actual waiting happens via the + * returned `waitForCallback()` promise. */ +export async function startCallbackServer(options: StartOptions = {}): Promise { + const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS + const expectedPath = options.path ?? DEFAULT_PATH + + let resolveOnce: ((r: CallbackResult) => void) | null = null + let rejectOnce: ((e: Error) => void) | null = null + + const waiter = new Promise((res, rej) => { + resolveOnce = res + rejectOnce = rej + }) + + const server = http.createServer((req, response) => { + if (!req.url) { + response.writeHead(400).end('missing URL') + return + } + // Parse against a dummy base — we only care about pathname + search. + const u = new URL(req.url, 'http://localhost') + if (u.pathname !== expectedPath) { + response.writeHead(404).end('not found') + return + } + + const err = u.searchParams.get('error') + if (err) { + const desc = u.searchParams.get('error_description') ?? '' + response + .writeHead(400, { 'Content-Type': 'text/html' }) + .end(`

Authorization failed

${escapeHtml(err)}: ${escapeHtml(desc)}

`) + rejectOnce?.(new Error(`OAuth callback error: ${err} ${desc}`.trim())) + resolveOnce = null + rejectOnce = null + return + } + + const code = u.searchParams.get('code') + if (!code) { + response.writeHead(400).end('missing code') + rejectOnce?.(new Error('OAuth callback missing `code` parameter')) + resolveOnce = null + rejectOnce = null + return + } + + const state = u.searchParams.get('state') ?? undefined + response + .writeHead(200, { 'Content-Type': 'text/html' }) + .end( + `` + + `

Authorization complete

` + + `

You can close this tab and return to the X-Code CLI.

` + + ``, + ) + resolveOnce?.({ code, state }) + resolveOnce = null + rejectOnce = null + }) + + // Watch for socket errors so a connection reset doesn't crash the + // CLI on Windows where ECONNRESET is more common. + server.on('error', (err) => { + debugLog('mcp.callback-server-error', String(err)) + rejectOnce?.(err) + resolveOnce = null + rejectOnce = null + }) + + // Bind to ephemeral port. listen(0, '127.0.0.1') asks the OS for any + // free port; the actual one comes out of address(). + await new Promise((resolve, reject) => { + server.once('error', reject) + server.listen(0, '127.0.0.1', () => { + server.removeListener('error', reject) + resolve() + }) + }) + + const addr = server.address() as AddressInfo + const url = `http://127.0.0.1:${addr.port}${expectedPath}` + + const timeoutHandle = setTimeout(() => { + rejectOnce?.(new Error(`OAuth callback timed out after ${timeoutMs}ms`)) + resolveOnce = null + rejectOnce = null + }, timeoutMs) + // Clear the timer on either resolution path. + void waiter.finally(() => clearTimeout(timeoutHandle)) + + let closed = false + const close = () => { + if (closed) return + closed = true + server.close() + } + // Auto-close once we've handled the (single) callback. + void waiter.finally(close) + + return { url, waitForCallback: () => waiter, close } +} + +function escapeHtml(s: string): string { + return s.replace(/[&<>"']/g, (c) => + c === '&' ? '&' : c === '<' ? '<' : c === '>' ? '>' : c === '"' ? '"' : ''', + ) +} diff --git a/packages/core/src/mcp/oauth/provider.ts b/packages/core/src/mcp/oauth/provider.ts new file mode 100644 index 0000000..ff1fadb --- /dev/null +++ b/packages/core/src/mcp/oauth/provider.ts @@ -0,0 +1,217 @@ +// @x-code-cli/core — OAuthClientProvider implementation +// +// Hooks the MCP SDK's auth flow up to our persistence + UX: +// +// - tokens() — read from McpTokenStorage +// - saveTokens() — write to McpTokenStorage +// - clientInformation() — read from McpTokenStorage +// - saveClientInformation() — write to McpTokenStorage (covers +// RFC 7591 dynamic registration result) +// - codeVerifier() / save — kept in-process memory; PKCE verifier +// is single-use per auth flow +// - redirectUrl — set to a freshly-started local +// callback server's URL +// - redirectToAuthorization — open the URL in the user's browser +// +// One instance per server. Built lazily by the factory in loader.ts. +// +// External browser launcher: we use `node:child_process` to spawn the +// platform-default opener (`start` on Windows, `open` on macOS, +// `xdg-open` on Linux). No npm dep — the cross-platform `open` package +// is nice but pulls in another 200KB. +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import type { + OAuthClientInformationMixed, + OAuthClientMetadata, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js' + +import { spawn } from 'node:child_process' + +import { debugLog } from '../../utils.js' +import { type RunningCallbackServer, startCallbackServer } from './callback-server.js' +import { McpTokenStorage } from './token-storage.js' + +const CLIENT_METADATA_BASE: Omit = { + client_name: 'X-Code CLI', + client_uri: 'https://github.com/woai3c/x-code-cli', + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', +} + +export interface CreateProviderOptions { + serverName: string + serverUrl: string + storage: McpTokenStorage + /** Callback that should be invoked just before the browser opens, + * e.g. to print "Opening browser for sentry auth..." to the CLI UI. */ + onOpenBrowser?: (url: string) => void +} + +/** Concrete provider, wired up to fetched persisted state + a callback + * server that gets started on demand. Reused across multiple connect / + * refresh attempts for the same server. */ +export class McpOAuthProvider implements OAuthClientProvider { + /** Currently-running callback server. We keep a handle so a second + * call to redirectToAuthorization (after a failed first attempt) + * reuses the same port instead of opening another listener. */ + private callbackServer: RunningCallbackServer | null = null + /** PKCE verifier — kept in memory only, replaced on each new flow. */ + private memoryCodeVerifier: string | null = null + /** Pending callback that the SDK will consume via `finishAuth` on + * the transport. Caller of `waitForAuthCode()` retrieves it. */ + private pendingCode: Promise<{ code: string; state?: string }> | null = null + + constructor(private readonly opts: CreateProviderOptions) {} + + // ── OAuthClientProvider ──────────────────────────────────────────────── + + get redirectUrl(): string { + // Caller MUST call ensureCallbackServer() before this getter is + // first used by the SDK. The SDK calls `redirectUrl` after + // `redirectToAuthorization` has been invoked (per its own flow), + // so the practical ordering holds. + if (!this.callbackServer) { + throw new Error('Callback server not started — redirectToAuthorization must be called first') + } + return this.callbackServer.url + } + + get clientMetadata(): OAuthClientMetadata { + return { + ...CLIENT_METADATA_BASE, + // Filled in by redirectToAuthorization once the server is up. + // Until then the SDK may inspect this object during dynamic + // registration — we use a placeholder; the SDK will overwrite + // the registration response anyway. + redirect_uris: [this.callbackServer?.url ?? 'http://127.0.0.1/callback'], + } + } + + async clientInformation(): Promise { + const stored = await this.opts.storage.get(this.opts.serverName) + return stored?.clientInformation + } + + async saveClientInformation(info: OAuthClientInformationMixed): Promise { + await this.opts.storage.setClientInformation(this.opts.serverName, this.opts.serverUrl, info) + } + + async tokens(): Promise { + const stored = await this.opts.storage.get(this.opts.serverName) + return stored?.tokens + } + + async saveTokens(tokens: OAuthTokens): Promise { + await this.opts.storage.setTokens(this.opts.serverName, this.opts.serverUrl, tokens) + } + + saveCodeVerifier(codeVerifier: string): void { + this.memoryCodeVerifier = codeVerifier + } + + codeVerifier(): string { + if (!this.memoryCodeVerifier) { + throw new Error('No PKCE verifier set — auth flow not in progress') + } + return this.memoryCodeVerifier + } + + async redirectToAuthorization(authorizationUrl: URL): Promise { + // Lazy-start the callback server right before we hand the auth URL + // to the browser, so the URL we advertise (via `redirectUrl`) + // matches what we'll listen on. We rebuild the auth URL with the + // updated redirect_uri reflecting our actual port. + await this.ensureCallbackServer() + authorizationUrl.searchParams.set('redirect_uri', this.callbackServer!.url) + + this.opts.onOpenBrowser?.(authorizationUrl.toString()) + openInBrowser(authorizationUrl.toString()) + + // Stash the pending callback so the caller can `await` it through + // `waitForAuthCode()` while the transport machinery handles the + // token-exchange step. + this.pendingCode = this.callbackServer!.waitForCallback() + } + + // ── Helpers used by /mcp auth handler ───────────────────────────────── + + /** Block until the auth server has redirected back. Resolves with the + * captured code; the caller then calls `transport.finishAuth(code)` + * on the SDK's StreamableHTTPClientTransport. */ + async waitForAuthCode(): Promise<{ code: string; state?: string }> { + if (!this.pendingCode) { + throw new Error('Auth flow not started — redirectToAuthorization was never invoked') + } + try { + return await this.pendingCode + } finally { + this.pendingCode = null + this.memoryCodeVerifier = null + this.callbackServer?.close() + this.callbackServer = null + } + } + + /** Drop any in-progress flow without saving. Safe to call any time. */ + cancel(): void { + this.callbackServer?.close() + this.callbackServer = null + this.pendingCode = null + this.memoryCodeVerifier = null + } + + // ── internals ────────────────────────────────────────────────────────── + + private async ensureCallbackServer(): Promise { + if (this.callbackServer) return + this.callbackServer = await startCallbackServer() + } +} + +/** Best-effort cross-platform `open `. Detached so the CLI doesn't + * block on the browser process; stdio piped to /dev/null so output + * doesn't smear into our terminal UI. Failures are logged but never + * thrown — the user can still copy/paste the URL by hand. */ +function openInBrowser(url: string): void { + try { + let cmd: string + let args: string[] + if (process.platform === 'win32') { + // `start` is a cmd builtin, so we go via cmd /c. + cmd = 'cmd' + // Empty "" arg is the window title — `start "title" "url"` so + // a URL containing spaces (rare but possible in test contexts) + // isn't interpreted as the title. + args = ['/c', 'start', '""', url] + } else if (process.platform === 'darwin') { + cmd = 'open' + args = [url] + } else { + cmd = 'xdg-open' + args = [url] + } + const child = spawn(cmd, args, { stdio: 'ignore', detached: true }) + child.unref() + child.on('error', (err) => debugLog('mcp.browser-open-failed', String(err))) + } catch (err) { + debugLog('mcp.browser-open-threw', String(err)) + } +} + +/** Factory used by loader.ts. Returns undefined for stdio servers — the + * loader skips OAuth construction for those. */ +export function createOAuthProviderFactory( + storage: McpTokenStorage, + onOpenBrowser?: (serverName: string, url: string) => void, +) { + return (serverName: string, serverUrl: string): McpOAuthProvider => { + return new McpOAuthProvider({ + serverName, + serverUrl, + storage, + onOpenBrowser: onOpenBrowser ? (url) => onOpenBrowser(serverName, url) : undefined, + }) + } +} diff --git a/packages/core/src/mcp/oauth/token-storage.ts b/packages/core/src/mcp/oauth/token-storage.ts new file mode 100644 index 0000000..2a780a8 --- /dev/null +++ b/packages/core/src/mcp/oauth/token-storage.ts @@ -0,0 +1,173 @@ +// @x-code-cli/core — Per-server OAuth token + client info persistence +// +// One file: ~/.x-code/mcp-auth.json +// +// { +// "sentry": { +// "url": "https://mcp.sentry.dev", +// "clientInformation": { client_id: "...", client_secret: "...", ... }, +// "tokens": { access_token: "...", refresh_token: "...", expires_in: 3600, ... } +// }, +// ... +// } +// +// Permissions: 0o600 (owner read/write only) on POSIX; on Windows the +// mode bits are ignored but the file lives under the user profile so +// other-user reach is bounded by OS ACLs. Atomic writes (tmp + rename) +// so a crash mid-write can't corrupt previously-good tokens. +// +// The SDK's `OAuthClientProvider` interface (see ../oauth/provider.ts) +// is the actual consumer — this module is the bare persistence layer. +import type { + OAuthClientInformationFull, + OAuthClientInformationMixed, + OAuthTokens, +} from '@modelcontextprotocol/sdk/shared/auth.js' + +import fs from 'node:fs/promises' +import path from 'node:path' + +import { GLOBAL_XCODE_DIR, debugLog } from '../../utils.js' + +/** Resolved at call time so tests can redirect via X_CODE_HOME. */ +function xcodeHome(): string { + return process.env.X_CODE_HOME ?? GLOBAL_XCODE_DIR +} +function authFile(): string { + return path.join(xcodeHome(), 'mcp-auth.json') +} + +export interface StoredServerAuth { + /** Server URL — recorded so we can detect "this stored token belongs + * to a different deployment" if the user repoints config later. */ + url: string + clientInformation?: OAuthClientInformationMixed + tokens?: OAuthTokens + /** UTC ISO timestamp when the most recent tokens were obtained. Used + * to compute expiry locally because OAuth `expires_in` is relative + * to issuance, not absolute. */ + tokensIssuedAt?: string +} + +type FileShape = Record + +export class McpTokenStorage { + private cache: FileShape | null = null + + async get(serverName: string): Promise { + await this.ensureLoaded() + return this.cache![serverName] + } + + async setClientInformation(serverName: string, url: string, info: OAuthClientInformationMixed): Promise { + await this.ensureLoaded() + const entry = (this.cache![serverName] ??= { url }) + entry.url = url + entry.clientInformation = info + await this.flush() + } + + async setTokens(serverName: string, url: string, tokens: OAuthTokens): Promise { + await this.ensureLoaded() + const entry = (this.cache![serverName] ??= { url }) + entry.url = url + entry.tokens = tokens + entry.tokensIssuedAt = new Date().toISOString() + await this.flush() + } + + async clear(serverName: string): Promise { + await this.ensureLoaded() + if (this.cache![serverName]) { + delete this.cache![serverName] + await this.flush() + } + } + + async listServers(): Promise> { + await this.ensureLoaded() + return Object.entries(this.cache!).map(([name, entry]) => ({ + name, + url: entry.url, + hasTokens: !!entry.tokens, + })) + } + + // ── helpers ──────────────────────────────────────────────────────────── + + /** Compute the absolute expiry timestamp from issuedAt + expires_in. + * Returns undefined when either is missing (some servers omit expiry — + * in that case callers should optimistically use the token and let a + * 401 trigger refresh). */ + static expiresAt(stored: StoredServerAuth | undefined): number | undefined { + const t = stored?.tokens + if (!t) return undefined + if (typeof t.expires_in !== 'number') return undefined + const issued = stored.tokensIssuedAt ? Date.parse(stored.tokensIssuedAt) : NaN + if (Number.isNaN(issued)) return undefined + return issued + t.expires_in * 1000 + } + + /** True iff stored tokens exist AND look fresh enough to use + * (i.e. won't expire in the next `skewMs` window). When expiry + * isn't known we return true and let the next 401 drive a refresh. */ + static isAccessTokenLikelyValid(stored: StoredServerAuth | undefined, skewMs = 60_000): boolean { + if (!stored?.tokens?.access_token) return false + const expiresAt = McpTokenStorage.expiresAt(stored) + if (expiresAt === undefined) return true + return Date.now() + skewMs < expiresAt + } + + // ── internals ────────────────────────────────────────────────────────── + + private async ensureLoaded(): Promise { + if (this.cache !== null) return + this.cache = await readFile() + } + + private async flush(): Promise { + if (!this.cache) return + try { + await fs.mkdir(xcodeHome(), { recursive: true }) + const tmp = authFile() + '.tmp' + await fs.writeFile(tmp, JSON.stringify(this.cache, null, 2) + '\n', { + encoding: 'utf-8', + mode: 0o600, + }) + await fs.rename(tmp, authFile()) + } catch (err) { + debugLog('mcp.token-write-failed', String(err)) + } + } +} + +async function readFile(): Promise { + try { + const raw = await fs.readFile(authFile(), 'utf-8') + const parsed = JSON.parse(raw) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as FileShape + } + } catch { + // missing / malformed — start clean + } + return {} +} + +/** Singleton instance. Wiring is simple: CLI startup constructs it once, + * passes it to loadMcpServers (which threads it into per-server OAuth + * providers) and to /mcp auth / /mcp logout handlers. */ +let globalInstance: McpTokenStorage | null = null +export function getTokenStorage(): McpTokenStorage { + if (!globalInstance) globalInstance = new McpTokenStorage() + return globalInstance +} + +/** Test hook — replace the singleton so unit tests don't touch + * ~/.x-code/. Note that X_CODE_HOME also reroutes the file, so most + * tests can just set that env var and avoid this hook. */ +export function setTokenStorageForTesting(s: McpTokenStorage | null): void { + globalInstance = s +} + +export type { OAuthClientInformationFull, OAuthClientInformationMixed, OAuthTokens } diff --git a/packages/core/src/mcp/permissions.ts b/packages/core/src/mcp/permissions.ts new file mode 100644 index 0000000..288aa81 --- /dev/null +++ b/packages/core/src/mcp/permissions.ts @@ -0,0 +1,120 @@ +// @x-code-cli/core — MCP tool permission gate +// +// Sits parallel to packages/core/src/permissions/index.ts (which gates +// built-in writeFile / edit / shell). MCP tools live in their own pool +// because: +// - their names are runtime-discovered, can't be enumerated in a +// static rules table; +// - the user's "this MCP tool is fine, don't ask again" decision is +// persisted per-tool to ~/.x-code/mcp-permissions.json, separate +// from any per-shell-prefix allow rules. +// +// Default policy: every MCP tool starts at "ask" and stays there until +// the user picks "always allow". No name-based heuristics — MCP tools +// are too varied for `list_/read_/search_` style classification to be +// safe (some "list_*" tools mutate, some "create_*" tools are no-ops). +import fs from 'node:fs/promises' +import path from 'node:path' + +import { GLOBAL_XCODE_DIR, debugLog } from '../utils.js' + +/** Resolved at call time so tests can redirect via X_CODE_HOME. */ +function xcodeHome(): string { + return process.env.X_CODE_HOME ?? GLOBAL_XCODE_DIR +} +function permissionsFile(): string { + return path.join(xcodeHome(), 'mcp-permissions.json') +} + +interface StoreShape { + alwaysAllow: string[] +} + +/** In-memory mirror of the persisted file + a session-scoped set for + * "this session only" allows. The persisted set is loaded lazily on + * first check; the session set is cleared on construction and never + * written to disk. */ +export class McpPermissionStore { + private persisted: Set | null = null + private session = new Set() + + /** Pre-load the persisted file. Optional — checks lazy-load anyway. */ + async preload(): Promise { + await this.ensurePersistedLoaded() + } + + /** Returns true iff the user has already approved this tool (either + * by "always allow" persisted, or by "this session" in-memory). */ + async isApproved(callableName: string): Promise { + if (this.session.has(callableName)) return true + await this.ensurePersistedLoaded() + return this.persisted!.has(callableName) + } + + /** Mark this tool approved for the rest of the session only. + * Not persisted. */ + approveForSession(callableName: string): void { + this.session.add(callableName) + } + + /** Mark this tool approved permanently — writes to disk. Failure to + * write is logged but never thrown; the worst case is the user has + * to click "always allow" again next session. */ + async approvePermanently(callableName: string): Promise { + await this.ensurePersistedLoaded() + if (this.persisted!.has(callableName)) return + this.persisted!.add(callableName) + // Also reflect in the session set so the very next call doesn't + // race the disk write. + this.session.add(callableName) + try { + await this.writePersisted() + } catch (err) { + debugLog('mcp.perm-write-failed', String(err)) + // Best-effort: do NOT remove from in-memory set on failure — + // the user explicitly said yes, honour that for the session. + } + } + + private async ensurePersistedLoaded(): Promise { + if (this.persisted !== null) return + this.persisted = await readPersisted() + } + + private async writePersisted(): Promise { + if (!this.persisted) return + await fs.mkdir(xcodeHome(), { recursive: true }) + const tmp = permissionsFile() + '.tmp' + const payload: StoreShape = { alwaysAllow: [...this.persisted].sort() } + // 0600 — readable only by the user. Same posture as mcp-auth.json + // (and same caveat: Windows ignores the mode bits but file is in + // ~/.x-code so practical leakage is limited to other apps running + // as the same user). + await fs.writeFile(tmp, JSON.stringify(payload, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }) + await fs.rename(tmp, permissionsFile()) + } +} + +async function readPersisted(): Promise> { + try { + const raw = await fs.readFile(permissionsFile(), 'utf-8') + const parsed = JSON.parse(raw) as StoreShape + if (parsed && Array.isArray(parsed.alwaysAllow)) { + return new Set(parsed.alwaysAllow.filter((s): s is string => typeof s === 'string')) + } + } catch { + // missing / malformed — start with empty allow list, degrade to all-ask + } + return new Set() +} + +/** Pull "yes" / "always" / "no" out of the existing askPermission + * callback. The callback's contract returns one of those three strings; + * we map them to a structured choice for our own callers. */ +export type McpPermissionDecision = 'allow-once' | 'allow-always' | 'deny' + +export function classifyDecision(raw: 'yes' | 'always' | 'no'): McpPermissionDecision { + if (raw === 'always') return 'allow-always' + if (raw === 'yes') return 'allow-once' + return 'deny' +} diff --git a/packages/core/src/mcp/registry.ts b/packages/core/src/mcp/registry.ts new file mode 100644 index 0000000..71ee5b0 --- /dev/null +++ b/packages/core/src/mcp/registry.ts @@ -0,0 +1,112 @@ +// @x-code-cli/core — MCP registry +// +// Built once at CLI startup by `loadMcpServers`, then frozen for the +// session lifetime. The freeze is deliberate — see CLAUDE.md on +// `systemPromptCache`: any change to the tool surface mid-session would +// invalidate the prompt cache OpenAI-compatible providers rely on for +// prefix matching. `/mcp refresh` works by REPLACING the whole registry +// with a freshly-built one and explicitly setting the session's +// systemPromptCache to null, rather than mutating this one. +import type { McpClient } from './client.js' +import type { McpCallResult, McpResourceEntry, McpServerStatus, McpToolEntry } from './types.js' + +export interface RegisteredServer { + name: string + client: McpClient + status: McpServerStatus + /** When status is `failed`, the most recent stderr tail (stdio only). + * Used by /mcp list to show why a server failed. */ + stderrTail?: string +} + +export class McpRegistry { + /** callableName → entry. callableName is the model-facing + * `mcp____` form; collisions resolved at insert time. */ + private readonly entries = new Map() + /** uri → entry. URIs are unique per spec; if two servers genuinely + * expose the same URI we keep the first and warn (handled by loader). */ + private readonly resources = new Map() + private readonly servers = new Map() + + constructor(input: { servers: RegisteredServer[]; tools: McpToolEntry[]; resources: McpResourceEntry[] }) { + for (const s of input.servers) this.servers.set(s.name, s) + for (const t of input.tools) this.entries.set(t.callableName, t) + for (const r of input.resources) this.resources.set(r.uri, r) + } + + // ── Tool surface ─────────────────────────────────────────────────────── + + /** Snapshot of every model-facing tool name; stable iteration order. + * Consumed by `buildTools` (agent loop) and `buildSystemPrompt`. */ + list(): McpToolEntry[] { + return [...this.entries.values()] + } + + get(callableName: string): McpToolEntry | undefined { + return this.entries.get(callableName) + } + + // ── Resource surface ─────────────────────────────────────────────────── + + listResources(): McpResourceEntry[] { + return [...this.resources.values()] + } + + /** Find the server that owns a given URI so the resource tool can + * dispatch the read. Returns undefined for unknown URIs. */ + resourceServer(uri: string): McpClient | undefined { + const r = this.resources.get(uri) + if (!r) return undefined + return this.servers.get(r.serverName)?.client + } + + // ── Server surface (for /mcp list / status) ─────────────────────────── + + serverStatus(): Array<{ name: string; status: McpServerStatus; stderrTail?: string }> { + return [...this.servers.values()].map((s) => ({ + name: s.name, + status: s.status, + stderrTail: s.stderrTail, + })) + } + + getServer(serverName: string): RegisteredServer | undefined { + return this.servers.get(serverName) + } + + // ── Dispatch ─────────────────────────────────────────────────────────── + + /** Call an MCP tool by its model-facing callable name. Looks up the + * entry, finds its owning server, and forwards to the SDK client. */ + async callTool(callableName: string, args: unknown, signal?: AbortSignal): Promise { + const entry = this.entries.get(callableName) + if (!entry) throw new Error(`MCP tool not found: ${callableName}`) + const server = this.servers.get(entry.serverName) + if (!server) throw new Error(`MCP server gone: ${entry.serverName}`) + return server.client.callTool(entry.rawName, args, signal) + } + + // ── Lifecycle ────────────────────────────────────────────────────────── + + /** Disconnect every server cleanly. Best-effort: one bad shutdown + * doesn't prevent others from running. Called from the CLI exit hook + * and from `/mcp refresh` before building the replacement registry. */ + async shutdown(): Promise { + const tasks: Promise[] = [] + for (const s of this.servers.values()) { + tasks.push( + s.client.close().catch(() => { + // already logged in client.safeClose; nothing useful to do here + }), + ) + } + await Promise.allSettled(tasks) + } +} + +/** Empty registry — used when MCP is disabled entirely (no mcpServers + * in config, or trust dialog rejected). Cheaper than null-checking the + * registry everywhere downstream. */ +export function emptyRegistry(): McpRegistry { + return new McpRegistry({ servers: [], tools: [], resources: [] }) +} diff --git a/packages/core/src/mcp/resources.ts b/packages/core/src/mcp/resources.ts new file mode 100644 index 0000000..9968a30 --- /dev/null +++ b/packages/core/src/mcp/resources.ts @@ -0,0 +1,42 @@ +// @x-code-cli/core — MCP Resources surfaced as two built-in tools +// +// MCP "resources" are server-exposed data the model may want to pull +// (e.g. files exposed by filesystem-server, log entries, DB row dumps). +// Rather than auto-injecting every resource into the conversation +// (token-expensive, often irrelevant), we expose two tools: +// +// - listMcpResources({ server? }) — enumerate URIs the model can fetch +// - readMcpResource({ uri }) — fetch one by URI +// +// Both tools are defined without an `execute` function so the agent +// loop's processToolCalls dispatcher handles them — see +// BYPASS_LOOP_GUARD_HANDLERS in tool-execution.ts. They surface in the +// system prompt only when an MCP registry is configured (buildTools +// gates the inclusion). +import { tool } from 'ai' + +import { z } from 'zod' + +export const listMcpResources = tool({ + description: `List resources exposed by connected MCP servers. + +Output one resource per line: "\t[] ()" with a description on the next indented line when present. + +Use this BEFORE readMcpResource so you have a URI to read. If the model already knows the URI (e.g. from a previous list call), readMcpResource directly is fine.`, + inputSchema: z.object({ + server: z + .string() + .optional() + .describe('Optional server name to filter by. Omit to list resources from all servers.'), + }), + // No execute — handled in tool-execution.ts via BYPASS_LOOP_GUARD_HANDLERS. +}) + +export const readMcpResource = tool({ + description: `Read the contents of an MCP resource by its URI. + +URIs come from listMcpResources. Text resources return their text directly; binary resources surface a one-line marker noting the omitted content.`, + inputSchema: z.object({ + uri: z.string().describe('The resource URI to read, as returned by listMcpResources.'), + }), +}) diff --git a/packages/core/src/mcp/tool-bridge.ts b/packages/core/src/mcp/tool-bridge.ts new file mode 100644 index 0000000..f5636ab --- /dev/null +++ b/packages/core/src/mcp/tool-bridge.ts @@ -0,0 +1,60 @@ +// @x-code-cli/core — MCP tool ↔ AI SDK adapter +// +// Two responsibilities: +// 1. Convert each McpToolEntry into an AI-SDK `tool({...})` definition +// so streamText() advertises it to the model alongside built-ins. +// 2. Trim overlong server-supplied descriptions so they don't bloat +// the system prompt / tool list. +// +// The tools are deliberately defined WITHOUT an `execute` function. The +// AI SDK then routes the model's tool_call into `result.toolCalls` and +// our `processToolCalls` dispatcher handles it manually — same path as +// shell / writeFile / edit. This is what lets us gate every MCP call +// through the permission + loop-guard machinery. +import { jsonSchema, tool } from 'ai' + +import type { McpToolEntry } from './types.js' + +/** Hard cap on the model-facing description length per tool. + * - 200 chars is plenty for "what does this tool do?" guidance. + * - Some MCP servers in the wild paste multi-paragraph docs into the + * description field; left unbounded these blow up the system prompt + * and chew through the prompt cache window. + * - Truncation is character-based, with an ellipsis marker so the model + * knows the string was clipped (the marker also doubles as a hint + * to server authors when they see their own description in /mcp tools). */ +const DESCRIPTION_MAX_CHARS = 200 + +export function truncateDescription(input: string): string { + if (input.length <= DESCRIPTION_MAX_CHARS) return input + // Keep room for the ellipsis marker so the result is still <= cap. + return input.slice(0, DESCRIPTION_MAX_CHARS - 1) + '…' +} + +/** Adapt one MCP tool into an AI SDK Tool. No execute — we hand-dispatch + * in tool-execution. The schema is passed through as raw JSON Schema + * (the SDK has first-class support via `jsonSchema(...)` so we don't + * need a zod conversion step). */ +export function bridgeMcpTool(entry: McpToolEntry) { + return tool({ + description: truncateDescription(entry.description || `MCP tool from ${entry.serverName}`), + // The SDK's jsonSchema() helper takes a JSON Schema object and + // produces a Schema instance compatible with `tool()`. MCP servers + // hand us back well-formed JSON Schema by spec, so no preprocessing. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + inputSchema: jsonSchema(entry.inputSchema as any), + // No execute — manual dispatch in tool-execution.ts gates the call + // through permissions + loop-guard. + }) +} + +/** Build the system-prompt-friendly view of every MCP tool — short + * description + the model-facing name. Used by `system-prompt.ts` to + * render the `## MCP Tools` section. */ +export function toSystemPromptEntries(entries: readonly McpToolEntry[]) { + return entries.map((e) => ({ + callableName: e.callableName, + serverName: e.serverName, + description: truncateDescription(e.description), + })) +} diff --git a/packages/core/src/mcp/trust.ts b/packages/core/src/mcp/trust.ts new file mode 100644 index 0000000..a8eface --- /dev/null +++ b/packages/core/src/mcp/trust.ts @@ -0,0 +1,130 @@ +// @x-code-cli/core — MCP project-level trust gate +// +// A `.x-code/config.json` checked into a git repo can declare MCP servers +// with arbitrary `command` strings — i.e. cloning a hostile repo and +// launching the CLI would silently spawn whatever that command says. +// Before honouring any project-level mcpServers block, we therefore +// require an explicit consent step keyed to the absolute project path. +// +// Persistence file: ~/.x-code/trusted-projects.json (mode 0600). +// Format: { trusted: [{ path: , trustedAt: }, ...] } +// +// User config (~/.x-code/config.json) is NOT subject to this gate — +// the user wrote it themselves; trust is implicit. +import fs from 'node:fs/promises' +import path from 'node:path' + +import { GLOBAL_XCODE_DIR } from '../utils.js' + +/** Resolve `~/.x-code` (or its X_CODE_HOME override) at call time. + * GLOBAL_XCODE_DIR is fixed at module load, but tests redirect via + * the env var — same pattern config/index.ts uses for userConfigPath. */ +function xcodeHome(): string { + return process.env.X_CODE_HOME ?? GLOBAL_XCODE_DIR +} +function trustedFile(): string { + return path.join(xcodeHome(), 'trusted-projects.json') +} + +interface TrustedEntry { + path: string + trustedAt: string +} + +interface TrustedStore { + trusted: TrustedEntry[] +} + +/** Normalise a path for stable comparison across platforms. + * Absolute + resolved + lowercased on Windows (case-insensitive FS), + * preserved case on macOS/Linux. */ +function normalize(p: string): string { + const resolved = path.resolve(p) + return process.platform === 'win32' ? resolved.toLowerCase() : resolved +} + +async function readStore(): Promise { + try { + const raw = await fs.readFile(trustedFile(), 'utf-8') + const parsed = JSON.parse(raw) as unknown + if (parsed && typeof parsed === 'object' && Array.isArray((parsed as TrustedStore).trusted)) { + return parsed as TrustedStore + } + } catch { + // missing file or malformed — start fresh + } + return { trusted: [] } +} + +async function writeStore(store: TrustedStore): Promise { + await fs.mkdir(xcodeHome(), { recursive: true }) + // Atomic write: tmp + rename. Avoids a half-written file if the process + // is killed mid-write (the trust file is small but the principle holds — + // we never want a corrupted JSON to lock the user out of MCP). + const tmp = trustedFile() + '.tmp' + await fs.writeFile(tmp, JSON.stringify(store, null, 2) + '\n', { encoding: 'utf-8', mode: 0o600 }) + await fs.rename(tmp, trustedFile()) +} + +export async function isProjectTrusted(projectPath: string): Promise { + const normalized = normalize(projectPath) + const store = await readStore() + return store.trusted.some((e) => normalize(e.path) === normalized) +} + +export async function trustProject(projectPath: string): Promise { + const normalized = normalize(projectPath) + const store = await readStore() + if (store.trusted.some((e) => normalize(e.path) === normalized)) return + store.trusted.push({ path: path.resolve(projectPath), trustedAt: new Date().toISOString() }) + await writeStore(store) +} + +export type TrustChoice = 'trust' | 'skip' | 'exit' + +/** Ask the user whether to trust the project's MCP config. + * + * Caller passes a generic askUser callback (the same one the agent loop + * uses for askUser tool calls) so trust prompts render in the same dialog + * style as the rest of the UI. We show the actual command strings so the + * user can audit what would run. + * + * Returns: + * 'trust' — user accepted; caller should persist via trustProject(...) + * 'skip' — load only user-level mcpServers + * 'exit' — caller should terminate the CLI */ +export async function promptForTrust( + projectPath: string, + serverSummaries: Array<{ name: string; preview: string }>, + askUser: (question: string, options: Array<{ label: string; description: string }>) => Promise, +): Promise { + const lines = serverSummaries.map((s) => ` • ${s.name}: ${s.preview}`).join('\n') + const question = + `This project wants to load ${serverSummaries.length} MCP server(s):\n` + + lines + + `\n\nThese commands will run on your machine. Trust only if you trust this project.` + + const answer = await askUser(question, [ + { label: 'Trust this project', description: 'Remember this choice. The project MCP servers will load.' }, + { label: 'Skip project MCP', description: 'Use only user-level mcpServers for this session. No write to disk.' }, + { label: 'Exit X-Code', description: 'Close the CLI without loading any MCP servers.' }, + ]) + + const lower = answer.toLowerCase() + if (lower.startsWith('trust')) return 'trust' + if (lower.startsWith('exit')) return 'exit' + return 'skip' +} + +/** Build the one-line preview shown for each server in the trust dialog. + * Stdio servers expose their full command + args; HTTP servers show the + * URL. We intentionally don't truncate — the user needs to see the whole + * thing to make an informed call. */ +export function buildServerPreview(config: { command?: string; args?: string[]; url?: string }): string { + if (config.url) return config.url + if (config.command) { + const parts = [config.command, ...(config.args ?? [])] + return parts.join(' ') + } + return '(invalid config)' +} diff --git a/packages/core/src/mcp/types.ts b/packages/core/src/mcp/types.ts new file mode 100644 index 0000000..348f444 --- /dev/null +++ b/packages/core/src/mcp/types.ts @@ -0,0 +1,80 @@ +// @x-code-cli/core — MCP public types +// +// Shared shapes used across the mcp/ subsystem. Kept dependency-free so the +// loader/registry/UI layers can import without circular hops back into the +// agent loop or CLI. + +/** stdio-based MCP server (local subprocess). */ +export interface McpStdioServerConfig { + command: string + args?: string[] + env?: Record + cwd?: string + /** First-connect timeout in ms. Default 30_000. */ + timeout?: number + /** Default true. Setting to false skips the server entirely. */ + enabled?: boolean +} + +/** Streamable HTTP MCP server (remote). */ +export interface McpHttpServerConfig { + url: string + /** Static headers attached to every request (e.g. `X-Custom: foo`). + * OAuth `Authorization: Bearer ...` is added automatically — do NOT put + * the access token here, store it via the OAuth flow instead. */ + headers?: Record + timeout?: number + enabled?: boolean +} + +export type McpServerConfig = McpStdioServerConfig | McpHttpServerConfig + +/** Discriminator: tells stdio vs http servers apart at runtime. */ +export function isStdioConfig(c: McpServerConfig): c is McpStdioServerConfig { + return 'command' in c +} +export function isHttpConfig(c: McpServerConfig): c is McpHttpServerConfig { + return 'url' in c +} + +/** Per-server runtime status. UI reads this via /mcp list. */ +export type McpServerStatus = + | { kind: 'disabled' } + | { kind: 'connecting' } + | { kind: 'connected'; toolCount: number; resourceCount: number } + | { kind: 'needs_auth'; authUrl?: string } + | { kind: 'failed'; error: string } + +/** One MCP tool, after name-mangling. + * + * callableName is the model-facing name (mcp____); + * rawName is what we pass back to client.callTool — MCP servers don't + * know about our prefix scheme. */ +export interface McpToolEntry { + callableName: string + rawName: string + serverName: string + description: string + /** JSON Schema as received from the server. We pass it directly to the + * AI SDK via `jsonSchema(...)` — no zod conversion. */ + inputSchema: Record +} + +/** One MCP resource (data the server lets us pull). */ +export interface McpResourceEntry { + uri: string + name: string + description?: string + mimeType?: string + serverName: string +} + +/** Result of calling an MCP tool — flattened from MCP's content-blocks + * into something we can shove into a tool_result message. The raw blocks + * are kept on the side in case a future UI wants images/audio. */ +export interface McpCallResult { + /** Text representation suitable for tool_result. */ + text: string + /** True iff the server marked the call as an error (MCP `isError` flag). */ + isError: boolean +} diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index e923d33..4c146b1 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -4,6 +4,8 @@ import type { LanguageModel, ModelMessage } from 'ai' import type { EditDiffPayload } from '../agent/diff.js' import type { SubAgentRegistry } from '../agent/sub-agents/registry.js' import type { SubAgentEvent } from '../agent/sub-agents/types.js' +import type { McpPermissionStore } from '../mcp/permissions.js' +import type { McpRegistry } from '../mcp/registry.js' // ─── Permission ─── @@ -210,6 +212,19 @@ export interface AgentOptions { /** Tool allow/deny filter. Used by sub-agent loops to restrict * which tools the child can call. `task` is always in `deny`. */ toolFilter?: { allow?: string[]; deny?: string[] } + + // ── MCP support ── + + /** MCP registry, populated at CLI startup by loadMcpServers. Absent + * means MCP is disabled entirely (no servers configured) — agent + * loop short-circuits all MCP machinery. The registry itself is + * immutable for the session lifetime; `/mcp refresh` replaces the + * whole object on the next agentLoop entry. */ + mcpRegistry?: McpRegistry + /** Permission store for MCP tool calls. Created once per CLI process, + * caches the persisted always-allow list + session-scoped allows. + * Absent ⇒ tool-execution falls back to ask-every-time semantics. */ + mcpPermissionStore?: McpPermissionStore } // ─── Knowledge ─── diff --git a/packages/core/tests/fixtures/mock-mcp-server.mjs b/packages/core/tests/fixtures/mock-mcp-server.mjs new file mode 100644 index 0000000..e131863 --- /dev/null +++ b/packages/core/tests/fixtures/mock-mcp-server.mjs @@ -0,0 +1,123 @@ +#!/usr/bin/env node +// Minimal stdio MCP server used by integration tests. Implements just +// enough of the protocol that McpClient can complete a handshake, +// enumerate one tool, and round-trip a callTool / readResource. +// +// Wire format: newline-delimited JSON-RPC 2.0 on stdin/stdout. No batching. + +let buf = '' + +process.stdin.setEncoding('utf-8') +process.stdin.on('data', (chunk) => { + buf += chunk + let nl + while ((nl = buf.indexOf('\n')) >= 0) { + const line = buf.slice(0, nl) + buf = buf.slice(nl + 1) + if (!line.trim()) continue + try { + handle(JSON.parse(line)) + } catch (err) { + process.stderr.write(`mock-server parse error: ${err}\n`) + } + } +}) + +function send(msg) { + process.stdout.write(JSON.stringify(msg) + '\n') +} + +function reply(id, result) { + send({ jsonrpc: '2.0', id, result }) +} + +function error(id, code, message) { + send({ jsonrpc: '2.0', id, error: { code, message } }) +} + +function handle(msg) { + const { method, id, params } = msg + + switch (method) { + case 'initialize': + reply(id, { + protocolVersion: '2024-11-05', + capabilities: { tools: {}, resources: {} }, + serverInfo: { name: 'mock-mcp-server', version: '1.0.0' }, + }) + return + + case 'notifications/initialized': + case 'notifications/cancelled': + // Notifications have no id → no response. + return + + case 'tools/list': + reply(id, { + tools: [ + { + name: 'echo', + description: 'Echo input text back to the caller', + inputSchema: { + type: 'object', + properties: { text: { type: 'string' } }, + required: ['text'], + }, + }, + { + name: 'add', + description: 'Add two numbers', + inputSchema: { + type: 'object', + properties: { a: { type: 'number' }, b: { type: 'number' } }, + required: ['a', 'b'], + }, + }, + ], + }) + return + + case 'tools/call': { + const { name, arguments: args } = params ?? {} + if (name === 'echo') { + reply(id, { content: [{ type: 'text', text: String(args?.text ?? '') }] }) + } else if (name === 'add') { + const sum = Number(args?.a ?? 0) + Number(args?.b ?? 0) + reply(id, { content: [{ type: 'text', text: String(sum) }] }) + } else if (name === 'boom') { + reply(id, { content: [{ type: 'text', text: 'simulated error' }], isError: true }) + } else { + error(id, -32601, `Unknown tool: ${name}`) + } + return + } + + case 'resources/list': + reply(id, { + resources: [{ uri: 'mock://hello', name: 'hello.txt', description: 'a greeting', mimeType: 'text/plain' }], + }) + return + + case 'resources/read': { + const uri = params?.uri + if (uri === 'mock://hello') { + reply(id, { contents: [{ uri, mimeType: 'text/plain', text: 'hello world' }] }) + } else { + error(id, -32602, `Unknown resource: ${uri}`) + } + return + } + + case 'ping': + reply(id, {}) + return + + default: + // SDK probes for some optional methods (logging/setLevel, + // resources/subscribe, …). Respond with method-not-found so the + // SDK falls back gracefully rather than hanging on a missing reply. + if (typeof id !== 'undefined') { + error(id, -32601, `Method not found: ${method}`) + } + } +} diff --git a/packages/core/tests/mcp-config-schema.test.ts b/packages/core/tests/mcp-config-schema.test.ts new file mode 100644 index 0000000..c7ccf7a --- /dev/null +++ b/packages/core/tests/mcp-config-schema.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { parseServerConfig, parseServersBlock } from '../src/mcp/config-schema.js' + +describe('parseServerConfig', () => { + it('accepts a valid stdio config', () => { + const cfg = parseServerConfig('filesystem', { + command: 'npx', + args: ['-y', 'pkg'], + env: { FOO: 'bar' }, + }) + expect(cfg).toMatchObject({ command: 'npx', args: ['-y', 'pkg'], env: { FOO: 'bar' } }) + }) + + it('accepts a valid http config', () => { + const cfg = parseServerConfig('sentry', { url: 'https://mcp.example.com' }) + expect(cfg).toMatchObject({ url: 'https://mcp.example.com' }) + }) + + it('rejects config with neither command nor url', () => { + expect(() => parseServerConfig('bad', { timeout: 100 })).toThrow(/either `command`.*or `url`/) + }) + + it('rejects config with both command and url', () => { + expect(() => parseServerConfig('bad', { command: 'foo', url: 'https://x.com' })).toThrow(/both `command` and `url`/) + }) + + it('rejects malformed url', () => { + expect(() => parseServerConfig('bad', { url: 'not-a-url' })).toThrow() + }) + + it('includes the server name in the error message', () => { + expect(() => parseServerConfig('myserver', {})).toThrow(/mcpServers\.myserver/) + }) +}) + +describe('parseServersBlock', () => { + it('returns empty result for undefined input', () => { + const r = parseServersBlock(undefined) + expect(r.servers).toEqual({}) + expect(r.errors).toEqual([]) + }) + + it('parses multiple servers and isolates errors', () => { + const r = parseServersBlock({ + good: { command: 'npx' }, + bad: { timeout: 100 }, + alsoGood: { url: 'https://example.com' }, + }) + expect(Object.keys(r.servers).sort()).toEqual(['alsoGood', 'good']) + expect(r.errors).toHaveLength(1) + expect(r.errors[0].name).toBe('bad') + }) + + it('rejects non-object root', () => { + const r = parseServersBlock([1, 2, 3]) + expect(r.errors[0].message).toMatch(/must be an object/) + }) +}) diff --git a/packages/core/tests/mcp-expand-env.test.ts b/packages/core/tests/mcp-expand-env.test.ts new file mode 100644 index 0000000..bec3b17 --- /dev/null +++ b/packages/core/tests/mcp-expand-env.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' + +import { EnvExpansionError, expandEnvDeep, expandEnvString } from '../src/mcp/expand-env.js' + +describe('expandEnvString', () => { + it('substitutes simple references', () => { + expect(expandEnvString('hello ${NAME}', { NAME: 'world' } as NodeJS.ProcessEnv)).toBe('hello world') + }) + + it('substitutes multiple references in one string', () => { + expect(expandEnvString('${A}/${B}/${C}', { A: '1', B: '2', C: '3' } as NodeJS.ProcessEnv)).toBe('1/2/3') + }) + + it('throws EnvExpansionError on missing variable without default', () => { + expect(() => expandEnvString('${MISSING_VAR}', {} as NodeJS.ProcessEnv)).toThrow(EnvExpansionError) + }) + + it('uses :- fallback when variable missing or empty', () => { + expect(expandEnvString('${X:-fallback}', {} as NodeJS.ProcessEnv)).toBe('fallback') + expect(expandEnvString('${X:-fallback}', { X: '' } as NodeJS.ProcessEnv)).toBe('fallback') + expect(expandEnvString('${X:-fallback}', { X: 'real' } as NodeJS.ProcessEnv)).toBe('real') + }) + + it('leaves non-matching $ patterns alone', () => { + // Single-$ patterns and unterminated ${ should pass through untouched — + // we don't support shell-style $VAR. + expect(expandEnvString('cost: $5', {} as NodeJS.ProcessEnv)).toBe('cost: $5') + expect(expandEnvString('${unfinished', {} as NodeJS.ProcessEnv)).toBe('${unfinished') + }) +}) + +describe('expandEnvDeep', () => { + it('walks arrays and objects', () => { + const input = { + command: '${BIN}', + args: ['--token', '${TOKEN}'], + env: { LOG: '${LEVEL:-info}' }, + timeout: 30000, + } + const env = { BIN: 'node', TOKEN: 'abc' } as NodeJS.ProcessEnv + const out = expandEnvDeep(input, env) + expect(out).toEqual({ + command: 'node', + args: ['--token', 'abc'], + env: { LOG: 'info' }, + timeout: 30000, + }) + }) + + it('does not mutate the input', () => { + const input = { command: '${BIN}' } + const env = { BIN: 'foo' } as NodeJS.ProcessEnv + expandEnvDeep(input, env) + expect(input.command).toBe('${BIN}') + }) + + it('preserves null / boolean / number primitives', () => { + expect(expandEnvDeep({ a: null, b: true, c: 5 } as Record, {} as NodeJS.ProcessEnv)).toEqual({ + a: null, + b: true, + c: 5, + }) + }) +}) diff --git a/packages/core/tests/mcp-integration.test.ts b/packages/core/tests/mcp-integration.test.ts new file mode 100644 index 0000000..7b42655 --- /dev/null +++ b/packages/core/tests/mcp-integration.test.ts @@ -0,0 +1,100 @@ +// Integration test for the MCP stack — wires McpClient up to a real +// child process implementing a minimal stdio MCP server, then exercises +// connect → listTools → callTool → readResource → close end-to-end. +// +// Why a custom mock and not `@modelcontextprotocol/server-filesystem`: +// - the official server pulls in a few hundred KB of deps via npx +// install on first run; flaky in CI without a warm cache +// - we want deterministic tool/resource shapes for assertions +// - fits in 100 lines, lives next to the test that uses it +import { describe, expect, it } from 'vitest' + +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +import { McpClient } from '../src/mcp/client.js' +import { buildCallableName } from '../src/mcp/name-mangling.js' +import { McpRegistry } from '../src/mcp/registry.js' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const MOCK_SERVER = path.join(__dirname, 'fixtures', 'mock-mcp-server.mjs') + +describe('MCP integration (stdio)', () => { + it('connect → list tools → call tool → close', async () => { + const client = new McpClient('mock', { + command: process.execPath, + args: [MOCK_SERVER], + }) + try { + const info = await client.connect() + expect(info.toolCount).toBe(2) + const tools = client.tools() + expect(tools.map((t) => t.name).sort()).toEqual(['add', 'echo']) + + const echoed = await client.callTool('echo', { text: 'hello' }) + expect(echoed.isError).toBe(false) + expect(echoed.text).toBe('hello') + + const summed = await client.callTool('add', { a: 2, b: 3 }) + expect(summed.text).toBe('5') + } finally { + await client.close() + } + }, 15_000) + + it('reads resources end-to-end', async () => { + const client = new McpClient('mock', { command: process.execPath, args: [MOCK_SERVER] }) + try { + await client.connect() + const resources = client.resources() + expect(resources).toHaveLength(1) + expect(resources[0].uri).toBe('mock://hello') + + const content = await client.readResource('mock://hello') + expect(content.text).toBe('hello world') + expect(content.mimeType).toBe('text/plain') + } finally { + await client.close() + } + }, 15_000) + + it('surfaces server-reported errors via isError', async () => { + const client = new McpClient('mock', { command: process.execPath, args: [MOCK_SERVER] }) + try { + await client.connect() + const r = await client.callTool('boom', {}) + expect(r.isError).toBe(true) + } finally { + await client.close() + } + }, 15_000) + + it('registry dispatches by callable name', async () => { + const client = new McpClient('mock', { command: process.execPath, args: [MOCK_SERVER] }) + try { + await client.connect() + const taken = new Set() + const tools = client.tools().map((t) => ({ + callableName: buildCallableName('mock', t.name, taken), + rawName: t.name, + serverName: 'mock', + description: t.description ?? '', + inputSchema: t.inputSchema, + })) + for (const t of tools) taken.add(t.callableName) + + const registry = new McpRegistry({ + servers: [{ name: 'mock', client, status: { kind: 'connected', toolCount: 2, resourceCount: 1 } }], + tools, + resources: [], + }) + + // Verify dispatch goes through the registry's callTool wrapper. + const callable = tools.find((t) => t.rawName === 'echo')!.callableName + const result = await registry.callTool(callable, { text: 'via registry' }) + expect(result.text).toBe('via registry') + } finally { + await client.close() + } + }, 15_000) +}) diff --git a/packages/core/tests/mcp-name-mangling.test.ts b/packages/core/tests/mcp-name-mangling.test.ts new file mode 100644 index 0000000..354e57a --- /dev/null +++ b/packages/core/tests/mcp-name-mangling.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { MCP_MAX_NAME_LEN, MCP_PREFIX, buildCallableName, isMcpCallableName } from '../src/mcp/name-mangling.js' + +describe('buildCallableName', () => { + it('produces mcp____ for clean inputs', () => { + const name = buildCallableName('filesystem', 'read_file', new Set()) + expect(name).toBe('mcp__filesystem__read_file') + }) + + it('sanitises disallowed chars to underscore', () => { + const name = buildCallableName('my-server.v2', 'foo:bar', new Set()) + // Hyphens, dots, colons → "_"; runs collapse to a single underscore + expect(name).toBe('mcp__my_server_v2__foo_bar') + }) + + it('falls back to hash when sanitisation empties a part', () => { + // All-CJK server name has no [A-Za-z0-9_] chars — must still produce + // a valid, unique identifier rather than `mcp____tool`. + const name = buildCallableName('文件系统', 'read', new Set()) + expect(name).toMatch(/^mcp__[a-f0-9]{6}__read$/) + }) + + it('keeps the prefix and stays under the 64-char cap', () => { + const longServer = 'x'.repeat(40) + const longTool = 'y'.repeat(40) + const name = buildCallableName(longServer, longTool, new Set()) + expect(name.length).toBeLessThanOrEqual(MCP_MAX_NAME_LEN) + expect(name.startsWith(MCP_PREFIX)).toBe(true) + }) + + it('disambiguates collisions across servers', () => { + const taken = new Set() + const a = buildCallableName('serverA', 'read', taken) + taken.add(a) + // Same tool name on a "different" server that sanitises to the same id + const b = buildCallableName('serverA', 'read', taken) + expect(a).not.toBe(b) + expect(b.startsWith(a)).toBe(true) // collision suffix appended + }) + + it('always produces a unique name even on repeated collisions', () => { + const taken = new Set() + for (let i = 0; i < 5; i++) { + const name = buildCallableName('s', 't', taken) + expect(taken.has(name)).toBe(false) + taken.add(name) + } + expect(taken.size).toBe(5) + }) +}) + +describe('isMcpCallableName', () => { + it('detects the prefix', () => { + expect(isMcpCallableName('mcp__a__b')).toBe(true) + expect(isMcpCallableName('readFile')).toBe(false) + expect(isMcpCallableName('mcpFoo')).toBe(false) // single underscore — not ours + }) +}) diff --git a/packages/core/tests/mcp-permissions.test.ts b/packages/core/tests/mcp-permissions.test.ts new file mode 100644 index 0000000..1433e2e --- /dev/null +++ b/packages/core/tests/mcp-permissions.test.ts @@ -0,0 +1,72 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { McpPermissionStore, classifyDecision } from '../src/mcp/permissions.js' + +function isolate(): string { + const dir = path.join(os.tmpdir(), 'mcp-perms-test-' + Math.random().toString(36).slice(2)) + process.env.X_CODE_HOME = dir + return dir +} + +describe('McpPermissionStore', () => { + let home: string + beforeEach(() => { + home = isolate() + }) + afterEach(() => { + delete process.env.X_CODE_HOME + }) + + it('starts empty', async () => { + const store = new McpPermissionStore() + expect(await store.isApproved('mcp__foo__bar')).toBe(false) + }) + + it('approves for session only without persisting', async () => { + const store = new McpPermissionStore() + store.approveForSession('mcp__foo__bar') + expect(await store.isApproved('mcp__foo__bar')).toBe(true) + + // New store instance — should still be unapproved (session-only). + const store2 = new McpPermissionStore() + expect(await store2.isApproved('mcp__foo__bar')).toBe(false) + }) + + it('approvePermanently persists across instances', async () => { + const store = new McpPermissionStore() + await store.approvePermanently('mcp__foo__bar') + + const store2 = new McpPermissionStore() + expect(await store2.isApproved('mcp__foo__bar')).toBe(true) + }) + + it('writes a 0600 file with sorted entries', async () => { + const store = new McpPermissionStore() + await store.approvePermanently('mcp__zeta__b') + await store.approvePermanently('mcp__alpha__a') + + const filePath = path.join(home, 'mcp-permissions.json') + const raw = await fs.readFile(filePath, 'utf-8') + const parsed = JSON.parse(raw) as { alwaysAllow: string[] } + expect(parsed.alwaysAllow).toEqual(['mcp__alpha__a', 'mcp__zeta__b']) + }) + + it('ignores re-approving an already-permanent entry', async () => { + const store = new McpPermissionStore() + await store.approvePermanently('mcp__foo__bar') + await store.approvePermanently('mcp__foo__bar') + expect(await store.isApproved('mcp__foo__bar')).toBe(true) + }) +}) + +describe('classifyDecision', () => { + it('maps callback strings to structured choices', () => { + expect(classifyDecision('yes')).toBe('allow-once') + expect(classifyDecision('always')).toBe('allow-always') + expect(classifyDecision('no')).toBe('deny') + }) +}) diff --git a/packages/core/tests/mcp-trust.test.ts b/packages/core/tests/mcp-trust.test.ts new file mode 100644 index 0000000..0639cf5 --- /dev/null +++ b/packages/core/tests/mcp-trust.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import os from 'node:os' +import path from 'node:path' + +import { buildServerPreview, isProjectTrusted, promptForTrust, trustProject } from '../src/mcp/trust.js' + +/** Each test gets its own scratch ~/.x-code under tmpdir so we never touch + * the developer's real trusted-projects.json. */ +function isolate(): string { + const dir = path.join(os.tmpdir(), 'mcp-trust-test-' + Math.random().toString(36).slice(2)) + process.env.X_CODE_HOME = dir + return dir +} + +describe('trust persistence', () => { + beforeEach(() => isolate()) + afterEach(() => { + delete process.env.X_CODE_HOME + }) + + it('reports untrusted by default', async () => { + expect(await isProjectTrusted('/some/path')).toBe(false) + }) + + it('persists a trusted path', async () => { + await trustProject('/foo/bar') + expect(await isProjectTrusted('/foo/bar')).toBe(true) + }) + + it('treats absolute path forms consistently', async () => { + await trustProject(path.resolve('.')) + expect(await isProjectTrusted(path.resolve('.'))).toBe(true) + }) + + it('does not duplicate entries when trustProject is called twice', async () => { + await trustProject('/foo') + await trustProject('/foo') + // Verified indirectly: still reports trusted, no throw on write. + expect(await isProjectTrusted('/foo')).toBe(true) + }) + + it('treats subdirectory as separate from parent', async () => { + await trustProject('/foo') + expect(await isProjectTrusted('/foo/sub')).toBe(false) + }) +}) + +describe('promptForTrust', () => { + beforeEach(() => isolate()) + afterEach(() => { + delete process.env.X_CODE_HOME + }) + + it('maps "Trust this project" answer to "trust"', async () => { + const choice = await promptForTrust('/p', [{ name: 's', preview: 'cmd' }], async () => 'Trust this project') + expect(choice).toBe('trust') + }) + + it('maps "Exit X-Code" answer to "exit"', async () => { + const choice = await promptForTrust('/p', [{ name: 's', preview: 'cmd' }], async () => 'Exit X-Code') + expect(choice).toBe('exit') + }) + + it('falls back to skip on any other / unrecognised answer', async () => { + const choice = await promptForTrust('/p', [{ name: 's', preview: 'cmd' }], async () => '???') + expect(choice).toBe('skip') + }) +}) + +describe('buildServerPreview', () => { + it('renders stdio config as command + args', () => { + expect(buildServerPreview({ command: 'npx', args: ['-y', 'foo'] })).toBe('npx -y foo') + }) + + it('renders http config as URL', () => { + expect(buildServerPreview({ url: 'https://x.com' })).toBe('https://x.com') + }) + + it('falls back when neither command nor url is present', () => { + expect(buildServerPreview({})).toBe('(invalid config)') + }) +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f63116c..5daab9e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -116,6 +116,9 @@ importers: '@ai-sdk/xai': specifier: ^3.0.0 version: 3.0.48(zod@3.25.76) + '@modelcontextprotocol/sdk': + specifier: ^1.29.0 + version: 1.29.0(zod@3.25.76) '@tavily/core': specifier: ^0.7.0 version: 0.7.1 @@ -588,6 +591,12 @@ packages: resolution: {integrity: sha512-bIZEUzOI1jkhviX2cp5vNyXQc6olzb2ohewQubuYlMXZ2Q/XjBO0x0XhGPvc9fjSIiUN0vw+0hq53BJ4eQSJKQ==} engines: {node: ^20.19.0 || ^22.13.0 || >=24} + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@humanfs/core@0.19.1': resolution: {integrity: sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==} engines: {node: '>=18.18.0'} @@ -644,6 +653,16 @@ packages: '@mixmark-io/domino@2.2.0': resolution: {integrity: sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==} + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + '@napi-rs/canvas-android-arm64@0.1.80': resolution: {integrity: sha512-sk7xhN/MoXeuExlggf91pNziBxLPVUqF2CAVnB57KLG/pz7+U5TKG8eXdc3pm0d7Od0WreB6ZKLj37sX9muGOQ==} engines: {node: '>= 10'} @@ -1087,6 +1106,10 @@ packages: resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==} engines: {node: '>=14.6'} + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1111,6 +1134,14 @@ packages: peerDependencies: zod: ^3.25.76 || ^4.1.8 + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} @@ -1176,6 +1207,10 @@ packages: bmp-js@0.1.0: resolution: {integrity: sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==} + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} @@ -1194,10 +1229,18 @@ packages: buffer-crc32@0.2.13: resolution: {integrity: sha512-VO9Ht/+p3SN7SKWqcrgEzjGbRSJYTx+Q1pTQC0wrWqHx0vpJraQ6GtHx8tvcg1rlK1byhU5gccxgOgj7B0TDkQ==} + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + call-bind-apply-helpers@1.0.2: resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} engines: {node: '>= 0.4'} + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -1281,6 +1324,18 @@ packages: compare-func@2.0.0: resolution: {integrity: sha512-zHig5N+tPWARooBnb0Zx1MFcdfpyJrfTJ3Y5L+IFvUm8rM74hHz66z0gw0x4tijh5CorKkKUCnW82R2vmpeCRA==} + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + conventional-changelog-angular@8.3.1: resolution: {integrity: sha512-6gfI3otXK5Ph5DfCOI1dblr+kN3FAm5a97hYoQkqNZxOaYa5WKfXH+AnpsmS+iUH2mgVC2Cg2Qw9m5OKcmNrIg==} engines: {node: '>=18'} @@ -1301,9 +1356,21 @@ packages: resolution: {integrity: sha512-rcQ1bsQO9799wq24uE5AM2tAILy4gXGIK/njFWcVQkGNZ96edlpY+A7bjwvzjYvLDyzmG1MmMLZhpcsb+klNMQ==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + core-util-is@1.0.3: resolution: {integrity: sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==} + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + cosmiconfig-typescript-loader@6.2.0: resolution: {integrity: sha512-GEN39v7TgdxgIoNcdkRE3uiAzQt3UXLyHbRHD6YoL048XAeOomyxaP+Hh/+2C6C2wYjxJ2onhJcsQp+L4YEkVQ==} engines: {node: '>=v18'} @@ -1356,6 +1423,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + diff@8.0.3: resolution: {integrity: sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ==} engines: {node: '>=0.3.1'} @@ -1387,6 +1458,9 @@ packages: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + electron-to-chromium@1.5.286: resolution: {integrity: sha512-9tfDXhJ4RKFNerfjdCcZfufu49vg620741MNs26a9+bhLThdB+plgMeou98CAaHu/WATj2iHOOHTp1hWtABj2A==} @@ -1396,6 +1470,10 @@ packages: emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + encoding-sniffer@0.2.1: resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} @@ -1453,6 +1531,9 @@ packages: resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} engines: {node: '>=6'} + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + escape-string-regexp@2.0.0: resolution: {integrity: sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==} engines: {node: '>=8'} @@ -1525,6 +1606,10 @@ packages: resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} engines: {node: '>=0.10.0'} + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + eventemitter3@5.0.4: resolution: {integrity: sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==} @@ -1532,6 +1617,10 @@ packages: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + execa@9.6.1: resolution: {integrity: sha512-9Be3ZoN4LmYR90tUoVu2te2BsbzHfhJyfEiAVfz7N5/zv+jduIfLrV2xdQXOHbaD6KgpGdO9PRPM1Y4Q9QkPkA==} engines: {node: ^18.19.0 || >=20.5.0} @@ -1540,6 +1629,16 @@ packages: resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} engines: {node: '>=12.0.0'} + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + fast-deep-equal@3.1.3: resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} @@ -1583,6 +1682,10 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + find-up@5.0.0: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} @@ -1607,10 +1710,18 @@ packages: resolution: {integrity: sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==} engines: {node: '>= 6'} + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + frac@1.1.2: resolution: {integrity: sha512-w/XBfkibaTl3YDqASwfDUqkna4Z2p9cFSr1aHDt0WoMTECnRfBOv2WArlZILlqgWlmdIlALXGpM2AOhEk5W3IA==} engines: {node: '>=0.8'} + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + fsevents@2.3.3: resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} @@ -1681,9 +1792,17 @@ packages: hermes-parser@0.25.1: resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==} + hono@4.12.19: + resolution: {integrity: sha512-xa3eYXYXx68XTT4hZ7dRzsXBhaq85ToSrlUJNoR0gwz/1Ap/CNwX47wfvV7pc/xWhjKVVkLT7zBJy8chhNguqQ==} + engines: {node: '>=16.9.0'} + htmlparser2@10.1.0: resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + https-proxy-agent@7.0.6: resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} engines: {node: '>= 14'} @@ -1701,6 +1820,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + idb-keyval@6.2.2: resolution: {integrity: sha512-yjD9nARJ/jb1g+CvD0tlhUHOrJ9Sy0P8T9MF3YaLlHnSRpwPfpTX0XIvpmw3gAJUmEu3FiICLBDPXVwyEvrleg==} @@ -1749,6 +1872,14 @@ packages: '@types/react': optional: true + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} @@ -1789,6 +1920,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + is-stream@4.0.1: resolution: {integrity: sha512-Dnz92NInDqYckGEUJv689RbRiTSEHCQ7wOVeALbkOz999YpqT46yMRIGtSNl2iCL1waAZSx40+h59NV/EwzV/A==} engines: {node: '>=18'} @@ -1813,6 +1947,9 @@ packages: resolution: {integrity: sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==} hasBin: true + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + js-tiktoken@1.0.21: resolution: {integrity: sha512-biOj/6M5qdgx5TKjDnFT1ymSpM5tbd3ylwDtrQvFQSu0Z7bBYko2dF+W/aUkXUPuk6IVpRxk/3Q2sHOzGlS36g==} @@ -1840,6 +1977,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + json-schema@0.4.0: resolution: {integrity: sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA==} @@ -1928,10 +2068,18 @@ packages: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + meow@13.2.0: resolution: {integrity: sha512-pxQJQzB6djGPXh08dacEloMFopsOqGVRKFPYvPOt9XDZ1HasbgDZA74CJGreSU4G3Ak7EFJGoiH2auq+yXISgA==} engines: {node: '>=18'} + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} @@ -1940,10 +2088,18 @@ packages: resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} engines: {node: '>= 0.6'} + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + mime-types@2.1.35: resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} engines: {node: '>= 0.6'} + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + mimic-fn@2.1.0: resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} engines: {node: '>=6'} @@ -1981,6 +2137,10 @@ packages: natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + node-fetch@2.7.0: resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} engines: {node: 4.x || >=6.0.0} @@ -2003,6 +2163,14 @@ packages: nth-check@2.1.1: resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + obliterator@2.0.5: resolution: {integrity: sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==} @@ -2014,6 +2182,13 @@ packages: engines: {node: '>=18.0.0'} hasBin: true + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + onetime@5.1.2: resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} engines: {node: '>=6'} @@ -2071,6 +2246,10 @@ packages: parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + patch-console@2.0.0: resolution: {integrity: sha512-0YNdUceMdaQwoKce1gatDScmMo5pu/tfABfnzEqeG0gtTmd7mh/WcwgUjtAeOU7N8nFFlbQBnFK2gXW5fGvmMA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -2091,6 +2270,9 @@ packages: resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} engines: {node: '>=12'} + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + pathe@2.0.3: resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} @@ -2126,6 +2308,10 @@ packages: engines: {node: '>=0.10'} hasBin: true + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + postcss@8.5.6: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} @@ -2146,6 +2332,10 @@ packages: process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + proxy-from-env@1.1.0: resolution: {integrity: sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==} @@ -2153,6 +2343,18 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + react-reconciler@0.32.0: resolution: {integrity: sha512-2NPMOzgTlG0ZWdIf3qG+dcbLSoAc/uLfOwckc3ofy5sSK0pLJqnQLpUFxvGcN2rlXSjnVtGeeFLNimCQEj5gOQ==} engines: {node: '>=0.10.0'} @@ -2204,6 +2406,10 @@ packages: engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + safe-buffer@5.1.2: resolution: {integrity: sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==} @@ -2222,9 +2428,20 @@ packages: engines: {node: '>=10'} hasBin: true + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + setimmediate@1.0.5: resolution: {integrity: sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==} + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -2233,6 +2450,22 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + siginfo@2.0.0: resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} @@ -2272,6 +2505,10 @@ packages: stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + std-env@3.10.0: resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} @@ -2335,6 +2572,10 @@ packages: resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} engines: {node: '>=8.0'} + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + token-types@6.1.2: resolution: {integrity: sha512-dRXchy+C0IgK8WPC6xvCHFRIWYUbqqdEIKPaKo/AcTUNzwLTK6AH7RjdLWsEZcAN/TBdtfUw3PYEgPr5VPr6ww==} engines: {node: '>=14.16'} @@ -2364,6 +2605,10 @@ packages: resolution: {integrity: sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA==} engines: {node: '>=16'} + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + typescript-eslint@8.54.0: resolution: {integrity: sha512-CKsJ+g53QpsNPqbzUsfKVgd3Lny4yKZ1pP4qN3jdMOg/sisIDLGyDMezycquXLE5JsEU0wp3dGNdzig0/fmSVQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} @@ -2394,6 +2639,10 @@ packages: resolution: {integrity: sha512-+QBBXBCvifc56fsbuxZQ6Sic3wqqc3WWaqxs58gvJrcOuN83HGTCwz3oS5phzU9LthRNE9VrJCFCLUgHeeFnfA==} engines: {node: '>=18'} + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + update-browserslist-db@1.2.3: resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==} hasBin: true @@ -2406,6 +2655,10 @@ packages: util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + vite@7.3.1: resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -2528,6 +2781,9 @@ packages: resolution: {integrity: sha512-42AtmgqjV+X1VpdOfyTGOYRi0/zsoLqtXQckTmqTeybT+BDIbM/Guxo7x3pE2vtpr1ok6xRqM9OpBe+Jyoqyww==} engines: {node: '>=18'} + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@8.19.0: resolution: {integrity: sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==} engines: {node: '>=10.0.0'} @@ -2598,6 +2854,11 @@ packages: zlibjs@0.3.1: resolution: {integrity: sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==} + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + zod-validation-error@4.0.2: resolution: {integrity: sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==} engines: {node: '>=18.0.0'} @@ -3032,6 +3293,10 @@ snapshots: '@eslint/core': 1.1.0 levn: 0.4.1 + '@hono/node-server@1.19.14(hono@4.12.19)': + dependencies: + hono: 4.12.19 + '@humanfs/core@0.19.1': {} '@humanfs/node@0.16.7': @@ -3103,6 +3368,28 @@ snapshots: '@mixmark-io/domino@2.2.0': {} + '@modelcontextprotocol/sdk@1.29.0(zod@3.25.76)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.19) + ajv: 8.18.0 + ajv-formats: 3.0.1(ajv@8.18.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.6 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.19 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.25.76 + zod-to-json-schema: 3.25.2(zod@3.25.76) + transitivePeerDependencies: + - supports-color + '@napi-rs/canvas-android-arm64@0.1.80': optional: true @@ -3490,6 +3777,11 @@ snapshots: '@xmldom/xmldom@0.9.10': {} + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + acorn-jsx@5.3.2(acorn@8.15.0): dependencies: acorn: 8.15.0 @@ -3508,6 +3800,10 @@ snapshots: '@opentelemetry/api': 1.9.0 zod: 3.25.76 + ajv-formats@3.0.1(ajv@8.18.0): + optionalDependencies: + ajv: 8.18.0 + ajv@6.12.6: dependencies: fast-deep-equal: 3.1.3 @@ -3568,6 +3864,20 @@ snapshots: bmp-js@0.1.0: {} + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + boolbase@1.0.0: {} brace-expansion@2.0.2: @@ -3588,11 +3898,18 @@ snapshots: buffer-crc32@0.2.13: {} + bytes@3.1.2: {} + call-bind-apply-helpers@1.0.2: dependencies: es-errors: 1.3.0 function-bind: 1.1.2 + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + callsites@3.1.0: {} caniuse-lite@1.0.30001769: {} @@ -3686,6 +4003,12 @@ snapshots: array-ify: 1.0.0 dot-prop: 5.3.0 + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + conventional-changelog-angular@8.3.1: dependencies: compare-func: 2.0.0 @@ -3703,8 +4026,17 @@ snapshots: convert-to-spaces@2.0.1: {} + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + core-util-is@1.0.3: {} + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + cosmiconfig-typescript-loader@6.2.0(@types/node@22.19.10)(cosmiconfig@9.0.1(typescript@5.9.3))(typescript@5.9.3): dependencies: '@types/node': 22.19.10 @@ -3749,6 +4081,8 @@ snapshots: delayed-stream@1.0.0: {} + depd@2.0.0: {} + diff@8.0.3: {} dingbat-to-unicode@1.0.1: {} @@ -3785,12 +4119,16 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 + ee-first@1.1.1: {} + electron-to-chromium@1.5.286: {} emoji-regex@10.6.0: {} emoji-regex@8.0.0: {} + encodeurl@2.0.0: {} + encoding-sniffer@0.2.1: dependencies: iconv-lite: 0.6.3 @@ -3860,6 +4198,8 @@ snapshots: escalade@3.2.0: {} + escape-html@1.0.3: {} + escape-string-regexp@2.0.0: {} escape-string-regexp@4.0.0: {} @@ -3953,10 +4293,16 @@ snapshots: esutils@2.0.3: {} + etag@1.8.1: {} + eventemitter3@5.0.4: {} eventsource-parser@3.0.6: {} + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.6 + execa@9.6.1: dependencies: '@sindresorhus/merge-streams': 4.0.0 @@ -3974,6 +4320,44 @@ snapshots: expect-type@1.3.0: {} + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + fast-deep-equal@3.1.3: {} fast-json-stable-stringify@2.1.0: {} @@ -4013,6 +4397,17 @@ snapshots: dependencies: to-regex-range: 5.0.1 + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + find-up@5.0.0: dependencies: locate-path: 6.0.0 @@ -4035,8 +4430,12 @@ snapshots: hasown: 2.0.2 mime-types: 2.1.35 + forwarded@0.2.0: {} + frac@1.1.2: {} + fresh@2.0.0: {} + fsevents@2.3.3: optional: true @@ -4109,6 +4508,8 @@ snapshots: dependencies: hermes-estree: 0.25.1 + hono@4.12.19: {} + htmlparser2@10.1.0: dependencies: domelementtype: 2.3.0 @@ -4116,6 +4517,14 @@ snapshots: domutils: 3.2.2 entities: 7.0.1 + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 @@ -4131,6 +4540,10 @@ snapshots: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + idb-keyval@6.2.2: {} ieee754@1.2.1: {} @@ -4160,6 +4573,10 @@ snapshots: optionalDependencies: '@types/react': 19.2.13 + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + is-arrayish@0.2.1: {} is-extglob@2.1.1: {} @@ -4184,6 +4601,8 @@ snapshots: is-plain-obj@4.1.0: {} + is-promise@4.0.0: {} + is-stream@4.0.1: {} is-unicode-supported@2.1.0: {} @@ -4198,6 +4617,8 @@ snapshots: jiti@2.6.1: {} + jose@6.2.3: {} + js-tiktoken@1.0.21: dependencies: base64-js: 1.5.1 @@ -4218,6 +4639,8 @@ snapshots: json-schema-traverse@1.0.0: {} + json-schema-typed@8.0.2: {} + json-schema@0.4.0: {} json-stable-stringify-without-jsonify@1.0.1: {} @@ -4322,8 +4745,12 @@ snapshots: math-intrinsics@1.1.0: {} + media-typer@1.1.0: {} + meow@13.2.0: {} + merge-descriptors@2.0.0: {} + micromatch@4.0.8: dependencies: braces: 3.0.3 @@ -4331,10 +4758,16 @@ snapshots: mime-db@1.52.0: {} + mime-db@1.54.0: {} + mime-types@2.1.35: dependencies: mime-db: 1.52.0 + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + mimic-fn@2.1.0: {} mimic-function@5.0.1: {} @@ -4361,6 +4794,8 @@ snapshots: natural-compare@1.4.0: {} + negotiator@1.0.0: {} + node-fetch@2.7.0: dependencies: whatwg-url: 5.0.0 @@ -4379,6 +4814,10 @@ snapshots: dependencies: boolbase: 1.0.0 + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + obliterator@2.0.5: {} obug@2.1.1: {} @@ -4394,6 +4833,14 @@ snapshots: - encoding - supports-color + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + onetime@5.1.2: dependencies: mimic-fn: 2.1.0 @@ -4457,6 +4904,8 @@ snapshots: dependencies: entities: 6.0.1 + parseurl@1.3.3: {} + patch-console@2.0.0: {} path-exists@4.0.0: {} @@ -4467,6 +4916,8 @@ snapshots: path-key@4.0.0: {} + path-to-regexp@8.4.2: {} + pathe@2.0.3: {} pdf-parse@2.4.5: @@ -4493,6 +4944,8 @@ snapshots: pidtree@0.6.0: {} + pkce-challenge@5.0.1: {} + postcss@8.5.6: dependencies: nanoid: 3.3.11 @@ -4509,10 +4962,28 @@ snapshots: process-nextick-args@2.0.1: {} + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + proxy-from-env@1.1.0: {} punycode@2.3.1: {} + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + react-reconciler@0.32.0(react@19.2.4): dependencies: react: 19.2.4 @@ -4585,6 +5056,16 @@ snapshots: '@rollup/rollup-win32-x64-msvc': 4.57.1 fsevents: 2.3.3 + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + safe-buffer@5.1.2: {} safer-buffer@2.1.2: {} @@ -4595,14 +5076,69 @@ snapshots: semver@7.7.4: {} + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + setimmediate@1.0.5: {} + setprototypeof@1.2.0: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 shebang-regex@3.0.0: {} + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} signal-exit@3.0.7: {} @@ -4635,6 +5171,8 @@ snapshots: stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} string-argv@0.3.2: {} @@ -4705,6 +5243,8 @@ snapshots: dependencies: is-number: 7.0.0 + toidentifier@1.0.1: {} + token-types@6.1.2: dependencies: '@borewit/text-codec': 0.2.2 @@ -4734,6 +5274,12 @@ snapshots: type-fest@4.41.0: {} + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + typescript-eslint@8.54.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3): dependencies: '@typescript-eslint/eslint-plugin': 8.54.0(@typescript-eslint/parser@8.54.0(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3))(eslint@10.0.0(jiti@2.6.1))(typescript@5.9.3) @@ -4757,6 +5303,8 @@ snapshots: unicorn-magic@0.3.0: {} + unpipe@1.0.0: {} + update-browserslist-db@1.2.3(browserslist@4.28.1): dependencies: browserslist: 4.28.1 @@ -4769,6 +5317,8 @@ snapshots: util-deprecate@1.0.2: {} + vary@1.1.2: {} + vite@7.3.1(@types/node@22.19.10)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.2): dependencies: esbuild: 0.27.3 @@ -4864,6 +5414,8 @@ snapshots: string-width: 7.2.0 strip-ansi: 7.1.2 + wrappy@1.0.2: {} + ws@8.19.0: {} xlsx@0.18.5: @@ -4927,6 +5479,10 @@ snapshots: zlibjs@0.3.1: {} + zod-to-json-schema@3.25.2(zod@3.25.76): + dependencies: + zod: 3.25.76 + zod-validation-error@4.0.2(zod@3.25.76): dependencies: zod: 3.25.76 From 332d86a5388b03ff803a852acb805a87b44102ce Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Mon, 18 May 2026 20:33:31 +0800 Subject: [PATCH 2/9] feat(core/cli): make /mcp refresh and /mcp auth actually work /mcp refresh and /mcp auth previously just printed "restart the CLI" hints. Gemini CLI is the only competitor that runs them as real operations, so this aligns with that behavior: - McpRegistry gains restartServer, restartAll, and authenticateServer. Configs + OAuth factory now live on the registry so it can rebuild servers in place without churning the AgentOptions reference held by the agent loop. - McpClient.connectWithOAuth drives the full OAuth round-trip: catch UnauthorizedError, await the callback server's code, finishAuth on the transport, retry connect. The default connect() path is unchanged so CLI boot still surfaces auth-needed servers as needs_auth instead of unconditionally popping a browser. - loadMergedConfigsFromDisk reads + trust-gates the config without spawning anything, so /mcp refresh can hand the merged map straight to restartAll for an in-place reload. - useAgent exposes invalidateSystemPromptCache so the slash-command handler can null the cache after the tool surface changes - prefix caching on OpenAI-compatible providers would otherwise send a stale tool list on the next turn. - /mcp refresh prints an added/removed/changed/reconnected summary so users can see what the reload actually did. --- packages/cli/src/ui/components/App.tsx | 103 +++++- packages/cli/src/ui/hooks/use-agent.ts | 19 ++ packages/core/src/index.ts | 12 +- packages/core/src/mcp/client.ts | 114 ++++++- packages/core/src/mcp/loader.ts | 176 +++++----- packages/core/src/mcp/registry.ts | 340 +++++++++++++++++++- packages/core/tests/mcp-integration.test.ts | 112 +++++++ 7 files changed, 766 insertions(+), 110 deletions(-) diff --git a/packages/cli/src/ui/components/App.tsx b/packages/cli/src/ui/components/App.tsx index 23d3b13..81b6a3d 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -13,6 +13,7 @@ import { getContextWindow, getTokenStorage, listSessions, + loadMergedConfigsFromDisk, loadSession, loadUserConfig, pickLatestSession, @@ -245,6 +246,7 @@ export function App({ switchModel, setThinking, getThinking, + invalidateSystemPromptCache, addInfoMessage, addUserMessage, addCommandMessage, @@ -1097,10 +1099,62 @@ export function App({ addCommandMessage(text, 'Usage: /mcp auth ') return } - addCommandMessage( - text, - `OAuth runs automatically the first time "${subArg}" connects. If you have stale tokens, run /mcp logout ${subArg} first, then restart the CLI.`, - ) + if (!registry) { + addCommandMessage(text, 'No MCP servers configured. Add `mcpServers` to ~/.x-code/config.json first.') + return + } + const config = registry.getConfig(subArg) + if (!config) { + addCommandMessage(text, `Unknown MCP server: "${subArg}". Run /mcp list to see configured servers.`) + return + } + if (!('url' in config) || typeof config.url !== 'string') { + addCommandMessage( + text, + `MCP server "${subArg}" is a stdio server — OAuth applies to HTTP servers (those with a "url" field) only.`, + ) + return + } + // Drop stored tokens up front. If the user runs /mcp auth on a + // server with valid tokens, we want a forced re-auth (matches + // Gemini CLI semantics — running auth again is a "let me log in + // from scratch", not "verify my existing session"). A separate + // /mcp logout exists for users who just want to clear without + // re-authing. + try { + await getTokenStorage().clear(subArg) + } catch { + // best-effort; an unwritable token store still lets the rest + // of the flow run and the user will see the actual failure + // when finishAuth tries to save. + } + addCommandMessage(text, `Authenticating "${subArg}" — opening browser...`) + try { + const server = await registry.authenticateServer(subArg, { + onBrowserOpen: (url) => { + addInfoMessage(`> Opened ${url}\n Waiting for the authorization redirect...`) + }, + }) + if (server.status.kind === 'connected') { + // Tool surface may have grown — invalidate cache so the next + // turn rebuilds the system prompt with the newly-available + // tools. + invalidateSystemPromptCache() + addInfoMessage( + ` ⎿ ✓ Authenticated "${subArg}" — ${server.status.toolCount} tool${ + server.status.toolCount === 1 ? '' : 's' + }, ${server.status.resourceCount} resource${server.status.resourceCount === 1 ? '' : 's'}`, + ) + } else if (server.status.kind === 'needs_auth') { + addInfoMessage(` ⎿ ⚠ Server still needs auth. The browser flow may have been cancelled.`) + } else if (server.status.kind === 'failed') { + addInfoMessage(` ⎿ ✗ Auth completed but server failed to connect: ${server.status.error}`) + } else { + addInfoMessage(` ⎿ Server is now in state: ${server.status.kind}`) + } + } catch (err) { + addInfoMessage(` ⎿ ✗ Authentication failed: ${err instanceof Error ? err.message : String(err)}`) + } return } case 'logout': { @@ -1110,17 +1164,48 @@ export function App({ } try { await getTokenStorage().clear(subArg) - addCommandMessage(text, `Removed stored OAuth tokens for "${subArg}". Restart the CLI to authenticate again.`) + addCommandMessage( + text, + `Removed stored OAuth tokens for "${subArg}". Run /mcp auth ${subArg} to log in again.`, + ) } catch (err) { addCommandMessage(text, `Failed to clear tokens: ${err instanceof Error ? err.message : String(err)}`) } return } case 'refresh': { - addCommandMessage( - text, - 'Config changes require a restart. Quit the CLI and run `xc` again to pick up new mcpServers entries.', - ) + if (!registry) { + addCommandMessage(text, 'No MCP registry to refresh.') + return + } + addCommandMessage(text, 'Re-reading MCP config and reconnecting servers...') + try { + const { configs, configErrors, projectSkipped } = await loadMergedConfigsFromDisk({ + cwd: process.cwd(), + askUser: (q, opts) => askQuestion(q, opts, { noOther: true }), + }) + const summary = await registry.restartAll(configs) + // Invalidate prompt cache: the tool surface almost certainly + // changed (even "all unchanged" servers re-list their tools + // after reconnect, which can differ if the server has + // hot-reloaded definitions). Better to take one cache miss + // than to send a stale tool list. + invalidateSystemPromptCache() + + const parts: string[] = [] + if (summary.added.length) parts.push(`added: ${summary.added.join(', ')}`) + if (summary.removed.length) parts.push(`removed: ${summary.removed.join(', ')}`) + if (summary.changed.length) parts.push(`changed: ${summary.changed.join(', ')}`) + if (summary.unchanged.length) parts.push(`reconnected: ${summary.unchanged.join(', ')}`) + if (parts.length === 0) parts.push('no servers configured') + const lines = [`Reloaded MCP — ${parts.join('; ')}.`] + lines.push(`Note: next message rebuilds the system prompt, so prompt-cache will miss once.`) + if (projectSkipped) lines.push('Project-level MCP servers were skipped (not trusted).') + for (const e of configErrors) lines.push(`Config error in ${e.name}: ${e.message}`) + addInfoMessage(lines.join('\n')) + } catch (err) { + addInfoMessage(` ⎿ ✗ Refresh failed: ${err instanceof Error ? err.message : String(err)}`) + } return } default: { diff --git a/packages/cli/src/ui/hooks/use-agent.ts b/packages/cli/src/ui/hooks/use-agent.ts index 6715d68..69e9f8b 100644 --- a/packages/cli/src/ui/hooks/use-agent.ts +++ b/packages/cli/src/ui/hooks/use-agent.ts @@ -801,6 +801,24 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini /** Read the current /thinking toggle (for status display). */ const getThinking = useCallback(() => thinkingRef.current, []) + /** Drop the cached system prompt so the next agent turn rebuilds it + * with whatever the current tool surface looks like. + * + * The cache is the tool-list + plan-overlay snapshot the agent loop + * builds at the start of every session and reuses across turns to + * preserve OpenAI-compatible providers' prefix caches. Anything that + * changes the visible tools — `/mcp refresh` adding or removing + * servers, `/mcp auth ` bringing a previously-needs_auth server + * online — MUST invalidate the cache so the next streamText call + * sends a prompt that matches the actual tool list. Otherwise the + * model would see tools that don't exist (or miss new ones), and + * the loop's `MCP tool not found: …` error path would fire. */ + const invalidateSystemPromptCache = useCallback(() => { + if (loopStateRef.current) { + loopStateRef.current.systemPromptCache = null + } + }, []) + /** Set permission mode directly. Use this for /plan-style direct * setters where the user is unambiguously asking for a specific * target. Updates LoopState live (so the next agent turn picks up @@ -881,6 +899,7 @@ export function useAgent(initialModel: LanguageModel, options: AgentOptions, ini switchModel, setThinking, getThinking, + invalidateSystemPromptCache, setPermissionMode, addInfoMessage, addUserMessage, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 93a8107..a7c4481 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -93,9 +93,15 @@ export type { LoadedSession, SessionListEntry } from './agent/session-store.js' // MCP — Model Context Protocol client support. export { McpRegistry, emptyRegistry } from './mcp/registry.js' -export type { RegisteredServer } from './mcp/registry.js' -export { loadMcpServers, loadMcpFromDisk } from './mcp/loader.js' -export type { LoadOptions as McpLoadOptions, LoadResult as McpLoadResult, OAuthProviderFactory } from './mcp/loader.js' +export type { + RegisteredServer, + RestartSummary as McpRestartSummary, + AuthHooks as McpAuthHooks, + ConnectResult as McpConnectResult, + OAuthProviderFactory, +} from './mcp/registry.js' +export { loadMcpServers, loadMcpFromDisk, loadMergedConfigsFromDisk } from './mcp/loader.js' +export type { LoadOptions as McpLoadOptions, LoadResult as McpLoadResult } from './mcp/loader.js' export { McpPermissionStore, classifyDecision } from './mcp/permissions.js' export type { McpPermissionDecision } from './mcp/permissions.js' export { isProjectTrusted, trustProject, promptForTrust, buildServerPreview } from './mcp/trust.js' diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 1d7ef7a..43c81b5 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -12,7 +12,7 @@ // hits Esc mid-tool-call the agent loop's signal aborts the SDK request, // which closes the JSON-RPC future without killing the underlying // connection — the next call can reuse the same transport. -import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' +import { type OAuthClientProvider, UnauthorizedError } from '@modelcontextprotocol/sdk/client/auth.js' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js' import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js' @@ -21,6 +21,7 @@ import type { Transport } from '@modelcontextprotocol/sdk/shared/transport.js' import { Stream } from 'node:stream' import { debugLog } from '../utils.js' +import { McpOAuthProvider } from './oauth/provider.js' import { type McpCallResult, type McpResourceEntry, @@ -136,6 +137,104 @@ export class McpClient { return this.cachedResources } + /** Connect with a full interactive OAuth round-trip. + * + * The MCP SDK's StreamableHTTP transport handles auth lazily: a fresh + * connect with no stored token calls `authProvider.redirectToAuthorization` + * (which opens the browser) and then throws `UnauthorizedError` because + * the token-exchange step has to wait for the user. The caller is + * expected to wait for the redirect callback to land, hand the + * authorization code to `transport.finishAuth(code)`, then retry + * connect — at which point tokens are saved and the next attempt + * succeeds. + * + * We encapsulate that dance here so that loader / registry can opt + * into "drive OAuth to completion" without each caller knowing about + * `finishAuth`. The default `connect()` path (no driveOAuth) keeps + * the lighter "throw UnauthorizedError, let caller mark needs_auth" + * behaviour so CLI boot doesn't accidentally pop a browser window. */ + async connectWithOAuth(hooks: { onBrowserOpen?: (url: string) => void } = {}): Promise { + if (!this.authProvider) { + throw new Error(`MCP server "${this.serverName}" has no OAuth provider configured`) + } + if (!(this.authProvider instanceof McpOAuthProvider)) { + // Allow third-party providers but skip our `waitForAuthCode` hook — + // they're expected to handle the flow themselves. + return this.connect() + } + + const provider = this.authProvider + // Forward the browser-open notification through the hook the caller + // wants. The provider was constructed with whatever onOpenBrowser + // was passed at factory time (printed via console.error in normal + // boot); the caller's hook fires alongside, so the /mcp auth + // handler can also print into the CLI scrollback. + if (hooks.onBrowserOpen) { + // McpOAuthProvider currently routes through its constructor hook; + // the simplest safe wiring is to tee via a one-shot listener on + // the next redirectToAuthorization call. We do that by wrapping + // the provider's redirect method, but only for THIS call — + // restoring on completion. The provider doesn't itself expose + // an event API, so we monkey-patch the method on the instance. + const original = provider.redirectToAuthorization.bind(provider) + provider.redirectToAuthorization = async (url: URL) => { + try { + hooks.onBrowserOpen?.(url.toString()) + } catch { + // Hook failures must not abort the OAuth flow. + } + return original(url) + } + // Restore once this connectWithOAuth call resolves either way. + // (Stashed via try/finally below.) + try { + return await this.runOAuthDance() + } finally { + provider.redirectToAuthorization = original + } + } + + return this.runOAuthDance() + } + + /** The actual two-phase connect: attempt-1 fires redirect, then we + * wait for the user, finish the auth, attempt-2 lands a real + * session. Both attempts share `cachedTools` / `cachedResources`. */ + private async runOAuthDance(): Promise { + const provider = this.authProvider as McpOAuthProvider + + // First attempt: most likely throws UnauthorizedError after the + // browser has been launched. If tokens were somehow already valid + // (stale state on disk) this succeeds and we short-circuit out. + try { + return await this.connect() + } catch (err) { + // Anything that isn't "we need to wait for the user" propagates. + if (!isUnauthorizedError(err)) { + provider.cancel() + throw err + } + } + + // The provider has already called redirectToAuthorization (the SDK + // does that internally before throwing). Now wait for the user to + // come back via the callback server, then complete the exchange. + const { code } = await provider.waitForAuthCode() + const transport = this.transport + if (!(transport instanceof StreamableHTTPClientTransport)) { + throw new Error(`Internal error: OAuth flow expected an HTTP transport for "${this.serverName}"`) + } + await transport.finishAuth(code) + + // Tokens are now saved. The first attempt left the client + transport + // in a half-open state (the SDK's connect threw mid-handshake); we + // need a clean transport for the retry, so close and rebuild. This + // also means the SDK's initialize roundtrip happens against a fresh + // socket, avoiding any "already connected" / state-leak surprises. + await this.safeClose() + return this.connect() + } + async callTool(name: string, args: unknown, signal?: AbortSignal): Promise { if (!this.client) throw new Error(`MCP server "${this.serverName}" is not connected`) const result = await this.client.callTool( @@ -243,6 +342,19 @@ export class McpClient { } } +/** Pattern-match an UnauthorizedError from the SDK without depending + * on instanceof (which can be fragile across bundling boundaries when + * the SDK is duplicated under different esm/cjs roots). The SDK exports + * the class directly though, so we use both checks. */ +function isUnauthorizedError(err: unknown): boolean { + if (err instanceof UnauthorizedError) return true + if (err instanceof Error) { + if (err.name === 'UnauthorizedError') return true + if (/unauthorized|401/i.test(err.message)) return true + } + return false +} + /** Flatten MCP call result content blocks into a single string. * MCP responses are an array of `{ type: "text" | "image" | ... }` * blocks. For tool_result we only care about the text; images/audio are diff --git a/packages/core/src/mcp/loader.ts b/packages/core/src/mcp/loader.ts index 249ab79..11ead0c 100644 --- a/packages/core/src/mcp/loader.ts +++ b/packages/core/src/mcp/loader.ts @@ -2,29 +2,32 @@ // // One-shot orchestration called from the CLI entry: read user + project // configs, apply the trust gate to anything project-level, expand env -// vars, spawn / dial every enabled server in parallel, build a frozen -// registry. Failures on individual servers are recorded but never abort -// the boot — `/mcp list` is the user's window into what went wrong. -import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' - +// vars, spawn / dial every enabled server in parallel, build a registry +// that can later be mutated by `/mcp refresh` and `/mcp auth`. Failures +// on individual servers are recorded but never abort the boot — +// `/mcp list` is the user's window into what went wrong. import fs from 'node:fs/promises' import path from 'node:path' import { getUserConfigPath } from '../config/index.js' import { XCODE_DIR, debugLog } from '../utils.js' -import { McpClient } from './client.js' import { parseServersBlock } from './config-schema.js' -import { EnvExpansionError, expandEnvDeep } from './expand-env.js' -import { buildCallableName } from './name-mangling.js' -import { McpRegistry, type RegisteredServer, emptyRegistry } from './registry.js' +import { buildCallableName as buildCallable } from './name-mangling.js' +import { + type ConnectResult, + McpRegistry, + type OAuthProviderFactory, + type RegisteredServer, + connectOneServer, + emptyRegistry, +} from './registry.js' import { type TrustChoice, buildServerPreview, isProjectTrusted, promptForTrust, trustProject } from './trust.js' -import { type McpResourceEntry, type McpServerConfig, type McpToolEntry, isHttpConfig } from './types.js' +import { type McpResourceEntry, type McpServerConfig, type McpToolEntry } from './types.js' -/** Resolve the OAuth provider for a single HTTP server. Returns - * undefined for stdio (auth is the server's problem) or when no auth - * has been set up yet — the first connect will then 401 and the user - * is told to run `/mcp auth `. */ -export type OAuthProviderFactory = (serverName: string, serverUrl: string) => OAuthClientProvider | undefined +// Re-export for legacy callers that imported the type from this module. +export type { OAuthProviderFactory } +export type { RegisteredServer, ConnectResult } +export type { McpResourceEntry, McpToolEntry } export interface LoadOptions { /** mcpServers from ~/.x-code/config.json. Trusted implicitly. */ @@ -76,6 +79,62 @@ export async function loadMcpFromDisk(opts: { }) } +/** Re-read configs from disk + apply the trust gate, but DON'T spawn any + * servers. Used by `/mcp refresh` so the caller can hand the resulting + * merged map to `registry.restartAll(...)` — that mutates the existing + * registry in place rather than allocating a parallel one. */ +export async function loadMergedConfigsFromDisk(opts: { cwd: string; askUser: LoadOptions['askUser'] }): Promise<{ + configs: Map + configErrors: Array<{ name: string; message: string }> + projectSkipped: boolean +}> { + const userServers = await readMcpServersFromFile(getUserConfigPath()) + const projectServers = await readMcpServersFromFile(path.join(opts.cwd, XCODE_DIR, 'config.json')) + + const configErrors: Array<{ name: string; message: string }> = [] + let projectSkipped = false + + const userParsed = parseServersBlock(userServers) + configErrors.push(...userParsed.errors.map((e) => ({ name: `user:${e.name}`, message: e.message }))) + const projectParsed = parseServersBlock(projectServers) + configErrors.push(...projectParsed.errors.map((e) => ({ name: `project:${e.name}`, message: e.message }))) + + let projectServersToUse = projectParsed.servers + if (Object.keys(projectServersToUse).length > 0) { + const trusted = await isProjectTrusted(opts.cwd) + if (!trusted) { + const choice = await askForTrust( + { + // Synthesise just enough of a LoadOptions for askForTrust — + // only projectPath + askUser are read. + userServers, + projectServers, + projectPath: opts.cwd, + askUser: opts.askUser, + }, + projectServersToUse, + ) + if (choice === 'exit') { + // /mcp refresh deliberately ignores 'exit' — bailing the whole + // CLI from a slash command is too violent. We treat it as + // 'skip' so the user can pick again on a real restart. + projectServersToUse = {} + projectSkipped = true + } else if (choice === 'skip') { + projectServersToUse = {} + projectSkipped = true + } else if (choice === 'trust') { + await trustProject(opts.cwd).catch((err) => { + debugLog('mcp.trust-write-failed', String(err)) + }) + } + } + } + + const merged = new Map(Object.entries({ ...userParsed.servers, ...projectServersToUse })) + return { configs: merged, configErrors, projectSkipped } +} + /** Pure loader (no disk I/O on configs — caller injects them). * Easier to test and lets the CLI control config sourcing. */ export async function loadMcpServers(options: LoadOptions): Promise { @@ -122,15 +181,23 @@ export async function loadMcpServers(options: LoadOptions): Promise const merged: Record = { ...userParsed.servers, ...projectServersToUse } // No servers configured anywhere → fast-path with an empty registry. + // We still pass the oauthFactory so a later /mcp refresh (after the + // user adds servers to config + restarts the CLI) would have it — + // although in practice the empty-registry path is only hit when both + // configs are empty at boot, and a later refresh rebuilds from disk + // via the CLI's own loadMcpFromDisk call. if (Object.keys(merged).length === 0) { - return { registry: emptyRegistry(), configErrors, projectSkipped } + return { + registry: new McpRegistry({ servers: [], tools: [], resources: [], oauthFactory: options.oauthProviderFor }), + configErrors, + projectSkipped, + } } // Spawn / dial in parallel. Each per-server promise is wrapped in // .then/.catch so one timeout doesn't trip the whole boot. const tasks = Object.entries(merged).map(async ([name, rawConfig]) => { - const result = await connectOneServer(name, rawConfig, options.oauthProviderFor) - return result + return connectOneServer(name, rawConfig, options.oauthProviderFor) }) const results = await Promise.all(tasks) @@ -146,7 +213,7 @@ export async function loadMcpServers(options: LoadOptions): Promise for (const r of results) { for (const t of r.tools) { - const callable = buildCallableName(r.server.name, t.name, taken) + const callable = buildCallable(r.server.name, t.name, taken) taken.add(callable) tools.push({ callableName: callable, @@ -159,10 +226,14 @@ export async function loadMcpServers(options: LoadOptions): Promise for (const res of r.resources) resources.push(res) } + const configs = new Map(Object.entries(merged)) + const registry = new McpRegistry({ servers: results.map((r) => r.server), tools, resources, + configs, + oauthFactory: options.oauthProviderFor, }) return { registry, configErrors, projectSkipped } @@ -186,73 +257,6 @@ async function askForTrust( } } -interface ConnectResult { - server: RegisteredServer - tools: ReadonlyArray<{ name: string; description?: string; inputSchema: Record }> - resources: ReadonlyArray -} - -async function connectOneServer( - name: string, - rawConfig: McpServerConfig, - oauthFactory: OAuthProviderFactory | undefined, -): Promise { - // Honour the `enabled: false` switch — register the server but skip - // the connection. Shows up in /mcp list as `disabled`. - if (rawConfig.enabled === false) { - const client = new McpClient(name, rawConfig) - return { - server: { name, client, status: { kind: 'disabled' } }, - tools: [], - resources: [], - } - } - - // Expand ${VAR} references. Done AFTER schema validation but BEFORE - // constructing the client — the client should never see literal - // unexpanded references. - let expanded: McpServerConfig - try { - expanded = expandEnvDeep(rawConfig) - } catch (err) { - const msg = err instanceof EnvExpansionError ? err.message : err instanceof Error ? err.message : String(err) - const client = new McpClient(name, rawConfig) - return { - server: { name, client, status: { kind: 'failed', error: msg } }, - tools: [], - resources: [], - } - } - - const authProvider = oauthFactory && isHttpConfig(expanded) ? oauthFactory(name, expanded.url) : undefined - - const client = new McpClient(name, expanded, authProvider) - try { - const info = await client.connect() - return { - server: { - name, - client, - status: { kind: 'connected', toolCount: info.toolCount, resourceCount: info.resourceCount }, - }, - tools: client.tools(), - resources: client.resources(), - } - } catch (err) { - const msg = err instanceof Error ? err.message : String(err) - // Heuristic: SDK throws `UnauthorizedError` (or similar) on 401. - // Surface that as needs_auth instead of generic failure so the UI - // can route the user to /mcp auth. - const needsAuth = /unauth|401|UnauthorizedError/i.test(msg) && isHttpConfig(expanded) - const status: RegisteredServer['status'] = needsAuth ? { kind: 'needs_auth' } : { kind: 'failed', error: msg } - return { - server: { name, client, status, stderrTail: client.stderr() || undefined }, - tools: [], - resources: [], - } - } -} - /** Read just the `mcpServers` field out of a JSON config file. Returns * undefined for missing file / parse error / missing field — all of * which mean "no MCP servers configured here", never an error to diff --git a/packages/core/src/mcp/registry.ts b/packages/core/src/mcp/registry.ts index 71ee5b0..4d36103 100644 --- a/packages/core/src/mcp/registry.ts +++ b/packages/core/src/mcp/registry.ts @@ -1,14 +1,42 @@ // @x-code-cli/core — MCP registry // -// Built once at CLI startup by `loadMcpServers`, then frozen for the -// session lifetime. The freeze is deliberate — see CLAUDE.md on -// `systemPromptCache`: any change to the tool surface mid-session would -// invalidate the prompt cache OpenAI-compatible providers rely on for -// prefix matching. `/mcp refresh` works by REPLACING the whole registry -// with a freshly-built one and explicitly setting the session's -// systemPromptCache to null, rather than mutating this one. -import type { McpClient } from './client.js' -import type { McpCallResult, McpResourceEntry, McpServerStatus, McpToolEntry } from './types.js' +// Built once at CLI startup by `loadMcpServers`, then largely stable for +// the session — but no longer fully frozen. Two mutating surfaces exist: +// +// - `restartAll(newConfigs?)` (used by /mcp refresh) — disconnect + reconnect +// every server, optionally swapping in a freshly-read config from disk so +// newly-added entries show up without a CLI restart. +// - `authenticateServer(name, hooks)` (used by /mcp auth ) — drive a +// fresh OAuth round-trip for one HTTP server, then reconnect it. +// +// Both methods mutate the registry's internal maps in place so that the +// `options.mcpRegistry` reference held by `AgentOptions` keeps pointing at +// a valid registry — the agent loop and tool-execution don't need to +// rewire anything. Callers are responsible for nulling out +// `state.systemPromptCache` afterwards: the tool surface has changed, and +// OpenAI-compatible providers' prefix cache (see CLAUDE.md on the byte- +// stability constraint) must be invalidated. The `/mcp` slash command +// handler in App.tsx does that via `invalidateSystemPromptCache()` on +// useAgent. +import type { OAuthClientProvider } from '@modelcontextprotocol/sdk/client/auth.js' + +import { debugLog } from '../utils.js' +import { McpClient } from './client.js' +import { EnvExpansionError, expandEnvDeep } from './expand-env.js' +import { buildCallableName } from './name-mangling.js' +import { + type McpCallResult, + type McpResourceEntry, + type McpServerConfig, + type McpServerStatus, + type McpToolEntry, + isHttpConfig, +} from './types.js' + +/** Build an OAuth provider for one HTTP server. Stdio servers get + * `undefined`. Returns `undefined` for HTTP servers too when OAuth is + * not wired up at the CLI level (no token storage configured). */ +export type OAuthProviderFactory = (serverName: string, serverUrl: string) => OAuthClientProvider | undefined export interface RegisteredServer { name: string @@ -19,6 +47,27 @@ export interface RegisteredServer { stderrTail?: string } +/** Hooks the /mcp auth handler hands in so the registry can surface + * human-visible progress without depending on the CLI layer. */ +export interface AuthHooks { + /** Called once just before the browser is opened. Receives the + * authorization URL the SDK is about to redirect to. */ + onBrowserOpen?: (url: string) => void +} + +/** Summary of what `restartAll` actually changed, for the /mcp refresh + * output line. */ +export interface RestartSummary { + /** Server names present after restart that weren't present before. */ + added: string[] + /** Server names removed (present before, not in new config). */ + removed: string[] + /** Server names present in both but whose config differs. */ + changed: string[] + /** Server names that survived restart unchanged. */ + unchanged: string[] +} + export class McpRegistry { /** callableName → entry. callableName is the model-facing * `mcp____` form; collisions resolved at insert time. */ @@ -27,11 +76,30 @@ export class McpRegistry { * expose the same URI we keep the first and warn (handled by loader). */ private readonly resources = new Map() private readonly servers = new Map() + /** Most-recently-loaded config per server. The source of truth for + * `restartServer` (which reconnects with the same config) and for + * diff'ing in `restartAll` when fresh configs are handed in. */ + private readonly configs = new Map() + /** Factory for per-server OAuth providers. Optional — undefined means + * HTTP servers requiring auth will surface as `needs_auth` and the + * /mcp auth handler can't drive them. */ + private oauthFactory: OAuthProviderFactory | undefined - constructor(input: { servers: RegisteredServer[]; tools: McpToolEntry[]; resources: McpResourceEntry[] }) { + constructor(input: { + servers: RegisteredServer[] + tools: McpToolEntry[] + resources: McpResourceEntry[] + /** Per-server config used at boot. Required for `restartServer` / + * `authenticateServer` to know what to rebuild. */ + configs?: Map + /** OAuth provider factory threaded through from the CLI. */ + oauthFactory?: OAuthProviderFactory + }) { for (const s of input.servers) this.servers.set(s.name, s) for (const t of input.tools) this.entries.set(t.callableName, t) for (const r of input.resources) this.resources.set(r.uri, r) + if (input.configs) for (const [k, v] of input.configs) this.configs.set(k, v) + this.oauthFactory = input.oauthFactory } // ── Tool surface ─────────────────────────────────────────────────────── @@ -74,6 +142,10 @@ export class McpRegistry { return this.servers.get(serverName) } + getConfig(serverName: string): McpServerConfig | undefined { + return this.configs.get(serverName) + } + // ── Dispatch ─────────────────────────────────────────────────────────── /** Call an MCP tool by its model-facing callable name. Looks up the @@ -90,7 +162,7 @@ export class McpRegistry { /** Disconnect every server cleanly. Best-effort: one bad shutdown * doesn't prevent others from running. Called from the CLI exit hook - * and from `/mcp refresh` before building the replacement registry. */ + * and (internally) by `restartAll` before rebuilding. */ async shutdown(): Promise { const tasks: Promise[] = [] for (const s of this.servers.values()) { @@ -102,6 +174,180 @@ export class McpRegistry { } await Promise.allSettled(tasks) } + + // ── Restart / refresh ────────────────────────────────────────────────── + + /** Reconnect one server in-place using its current config. Used by + * `authenticateServer` (after fresh tokens are saved) and exposed for + * callers that want a per-server reload without a full refresh. + * + * Tool / resource entries from the old connection are dropped and + * replaced with whatever the new connection enumerates — tool names + * may change if the server's `tools/list` output changes between + * reconnects. Callers must invalidate the agent's systemPromptCache + * after this returns. */ + async restartServer(name: string, opts: { driveOAuth?: AuthHooks } = {}): Promise { + const config = this.configs.get(name) + if (!config) { + throw new Error(`No MCP server registered as "${name}"`) + } + // Close the existing client (if any) before spawning a replacement — + // for stdio servers this kills the previous child process so we + // don't leave a zombie behind. Errors are non-fatal: a broken + // connection that can't be closed cleanly should still be replaced. + const existing = this.servers.get(name) + if (existing) { + try { + await existing.client.close() + } catch (err) { + debugLog('mcp.restart-close-failed', `${name}: ${String(err)}`) + } + } + + // Strip old tools / resources owned by this server. Done *before* + // the new connect so a partial failure mid-reconnect leaves us in a + // consistent "nothing from this server" state rather than a mix of + // old + nothing. + this.removeServerEntries(name) + + const result = await connectOneServer(name, config, this.oauthFactory, opts.driveOAuth) + this.installServer(result) + return result.server + } + + /** Disconnect everything and rebuild against `newConfigs` (or the + * existing configs if omitted). Returns a diff summary so the UI + * can tell the user what actually changed. + * + * Used by `/mcp refresh`: re-read the user + project config files, + * hand the merged map in here, and we'll add / remove / restart the + * appropriate set. Servers whose config bytes didn't change are + * still reconnected — fresher to the user, simpler than diffing + * every nested field. */ + async restartAll(newConfigs?: Map): Promise { + const oldNames = new Set(this.configs.keys()) + const newNames = new Set((newConfigs ?? this.configs).keys()) + + const summary: RestartSummary = { + added: [...newNames].filter((n) => !oldNames.has(n)), + removed: [...oldNames].filter((n) => !newNames.has(n)), + changed: [], + unchanged: [], + } + + if (newConfigs) { + for (const name of newNames) { + if (!oldNames.has(name)) continue + const before = JSON.stringify(this.configs.get(name)) + const after = JSON.stringify(newConfigs.get(name)) + if (before !== after) summary.changed.push(name) + else summary.unchanged.push(name) + } + } else { + summary.unchanged = [...newNames] + } + + // Tear down everything first. Doing close-all then connect-all + // (rather than per-server close+connect) is more predictable: we + // never have two clients for the same server alive at once, and + // stdio child processes definitely exit before their replacements + // spawn. + await this.shutdown() + + // Reset internal state. We keep the OAuth factory because that + // came from the CLI process and isn't tied to any one config. + this.servers.clear() + this.entries.clear() + this.resources.clear() + this.configs.clear() + const effective = newConfigs ?? new Map() + for (const [k, v] of effective) this.configs.set(k, v) + + // Reconnect in parallel — same approach as initial boot. Each + // failure is recorded as `status: failed` rather than aborting the + // restart. + const tasks = [...effective.entries()].map(async ([name, config]) => { + try { + return await connectOneServer(name, config, this.oauthFactory) + } catch (err) { + debugLog('mcp.restartAll-connect-failed', `${name}: ${String(err)}`) + return null + } + }) + const results = await Promise.all(tasks) + + // Sort by name so tool insertion order is stable (matches initial- + // boot behaviour in loader.ts). + const installable = results + .filter((r): r is ConnectResult => r !== null) + .sort((a, b) => a.server.name.localeCompare(b.server.name)) + for (const r of installable) this.installServer(r) + + return summary + } + + /** Drive a fresh OAuth round-trip for one HTTP server, then reconnect + * it. Used by `/mcp auth `. + * + * Pre-condition: the caller should have just cleared any stale + * tokens for this server via the token storage's `clear()` — + * otherwise an existing-but-expired token could short-circuit the + * re-auth path and reuse the bad state. + * + * Returns the post-auth server state. Throws if the server is stdio + * (no OAuth needed), if no OAuth factory is wired up, or if the + * user closes the browser tab / the callback times out. */ + async authenticateServer(name: string, hooks: AuthHooks = {}): Promise { + const config = this.configs.get(name) + if (!config) throw new Error(`No MCP server registered as "${name}"`) + if (!isHttpConfig(config)) { + throw new Error(`MCP server "${name}" is stdio — OAuth applies to HTTP servers only`) + } + if (!this.oauthFactory) { + throw new Error(`OAuth not configured — set a token storage in the loader to use /mcp auth`) + } + + return this.restartServer(name, { driveOAuth: hooks }) + } + + /** Replace the OAuth factory wholesale. Used by the CLI when the + * token storage / onBrowserOpen wiring is built lazily after the + * registry has been constructed (rare, but the test harness needs + * to swap it). */ + setOAuthFactory(factory: OAuthProviderFactory | undefined): void { + this.oauthFactory = factory + } + + // ── internals ────────────────────────────────────────────────────────── + + /** Drop every tool + resource owned by this server. Idempotent. */ + private removeServerEntries(name: string): void { + for (const [key, entry] of this.entries) { + if (entry.serverName === name) this.entries.delete(key) + } + for (const [key, res] of this.resources) { + if (res.serverName === name) this.resources.delete(key) + } + } + + /** Install a fresh ConnectResult into the maps. Caller is responsible + * for having removed any previous entries for the same server first. */ + private installServer(r: ConnectResult): void { + this.servers.set(r.server.name, r.server) + const taken = new Set(this.entries.keys()) + for (const t of r.tools) { + const callable = buildCallableName(r.server.name, t.name, taken) + taken.add(callable) + this.entries.set(callable, { + callableName: callable, + rawName: t.name, + serverName: r.server.name, + description: t.description ?? '', + inputSchema: t.inputSchema, + }) + } + for (const res of r.resources) this.resources.set(res.uri, res) + } } /** Empty registry — used when MCP is disabled entirely (no mcpServers @@ -110,3 +356,75 @@ export class McpRegistry { export function emptyRegistry(): McpRegistry { return new McpRegistry({ servers: [], tools: [], resources: [] }) } + +// ── Connect helper (shared with loader.ts on initial boot) ────────────── + +/** One server's worth of "connect + enumerate" output. Shared between + * initial boot (`loadMcpServers`) and the registry's restart paths so + * the connect-shape stays consistent. */ +export interface ConnectResult { + server: RegisteredServer + tools: ReadonlyArray<{ name: string; description?: string; inputSchema: Record }> + resources: ReadonlyArray +} + +/** Build a client for one server, run the connect handshake, and report + * the enumerated capabilities. `driveOAuth` (when set) opts into the + * full browser-based OAuth flow on UnauthorizedError; without it, + * UnauthorizedError surfaces as `status: needs_auth` and the user is + * expected to invoke /mcp auth explicitly. */ +export async function connectOneServer( + name: string, + rawConfig: McpServerConfig, + oauthFactory: OAuthProviderFactory | undefined, + driveOAuth?: AuthHooks, +): Promise { + // Honour `enabled: false` — register but skip the connection. + if (rawConfig.enabled === false) { + const client = new McpClient(name, rawConfig) + return { + server: { name, client, status: { kind: 'disabled' } }, + tools: [], + resources: [], + } + } + + // Expand ${VAR} references before constructing the client. + let expanded: McpServerConfig + try { + expanded = expandEnvDeep(rawConfig) + } catch (err) { + const msg = err instanceof EnvExpansionError ? err.message : err instanceof Error ? err.message : String(err) + const client = new McpClient(name, rawConfig) + return { + server: { name, client, status: { kind: 'failed', error: msg } }, + tools: [], + resources: [], + } + } + + const authProvider = oauthFactory && isHttpConfig(expanded) ? oauthFactory(name, expanded.url) : undefined + const client = new McpClient(name, expanded, authProvider) + + try { + const info = driveOAuth ? await client.connectWithOAuth(driveOAuth) : await client.connect() + return { + server: { + name, + client, + status: { kind: 'connected', toolCount: info.toolCount, resourceCount: info.resourceCount }, + }, + tools: client.tools(), + resources: client.resources(), + } + } catch (err) { + const msg = err instanceof Error ? err.message : String(err) + const needsAuth = /unauth|401|UnauthorizedError/i.test(msg) && isHttpConfig(expanded) + const status: RegisteredServer['status'] = needsAuth ? { kind: 'needs_auth' } : { kind: 'failed', error: msg } + return { + server: { name, client, status, stderrTail: client.stderr() || undefined }, + tools: [], + resources: [], + } + } +} diff --git a/packages/core/tests/mcp-integration.test.ts b/packages/core/tests/mcp-integration.test.ts index 7b42655..02fc958 100644 --- a/packages/core/tests/mcp-integration.test.ts +++ b/packages/core/tests/mcp-integration.test.ts @@ -13,8 +13,10 @@ import path from 'node:path' import { fileURLToPath } from 'node:url' import { McpClient } from '../src/mcp/client.js' +import { loadMcpServers } from '../src/mcp/loader.js' import { buildCallableName } from '../src/mcp/name-mangling.js' import { McpRegistry } from '../src/mcp/registry.js' +import type { McpServerConfig } from '../src/mcp/types.js' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const MOCK_SERVER = path.join(__dirname, 'fixtures', 'mock-mcp-server.mjs') @@ -69,6 +71,116 @@ describe('MCP integration (stdio)', () => { } }, 15_000) + it('restartServer reconnects a stdio server in place', async () => { + // Bootstrap a real registry via the loader so configs + oauthFactory + // wiring is exercised end-to-end. The loader spawns the mock server, + // enumerates `echo` + `add`, and returns a registry whose configs + // map remembers the launch config — restartServer() reads from there. + const { registry } = await loadMcpServers({ + userServers: { + mock: { command: process.execPath, args: [MOCK_SERVER] }, + }, + projectServers: undefined, + projectPath: process.cwd(), + askUser: async () => 'skip', + }) + try { + const before = registry + .list() + .map((t) => t.callableName) + .sort() + expect(before).toContain('mcp__mock__echo') + + const restarted = await registry.restartServer('mock') + expect(restarted.status.kind).toBe('connected') + + // Tool list should be the same after a reconnect against the same + // server — we're verifying the registry rebuilt cleanly, not that + // the server changed its surface. + const after = registry + .list() + .map((t) => t.callableName) + .sort() + expect(after).toEqual(before) + + // Verify the new client (not the old, now-closed one) handles calls. + const r = await registry.callTool('mcp__mock__echo', { text: 'after-restart' }) + expect(r.text).toBe('after-restart') + } finally { + await registry.shutdown() + } + }, 20_000) + + it('restartAll diffs added / removed / changed servers', async () => { + // Boot with one server, then restartAll with a different config set: + // - 'mock' stays (with the same config) → unchanged + // - 'mock-b' is new → added + // - 'mock-old' would've been there but isn't → (n/a — wasn't booted) + // Then a second restartAll removes 'mock-b' to exercise the removed path. + const { registry } = await loadMcpServers({ + userServers: { + mock: { command: process.execPath, args: [MOCK_SERVER] }, + }, + projectServers: undefined, + projectPath: process.cwd(), + askUser: async () => 'skip', + }) + try { + // restartAll with `mock` unchanged + new `mock-b` + const configs1 = new Map([ + ['mock', { command: process.execPath, args: [MOCK_SERVER] }], + ['mock-b', { command: process.execPath, args: [MOCK_SERVER] }], + ]) + const summary1 = await registry.restartAll(configs1) + expect(summary1.added).toEqual(['mock-b']) + expect(summary1.removed).toEqual([]) + expect(summary1.unchanged).toEqual(['mock']) + + // Now both connected — tool list spans both servers. + const names = registry + .list() + .map((t) => t.callableName) + .sort() + expect(names).toContain('mcp__mock__echo') + expect(names).toContain('mcp__mock_b__echo') + + // Second restartAll: remove mock-b, change mock's args slightly. + const configs2 = new Map([ + ['mock', { command: process.execPath, args: [MOCK_SERVER], timeout: 15_000 }], + ]) + const summary2 = await registry.restartAll(configs2) + expect(summary2.added).toEqual([]) + expect(summary2.removed).toEqual(['mock-b']) + expect(summary2.changed).toEqual(['mock']) + + // mock-b should no longer appear in the tool surface. + const afterRemoval = registry + .list() + .map((t) => t.callableName) + .sort() + expect(afterRemoval).not.toContain('mcp__mock_b__echo') + expect(afterRemoval).toContain('mcp__mock__echo') + } finally { + await registry.shutdown() + } + }, 30_000) + + it('authenticateServer rejects stdio servers', async () => { + const { registry } = await loadMcpServers({ + userServers: { + mock: { command: process.execPath, args: [MOCK_SERVER] }, + }, + projectServers: undefined, + projectPath: process.cwd(), + askUser: async () => 'skip', + }) + try { + await expect(registry.authenticateServer('mock')).rejects.toThrow(/stdio/i) + } finally { + await registry.shutdown() + } + }, 15_000) + it('registry dispatches by callable name', async () => { const client = new McpClient('mock', { command: process.execPath, args: [MOCK_SERVER] }) try { From 45495078b3f288aed546d4e4718987a31bc48901 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Mon, 18 May 2026 20:45:34 +0800 Subject: [PATCH 3/9] fix(ci): whitelist @vscode/ripgrep so its postinstall downloads the rg binary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit pnpm 10's default sandbox skips install scripts for every dep that isn't in onlyBuiltDependencies. @vscode/ripgrep relies on its postinstall to fetch the rg binary from GitHub Releases, so on a fresh CI runner the binary never lands on disk and glob-tool / utils tests spawn rg with ENOENT. Local runs were fine only because the pnpm store had a cached copy from a pre-10 install — fresh runners have no such cache, which is why the failure was CI-only. --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index a8c160f..e0c5240 100644 --- a/package.json +++ b/package.json @@ -51,7 +51,8 @@ }, "pnpm": { "onlyBuiltDependencies": [ - "esbuild" + "esbuild", + "@vscode/ripgrep" ], "peerDependencyRules": { "allowedVersions": { From 4cc1056fd27006e85ef371b5cdad3bb516d75635 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Mon, 18 May 2026 21:12:32 +0800 Subject: [PATCH 4/9] fix(ci): pass GITHUB_TOKEN to @vscode/ripgrep postinstall so it doesn't 403 After whitelisting @vscode/ripgrep for builds in the previous commit, its postinstall started running on CI and immediately hit api.github.com/repos/microsoft/ripgrep-prebuilt/releases/tags/v15.0.0 with a 403. The anonymous GitHub API quota is 60 req/h per IP, and Actions runners share IP pools - concurrent runs across the org exhaust it. postinstall.js reads process.env.GITHUB_TOKEN and forwards it as a bearer token, which lifts us into the 5000/h authenticated bucket. secrets.GITHUB_TOKEN is auto-provisioned per workflow run so there's nothing to configure. --- .github/workflows/pr-check.yml | 8 ++++++++ .github/workflows/release.yml | 6 ++++++ 2 files changed, 14 insertions(+) diff --git a/.github/workflows/pr-check.yml b/.github/workflows/pr-check.yml index c5ba6f1..8f2dc55 100644 --- a/.github/workflows/pr-check.yml +++ b/.github/workflows/pr-check.yml @@ -7,6 +7,14 @@ on: permissions: contents: write +# @vscode/ripgrep's postinstall hits api.github.com/repos/microsoft/ripgrep-prebuilt/releases +# to find the rg binary URL. Unauthenticated requests are rate-limited to 60/h per IP, and +# Actions runners share IP pools — concurrent runs across the org reliably trip the limit +# and 403 the install. Passing the auto-provided GITHUB_TOKEN flips it into the +# authenticated 5000/h bucket, which is plenty. +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + jobs: lint-format: name: Lint & Format diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5e9fdc4..39f7221 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,6 +8,12 @@ permissions: contents: write id-token: write +# Authenticate @vscode/ripgrep's postinstall against the GitHub API to +# avoid the 60 req/h anonymous rate limit shared across Actions runners. +# Same reasoning as pr-check.yml — see the note there. +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + jobs: release: name: Release From 8f94fa44c69203301902d1068270fbd4cb1ee54e Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Tue, 19 May 2026 16:20:03 +0800 Subject: [PATCH 5/9] feat(core/cli): add /mcp add, /mcp add-json, /mcp remove commands Three new slash subcommands that let users manage MCP server config without hand-editing config.json: /mcp add [args...] stdio server /mcp add --http Streamable HTTP server /mcp add-json '' raw JSON for complex configs /mcp remove delete (with y/N confirm) - Defaults to user scope (~/.x-code/config.json). --scope project writes to /.x-code/config.json AND auto-adds the project to trusted-projects.json, so the user running the add doesn't trip their own consent dialog on next launch. Collaborators cloning the repo still go through the trust prompt normally. - /mcp remove auto-detects which scope contains the server; ambiguous (both scopes) requires --scope to avoid silent wrong-pick. - Duplicate add prints the existing config as JSON so the user can see what they were about to clobber. - Tokenizer guards Windows paths: backslash escapes only whitespace, quotes, and itself; `D:\res\x-code\tmp` survives verbatim instead of collapsing to `D:resx-codetmp`. Not implemented (rejected by design): - --scope local (no third config tier in our architecture) - --trust flag a la Gemini (would bypass the ask-first permission model) --- packages/cli/src/ui/components/App.tsx | 236 +++++++++- packages/core/src/index.ts | 18 + packages/core/src/mcp/arg-parser.ts | 418 ++++++++++++++++++ packages/core/src/mcp/config-writer.ts | 155 +++++++ packages/core/tests/mcp-arg-parser.test.ts | 274 ++++++++++++ packages/core/tests/mcp-config-writer.test.ts | 215 +++++++++ 6 files changed, 1314 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/mcp/arg-parser.ts create mode 100644 packages/core/src/mcp/config-writer.ts create mode 100644 packages/core/tests/mcp-arg-parser.test.ts create mode 100644 packages/core/tests/mcp-config-writer.test.ts diff --git a/packages/cli/src/ui/components/App.tsx b/packages/cli/src/ui/components/App.tsx index 81b6a3d..861cead 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -7,18 +7,28 @@ import { MODEL_ALIASES, PROVIDER_MODELS, createModelRegistry, + detectScope, estimateTokenCount, getAutoMemory, getAvailableProviders, getContextWindow, + getMcpConfigPath, getTokenStorage, listSessions, loadMergedConfigsFromDisk, loadSession, loadUserConfig, + parseAdd, + parseAddJson, + parseRemove, pickLatestSession, + readServerConfig, + removeServerFromConfig, resolveModelId, saveUserConfig, + serverExists, + trustProject, + writeServerToConfig, } from '@x-code-cli/core' import type { AgentOptions, KnowledgeFact, LanguageModel, LoadedSession, TokenUsage } from '@x-code-cli/core' @@ -68,7 +78,10 @@ export const SLASH_COMMANDS = [ { name: '/usage', description: 'Show current-session token usage (input/output/cache)' }, { name: '/usage-history', description: 'List past sessions in this project' }, { name: '/memory', description: 'Show auto-memory entries (project + global)' }, - { name: '/mcp', description: 'Manage MCP servers (list / tools / auth / logout / refresh)' }, + { + name: '/mcp', + description: 'Manage MCP servers (list / tools / add / add-json / remove / auth / logout / refresh)', + }, { name: '/exit', description: 'Exit (flushes session)' }, ] as const @@ -1208,13 +1221,232 @@ export function App({ } return } + case 'add': + await handleMcpAdd(text, subArg) + return + + case 'add-json': + await handleMcpAddJson(text, subArg) + return + + case 'remove': + case 'rm': + await handleMcpRemove(text, subArg) + return + default: { - addCommandMessage(text, `Unknown subcommand: /mcp ${sub}. Available: list, tools, auth, logout, refresh.`) + addCommandMessage( + text, + `Unknown subcommand: /mcp ${sub}. Available: list, tools, add, add-json, remove, auth, logout, refresh.`, + ) return } } } + /** /mcp add — write a new server to user (default) or project config. + * + * Doesn't auto-connect: tool surface changes mid-session would invalidate + * the prompt cache and force a miss on the next turn (OpenAI-compatible + * providers' prefix cache). User is told to `/mcp refresh` or restart + * when they're ready — matches the design doc's "explicit refresh" + * philosophy. + * + * --scope project also auto-trusts the project (the user running the + * command IS the consent signal — no point making them confirm a + * trust dialog for their own command on next start). Collaborators + * who clone the repo still go through the dialog normally. */ + async function handleMcpAdd(text: string, subArgRaw: string) { + const res = parseAdd(subArgRaw) + if (!res.ok) { + addCommandMessage(text, res.error) + return + } + const { name, scope, config } = res.command + + // Duplicate-check in the requested scope. We use serverExists rather + // than detectScope here on purpose: cross-scope name reuse is allowed + // (a user-scope and project-scope server can legitimately share a + // name — e.g. a personal vs team-shared variant). Only same-scope + // collisions block the add. + if (await serverExists(name, scope, process.cwd())) { + const existing = await readServerConfig(name, scope, process.cwd()) + const summary = + existing && typeof existing === 'object' + ? JSON.stringify(existing, null, 2) + .split('\n') + .map((l) => ' ' + l) + .join('\n') + : '(unreadable)' + addCommandMessage( + text, + [ + `Server "${name}" already exists in ${scope} scope:`, + summary, + '', + `Run /mcp remove --scope ${scope} ${name} first, or pick a different name.`, + ].join('\n'), + ) + return + } + + let written: { path: string } + try { + written = await writeServerToConfig(name, config, scope, process.cwd()) + } catch (err) { + addCommandMessage(text, `Failed to add "${name}": ${err instanceof Error ? err.message : String(err)}`) + return + } + + // For project scope, auto-trust this path so the user doesn't bump + // into their own consent dialog on next launch. + let autoTrusted = false + if (scope === 'project') { + try { + await trustProject(process.cwd()) + autoTrusted = true + } catch { + // Non-fatal — they'll just see the trust dialog next launch. + } + } + + const transport = 'url' in config ? 'http' : 'stdio' + const lines = [`Added MCP server "${name}" (${transport}) to ${written.path}.`] + if (autoTrusted) { + lines.push('Auto-trusted this project for future launches.') + } + if (scope === 'project') { + lines.push('Tip: commit `.x-code/config.json` to share with collaborators.') + } + lines.push('Run /mcp refresh to load it now, or restart xc.') + addCommandMessage(text, lines.join('\n')) + } + + /** /mcp add-json — same as /mcp add but takes a raw JSON object for the + * config body. The escape hatch for complex configs that don't fit + * command-line flags (nested env, multiple headers, custom cwd, etc.). */ + async function handleMcpAddJson(text: string, subArgRaw: string) { + const res = parseAddJson(subArgRaw) + if (!res.ok) { + addCommandMessage(text, res.error) + return + } + const { name, scope, config } = res.command + + if (await serverExists(name, scope, process.cwd())) { + addCommandMessage( + text, + `Server "${name}" already exists in ${scope} scope. Run /mcp remove --scope ${scope} ${name} first.`, + ) + return + } + + let written: { path: string } + try { + written = await writeServerToConfig(name, config, scope, process.cwd()) + } catch (err) { + addCommandMessage(text, `Failed to add "${name}": ${err instanceof Error ? err.message : String(err)}`) + return + } + + let autoTrusted = false + if (scope === 'project') { + try { + await trustProject(process.cwd()) + autoTrusted = true + } catch { + // best-effort + } + } + + const lines = [`Added MCP server "${name}" to ${written.path}.`] + if (autoTrusted) lines.push('Auto-trusted this project for future launches.') + if (scope === 'project') lines.push('Tip: commit `.x-code/config.json` to share with collaborators.') + lines.push('Run /mcp refresh to load it now, or restart xc.') + addCommandMessage(text, lines.join('\n')) + } + + /** /mcp remove — delete a server from config.json. Asks y/N before doing + * anything destructive (every other competitor skips this — we keep + * it because a typo can nuke a real entry and the cost of one extra + * keypress is near zero). Current session keeps running with whatever + * it had loaded — disconnecting mid-session has more downside (live + * tool calls get orphaned) than upside (the file change only matters + * at next launch / refresh). */ + async function handleMcpRemove(text: string, subArgRaw: string) { + const res = parseRemove(subArgRaw) + if (!res.ok) { + addCommandMessage(text, res.error) + return + } + const { name } = res.command + let scope = res.command.scope + + if (!scope) { + // Auto-detect. The ambiguous case (both scopes) forces an explicit + // --scope so we don't silently delete the wrong one. + const detected = await detectScope(name, process.cwd()) + switch (detected.kind) { + case 'not-found': + addCommandMessage(text, `Server "${name}" is not in user or project config — nothing to remove.`) + return + case 'both': + addCommandMessage(text, `Server "${name}" exists at both scopes. Specify --scope user or --scope project.`) + return + case 'user': + case 'project': + scope = detected.kind + break + } + } else { + // Explicit scope: verify presence before bothering the user with a + // confirmation dialog. + if (!(await serverExists(name, scope, process.cwd()))) { + addCommandMessage( + text, + `Server "${name}" is not in ${scope} scope (${getMcpConfigPath(scope, process.cwd())}) — nothing to remove.`, + ) + return + } + } + + const confirmAnswer = await askQuestion( + `Remove MCP server "${name}" from ${scope} scope?\n (${getMcpConfigPath(scope, process.cwd())})`, + [ + { label: 'Remove', description: 'Delete this server entry. Current session unchanged.' }, + { label: 'Cancel', description: 'Keep the config as-is.' }, + ], + { noOther: true }, + ) + if (confirmAnswer !== 'Remove') { + addCommandMessage(text, `Cancelled — "${name}" not removed.`) + return + } + + let result: { path: string; removed: boolean } + try { + result = await removeServerFromConfig(name, scope, process.cwd()) + } catch (err) { + addCommandMessage(text, `Failed to remove "${name}": ${err instanceof Error ? err.message : String(err)}`) + return + } + if (!result.removed) { + // Race: someone deleted the file or entry between detection and + // remove. Idempotent path — just say so. + addCommandMessage(text, `Server "${name}" was already gone from ${scope} scope.`) + return + } + + addCommandMessage( + text, + [ + `Removed "${name}" from ${scope} scope (${result.path}).`, + 'Current session unchanged — the running server (if any) keeps working until xc exits.', + `Stored OAuth tokens (if any) kept — run /mcp logout ${name} to clear them too.`, + ].join('\n'), + ) + } + // RENDERING ARCHITECTURE // // `ChatInput` owns the ENTIRE terminal region below the initial header: diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a7c4481..622bb93 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -115,3 +115,21 @@ export { isStdioConfig, isHttpConfig } from './mcp/types.js' export { buildCallableName, isMcpCallableName, MCP_PREFIX } from './mcp/name-mangling.js' export { expandEnvDeep, expandEnvString, EnvExpansionError } from './mcp/expand-env.js' export { parseServersBlock, parseServerConfig, mcpServersSchema } from './mcp/config-schema.js' +export { parseAdd, parseAddJson, parseRemove, tokenize } from './mcp/arg-parser.js' +export type { + AddCommand, + AddJsonCommand, + RemoveCommand, + ParsedCommand, + ParseResult, + ConfigScope, +} from './mcp/arg-parser.js' +export { + detectScope, + getConfigPath as getMcpConfigPath, + readServerConfig, + removeServerFromConfig, + serverExists, + writeServerToConfig, +} from './mcp/config-writer.js' +export type { DetectScopeResult } from './mcp/config-writer.js' diff --git a/packages/core/src/mcp/arg-parser.ts b/packages/core/src/mcp/arg-parser.ts new file mode 100644 index 0000000..b40bb2e --- /dev/null +++ b/packages/core/src/mcp/arg-parser.ts @@ -0,0 +1,418 @@ +// @x-code-cli/core — Slash-command argument parser for /mcp add/add-json/remove +// +// Slash commands deliver one raw string (the text after `/mcp `) and we +// have to coerce that into a structured McpServerConfig. The parser is +// deliberately narrow: +// - one entry point per subcommand (parseAdd / parseAddJson / parseRemove) +// - returns a tagged ParseResult so the App.tsx caller branches once and +// gets either a usable command or a one-line error string +// +// Quoting rules we honour, intentionally minimal: +// - "double-quoted" and 'single-quoted' strings keep whitespace +// - backslash escapes ONLY whitespace and quote chars (and itself) — +// `\ ` for a literal space, `\"` for a literal quote, `\\` for a +// literal backslash. Backslash before anything else passes through +// verbatim. This is critical on Windows where users routinely paste +// paths like `D:\res\x-code-cli\tmp` — full POSIX-style escape would +// eat all those backslashes and silently corrupt the path. +// - everything else: whitespace splits tokens +// +// Why we don't lean on a shell-words npm package: the surface here is +// small, and a 50-line tokeniser keeps the parser entirely deterministic +// for tests + free of cross-platform shell-escaping surprises. +import type { McpHttpServerConfig, McpServerConfig, McpStdioServerConfig } from './types.js' + +export type ConfigScope = 'user' | 'project' + +export interface AddCommand { + kind: 'add' + name: string + scope: ConfigScope + config: McpServerConfig +} + +export interface AddJsonCommand { + kind: 'add-json' + name: string + scope: ConfigScope + config: McpServerConfig +} + +export interface RemoveCommand { + kind: 'remove' + name: string + /** Undefined when the user didn't pass --scope; caller auto-detects. */ + scope?: ConfigScope +} + +export type ParsedCommand = AddCommand | AddJsonCommand | RemoveCommand + +export type ParseResult = + | { ok: true; command: T } + | { ok: false; error: string } + +/** Names allowed in `mcpServers.`. Tightened relative to the runtime + * name-mangling sanitizer because *config entry point* is a better place + * to refuse weird names — surprising sanitisation post-add ("I typed + * `my server!` and got `mcp__my_server___xxx`") is worse than a clear + * rejection. Length 32 leaves headroom for the `mcp__{server}__{tool}` + * prefix to stay well under the model-side 64-char tool name limit. */ +const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/ + +// ── Top-level entry points ───────────────────────────────────────────────── + +/** Parse args for `/mcp add [...flags] [args...]`. */ +export function parseAdd(rawArg: string): ParseResult { + const tokRes = tokenize(rawArg) + if (!tokRes.ok) return tokRes + const tokens = tokRes.tokens + + // First pass: pull flags off the front. Stop at the first non-flag token + // (which becomes the server name); the `--` separator hard-stops flag + // parsing and is then dropped — everything after is positional. + let isHttp = false + let scope: ConfigScope = 'user' + let timeout: number | undefined + const envEntries: Array<[string, string]> = [] + const headerEntries: Array<[string, string]> = [] + + let i = 0 + let sawDoubleDash = false + while (i < tokens.length) { + const t = tokens[i]! + if (!t.startsWith('-')) break // first positional + if (t === '--') { + sawDoubleDash = true + i++ + break + } + if (t === '--http' || t === '--transport') { + // --http is our shorthand; --transport is Claude/Gemini syntax + // (we accept only http here; sse is intentionally not supported per + // the design doc — MCP spec deprecated SSE in 2025-03). + if (t === '--transport') { + const next = tokens[i + 1] + if (next !== 'http') { + return err( + `--transport only supports "http" (got ${next ?? '(missing)'}); use --http directly or omit for stdio`, + ) + } + i += 2 + } else { + i++ + } + isHttp = true + continue + } + if (t === '--scope') { + const v = tokens[i + 1] + if (v !== 'user' && v !== 'project') { + return err(`--scope requires "user" or "project" (got ${v ?? '(missing)'})`) + } + scope = v + i += 2 + continue + } + if (t === '--env') { + const v = tokens[i + 1] + if (typeof v !== 'string') return err('--env requires a KEY=VALUE argument') + const eq = v.indexOf('=') + if (eq <= 0) return err(`--env expects KEY=VALUE (got ${v})`) + envEntries.push([v.slice(0, eq), v.slice(eq + 1)]) + i += 2 + continue + } + if (t === '--header') { + const v = tokens[i + 1] + if (typeof v !== 'string') return err('--header requires a "Key: value" argument') + // Header format: "Key: Value" (RFC 7230 style). Be permissive with + // whitespace around the colon to match user habit. + const colon = v.indexOf(':') + if (colon <= 0) return err(`--header expects "Key: Value" (got ${v})`) + headerEntries.push([v.slice(0, colon).trim(), v.slice(colon + 1).trim()]) + i += 2 + continue + } + if (t === '--timeout') { + const v = tokens[i + 1] + if (typeof v !== 'string') return err('--timeout requires a number (ms)') + const n = Number(v) + if (!Number.isInteger(n) || n <= 0) return err(`--timeout requires a positive integer (got ${v})`) + timeout = n + i += 2 + continue + } + return err(`Unknown flag: ${t}`) + } + + // Positional args. After the (optional) `--`, everything left is name + + // command/url + the rest. Stdio: tokens[i] = name, tokens[i+1] = command, + // tokens[i+2..] = args. HTTP: tokens[i] = name, tokens[i+1] = url, nothing + // after. + // + // Users coming from Claude Code muscle-memory write `add -- ` + // with the separator AFTER the name. Our flag loop already stops at the + // first non-flag (the name), so any `--` lands at positional[1]. Drop it + // — it's cosmetic, the actual command follows. + let positional = tokens.slice(i) + if (positional[1] === '--') { + positional = [positional[0]!, ...positional.slice(2)] + } + if (positional.length < 2) { + return err( + isHttp + ? 'Usage: /mcp add --http [--scope user|project] [--header "K: V"]... [--timeout N] ' + : 'Usage: /mcp add [--scope user|project] [--env K=V]... [--timeout N] [args...]', + ) + } + const name = positional[0]! + if (!NAME_RE.test(name)) { + return err(`Invalid server name "${name}". Must match ${NAME_RE.source}.`) + } + + if (isHttp) { + if (positional.length > 2) { + return err('HTTP servers take only — no extra positional args') + } + if (envEntries.length > 0) return err('--env is only valid for stdio servers') + const url = positional[1]! + if (!isValidUrl(url)) return err(`Invalid URL: ${url}`) + const config: McpHttpServerConfig = { + url, + ...(headerEntries.length > 0 ? { headers: Object.fromEntries(headerEntries) } : {}), + ...(timeout !== undefined ? { timeout } : {}), + } + return ok({ kind: 'add', name, scope, config }) + } + + // stdio. `--` is allowed but optional. Some users will write + // `/mcp add fs npx -y @pkg/foo /tmp`, others `/mcp add fs -- npx -y ...`. + // Both reach this branch identically — we already stripped `--` upstream. + void sawDoubleDash + if (headerEntries.length > 0) return err('--header is only valid for HTTP servers (--http)') + const command = positional[1]! + const args = positional.slice(2) + const config: McpStdioServerConfig = { + command, + ...(args.length > 0 ? { args } : {}), + ...(envEntries.length > 0 ? { env: Object.fromEntries(envEntries) } : {}), + ...(timeout !== undefined ? { timeout } : {}), + } + return ok({ kind: 'add', name, scope, config }) +} + +/** Parse args for `/mcp add-json [--scope ...] ''`. + * The JSON blob is whatever the schema accepts — same validation runs + * on the loader side, so passing parseServerConfig here keeps errors + * uniform between "wrote it via CLI" and "edited the file by hand". */ +export function parseAddJson(rawArg: string): ParseResult { + // add-json uniquely benefits from KEEPING the JSON literal intact rather + // than running it through the shell tokeniser (which would mangle nested + // quotes). Strategy: pull flags + name off the front via tokenize on the + // *prefix* up to where the JSON begins, then take the JSON as the + // suffix verbatim. We find the JSON start by looking for the first `{` + // after the name token. + + const trimmed = rawArg.trim() + if (!trimmed) { + return err("Usage: /mcp add-json [--scope user|project] ''") + } + + // Walk through tokens until we either run out of flags/name OR hit a + // token starting with `{`. The JSON blob may have been entered single- + // quoted to the slash command — in that case the tokeniser strips the + // quotes and we get a clean object string. If unquoted, the user + // shouldn't have nested whitespace anyway, so a single token suffices. + const tokRes = tokenize(trimmed) + if (!tokRes.ok) return tokRes + const tokens = tokRes.tokens + + let scope: ConfigScope = 'user' + let i = 0 + while (i < tokens.length) { + const t = tokens[i]! + if (t === '--scope') { + const v = tokens[i + 1] + if (v !== 'user' && v !== 'project') { + return err(`--scope requires "user" or "project" (got ${v ?? '(missing)'})`) + } + scope = v + i += 2 + continue + } + if (!t.startsWith('-')) break + return err(`Unknown flag for add-json: ${t}`) + } + + if (i >= tokens.length) { + return err("Usage: /mcp add-json [--scope user|project] ''") + } + const name = tokens[i]! + if (!NAME_RE.test(name)) { + return err(`Invalid server name "${name}". Must match ${NAME_RE.source}.`) + } + i++ + + // The JSON might have been split across tokens if the user didn't quote + // it. Concatenate the remainder with single spaces; JSON parsing tolerates + // any whitespace between tokens so this round-trips fine in practice. + if (i >= tokens.length) { + return err(`Missing JSON body for "${name}". Wrap it in single quotes: '{...}'`) + } + const jsonBlob = tokens.slice(i).join(' ').trim() + + let parsed: unknown + try { + parsed = JSON.parse(jsonBlob) + } catch (e) { + return err(`Invalid JSON: ${e instanceof Error ? e.message : String(e)}`) + } + + // We validate via the same zod schema the loader uses, but we keep the + // dependency in the writer layer to avoid a circular import here — so + // signal "needs validation" by returning the parsed object as + // McpServerConfig and letting the caller validate. The writer DOES + // validate before writing (see config-writer.ts). + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + return err('JSON body must be an object') + } + return ok({ kind: 'add-json', name, scope, config: parsed as McpServerConfig }) +} + +/** Parse args for `/mcp remove [--scope ...] `. */ +export function parseRemove(rawArg: string): ParseResult { + const tokRes = tokenize(rawArg) + if (!tokRes.ok) return tokRes + const tokens = tokRes.tokens + if (tokens.length === 0) { + return err('Usage: /mcp remove [--scope user|project] ') + } + + let scope: ConfigScope | undefined + let i = 0 + while (i < tokens.length) { + const t = tokens[i]! + if (t === '--scope') { + const v = tokens[i + 1] + if (v !== 'user' && v !== 'project') { + return err(`--scope requires "user" or "project" (got ${v ?? '(missing)'})`) + } + scope = v + i += 2 + continue + } + if (!t.startsWith('-')) break + return err(`Unknown flag for remove: ${t}`) + } + + if (i >= tokens.length) { + return err('Usage: /mcp remove [--scope user|project] ') + } + if (i + 1 < tokens.length) { + return err(`/mcp remove takes exactly one name (got extra: ${tokens.slice(i + 1).join(' ')})`) + } + const name = tokens[i]! + if (!NAME_RE.test(name)) { + return err(`Invalid server name "${name}". Must match ${NAME_RE.source}.`) + } + return ok({ kind: 'remove', name, scope }) +} + +// ── Internals ────────────────────────────────────────────────────────────── + +function ok(command: T): ParseResult { + return { ok: true, command } +} +function err(message: string): { ok: false; error: string } { + return { ok: false, error: message } +} + +/** Minimal POSIX-ish tokeniser. Supports "..."/'...' quoting and + * backslash-escape of any single char. Quotes are stripped from the + * output; escapes drop the backslash. Returns a tagged result so the + * caller can surface "unclosed quote" without throwing. */ +export function tokenize(input: string): { ok: true; tokens: string[] } | { ok: false; error: string } { + const tokens: string[] = [] + let i = 0 + const n = input.length + + while (i < n) { + // Skip whitespace between tokens. + while (i < n && /\s/.test(input[i]!)) i++ + if (i >= n) break + + let token = '' + let quote: '"' | "'" | null = null + let inToken = true + + while (i < n && inToken) { + const c = input[i]! + if (quote) { + if (c === '\\' && quote === '"' && i + 1 < n) { + // Inside double quotes, allow backslash escape for " and \. + const next = input[i + 1]! + if (next === '"' || next === '\\') { + token += next + i += 2 + continue + } + // Otherwise keep the backslash literal — POSIX behaviour. + token += c + i++ + continue + } + if (c === quote) { + quote = null + i++ + continue + } + token += c + i++ + continue + } + // Unquoted. + if (c === '"' || c === "'") { + quote = c + i++ + continue + } + if (c === '\\' && i + 1 < n) { + // Only escape whitespace, quotes, and backslash itself. Anything + // else passes through with the backslash intact so Windows paths + // like `D:\res\x-code-cli\tmp` survive — eating those backslashes + // would silently corrupt the path and the user wouldn't notice + // until the MCP server failed to access the directory. + const next = input[i + 1]! + if (next === ' ' || next === '\t' || next === '"' || next === "'" || next === '\\') { + token += next + i += 2 + continue + } + // Backslash followed by anything else: keep both chars literal. + token += c + i++ + continue + } + if (/\s/.test(c)) { + inToken = false + break + } + token += c + i++ + } + if (quote) { + return { ok: false, error: `Unclosed ${quote} quote` } + } + tokens.push(token) + } + return { ok: true, tokens } +} + +function isValidUrl(s: string): boolean { + try { + const u = new URL(s) + return u.protocol === 'http:' || u.protocol === 'https:' + } catch { + return false + } +} diff --git a/packages/core/src/mcp/config-writer.ts b/packages/core/src/mcp/config-writer.ts new file mode 100644 index 0000000..d2a9cca --- /dev/null +++ b/packages/core/src/mcp/config-writer.ts @@ -0,0 +1,155 @@ +// @x-code-cli/core — Read/write `mcpServers` in user / project config.json +// +// Drives `/mcp add` and `/mcp remove`. The job is small but error-prone: +// - preserve unrelated top-level fields (theme, model, thinking, etc.) +// - preserve other mcpServers entries when adding/removing one +// - write atomically so a Ctrl-C mid-write can't corrupt the file +// - never read once, write later — re-read at write time so we don't +// stomp on a concurrent edit (rare but cheap to guard against) +// +// The writer validates every config it persists against the same Zod +// schema the loader uses, so add-json input that would be rejected at +// load time is rejected here instead — fail-fast at the entry point. +import fs from 'node:fs/promises' +import path from 'node:path' + +import { getUserConfigPath } from '../config/index.js' +import { XCODE_DIR } from '../utils.js' +import { parseServerConfig } from './config-schema.js' +import { type McpServerConfig } from './types.js' + +export type ConfigScope = 'user' | 'project' + +/** Where each scope's config.json lives. Mirrors the same paths the loader + * reads from, so a write here is guaranteed to be picked up on the next + * load (or `/mcp refresh`). */ +export function getConfigPath(scope: ConfigScope, cwd: string): string { + if (scope === 'user') return getUserConfigPath() + return path.join(cwd, XCODE_DIR, 'config.json') +} + +/** Read the parsed JSON object at the given scope. Returns `{}` when the + * file doesn't exist, is empty, or is malformed — the caller treats + * those uniformly as "no MCP servers configured here yet". */ +async function readConfigObject(scope: ConfigScope, cwd: string): Promise> { + const file = getConfigPath(scope, cwd) + let raw: string + try { + raw = await fs.readFile(file, 'utf-8') + } catch { + return {} + } + try { + const parsed = JSON.parse(raw) as unknown + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // Malformed JSON. We deliberately don't overwrite without a parse — + // bail and let the caller surface an error. Returning {} here would + // mask a corrupt config and writing would clobber whatever was there. + throw new Error(`Config file at ${file} is not valid JSON. Fix it manually before running /mcp add or /mcp remove.`) + } + return {} +} + +/** Atomic JSON write: write to tmp, then rename. Trailing newline + 2-space + * indent matches the convention used elsewhere (saveUserConfig). */ +async function writeConfigObject(scope: ConfigScope, cwd: string, obj: Record): Promise { + const file = getConfigPath(scope, cwd) + await fs.mkdir(path.dirname(file), { recursive: true }) + const tmp = file + '.tmp' + await fs.writeFile(tmp, JSON.stringify(obj, null, 2) + '\n', 'utf-8') + await fs.rename(tmp, file) +} + +/** Where a given server name currently lives. Returned to the App.tsx + * caller so `/mcp remove` can auto-target the right scope (and detect + * the rare both-scopes ambiguity that forces an explicit --scope). */ +export type DetectScopeResult = { kind: 'not-found' } | { kind: 'user' } | { kind: 'project' } | { kind: 'both' } + +export async function detectScope(name: string, cwd: string): Promise { + const [user, project] = await Promise.all([serverExists(name, 'user', cwd), serverExists(name, 'project', cwd)]) + if (user && project) return { kind: 'both' } + if (user) return { kind: 'user' } + if (project) return { kind: 'project' } + return { kind: 'not-found' } +} + +export async function serverExists(name: string, scope: ConfigScope, cwd: string): Promise { + const obj = await readConfigObject(scope, cwd) + const servers = obj.mcpServers + if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return false + return Object.prototype.hasOwnProperty.call(servers, name) +} + +/** Add a server to the given scope's config.json. Refuses to overwrite — + * caller must check duplicates first via `serverExists` and surface a + * helpful error including current vs. attempted config. */ +export async function writeServerToConfig( + name: string, + config: McpServerConfig, + scope: ConfigScope, + cwd: string, +): Promise<{ path: string }> { + // Validate first. Bad JSON via /mcp add-json shouldn't get written and + // then explode at next launch — fail at the entry point with a clear + // schema error. + const validated = parseServerConfig(name, config) + + const obj = await readConfigObject(scope, cwd) + const existing = obj.mcpServers + const servers = + existing && typeof existing === 'object' && !Array.isArray(existing) + ? { ...(existing as Record) } + : {} + servers[name] = validated + obj.mcpServers = servers + await writeConfigObject(scope, cwd, obj) + return { path: getConfigPath(scope, cwd) } +} + +/** Remove a server from the given scope's config.json. Idempotent: returns + * `removed: false` when the name wasn't present (or the file didn't exist). + * Leaves the file with an empty `mcpServers: {}` rather than deleting the + * field — preserves the spot for future adds and avoids churn that would + * surprise users diffing the file in git. */ +export async function removeServerFromConfig( + name: string, + scope: ConfigScope, + cwd: string, +): Promise<{ path: string; removed: boolean }> { + const file = getConfigPath(scope, cwd) + const obj = await readConfigObject(scope, cwd) + const existing = obj.mcpServers + if (!existing || typeof existing !== 'object' || Array.isArray(existing)) { + return { path: file, removed: false } + } + const servers = existing as Record + if (!Object.prototype.hasOwnProperty.call(servers, name)) { + return { path: file, removed: false } + } + const next: Record = {} + for (const [k, v] of Object.entries(servers)) { + if (k !== name) next[k] = v + } + obj.mcpServers = next + await writeConfigObject(scope, cwd, obj) + return { path: file, removed: true } +} + +/** Read the current config for `name` from the given scope, for the + * "already exists, here's what's there" path of /mcp add. Returns null + * if not present. Best-effort: a malformed entry returns null rather + * than throwing — the duplicate-check use case shouldn't crash. */ +export async function readServerConfig(name: string, scope: ConfigScope, cwd: string): Promise { + try { + const obj = await readConfigObject(scope, cwd) + const servers = obj.mcpServers + if (!servers || typeof servers !== 'object' || Array.isArray(servers)) return null + const value = (servers as Record)[name] + return value ?? null + } catch { + return null + } +} diff --git a/packages/core/tests/mcp-arg-parser.test.ts b/packages/core/tests/mcp-arg-parser.test.ts new file mode 100644 index 0000000..f17dcde --- /dev/null +++ b/packages/core/tests/mcp-arg-parser.test.ts @@ -0,0 +1,274 @@ +import { describe, expect, it } from 'vitest' + +import { parseAdd, parseAddJson, parseRemove, tokenize } from '../src/mcp/arg-parser.js' + +describe('tokenize', () => { + it('splits on whitespace', () => { + expect(tokenize('a b c')).toEqual({ ok: true, tokens: ['a', 'b', 'c'] }) + }) + + it('preserves double-quoted spans', () => { + expect(tokenize('a "b c" d')).toEqual({ ok: true, tokens: ['a', 'b c', 'd'] }) + }) + + it('preserves single-quoted spans', () => { + expect(tokenize("a 'b c' d")).toEqual({ ok: true, tokens: ['a', 'b c', 'd'] }) + }) + + it('escapes whitespace with backslash outside quotes', () => { + expect(tokenize('a\\ b c')).toEqual({ ok: true, tokens: ['a b', 'c'] }) + }) + + it('escapes quote/backslash outside quotes', () => { + expect(tokenize('a\\"b \\\\c')).toEqual({ ok: true, tokens: ['a"b', '\\c'] }) + }) + + it('preserves backslash-non-special as a literal pair (Windows paths)', () => { + // Regression: a previous POSIX-style "backslash escapes any char" rule + // ate path separators on Windows. `D:\res\x-code-cli\tmp` MUST survive. + expect(tokenize('D:\\res\\x-code-cli\\tmp')).toEqual({ + ok: true, + tokens: ['D:\\res\\x-code-cli\\tmp'], + }) + }) + + it('handles backslash-escape inside double quotes', () => { + expect(tokenize('"a\\"b"')).toEqual({ ok: true, tokens: ['a"b'] }) + }) + + it('rejects unclosed quote', () => { + expect(tokenize('a "b')).toEqual({ ok: false, error: 'Unclosed " quote' }) + }) + + it('returns empty for empty/whitespace-only input', () => { + expect(tokenize('')).toEqual({ ok: true, tokens: [] }) + expect(tokenize(' ')).toEqual({ ok: true, tokens: [] }) + }) +}) + +describe('parseAdd — stdio', () => { + it('parses bare stdio: name + command + args', () => { + const r = parseAdd('fs npx -y @modelcontextprotocol/server-filesystem /tmp') + expect(r).toEqual({ + ok: true, + command: { + kind: 'add', + name: 'fs', + scope: 'user', + config: { + command: 'npx', + args: ['-y', '@modelcontextprotocol/server-filesystem', '/tmp'], + }, + }, + }) + }) + + it('accepts -- separator before the command', () => { + const r = parseAdd('fs -- npx -y pkg') + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.command.name).toBe('fs') + expect((r.command.config as { command: string; args?: string[] }).command).toBe('npx') + expect((r.command.config as { command: string; args?: string[] }).args).toEqual(['-y', 'pkg']) + } + }) + + it('collects multiple --env flags into env object', () => { + const r = parseAdd('--env A=1 --env B=hello srv node ./s.js') + expect(r.ok).toBe(true) + if (r.ok) { + const cfg = r.command.config as { env?: Record } + expect(cfg.env).toEqual({ A: '1', B: 'hello' }) + } + }) + + it('allows --env values containing =', () => { + const r = parseAdd('--env URL=https://x.com?a=1 srv node s.js') + expect(r.ok).toBe(true) + if (r.ok) { + const cfg = r.command.config as { env?: Record } + expect(cfg.env).toEqual({ URL: 'https://x.com?a=1' }) + } + }) + + it('accepts --timeout', () => { + const r = parseAdd('--timeout 60000 srv node s.js') + expect(r.ok).toBe(true) + if (r.ok) { + const cfg = r.command.config as { timeout?: number } + expect(cfg.timeout).toBe(60000) + } + }) + + it('accepts --scope project', () => { + const r = parseAdd('--scope project srv node s.js') + expect(r.ok).toBe(true) + if (r.ok) expect(r.command.scope).toBe('project') + }) + + it('rejects --header with stdio', () => { + const r = parseAdd('--header "X: Y" srv node s.js') + expect(r).toEqual({ ok: false, error: expect.stringContaining('--header is only valid for HTTP') }) + }) + + it('rejects invalid name (single bad token)', () => { + // `server!` — single token containing punctuation that fails NAME_RE. + // Multi-word "my server" would tokenise into separate args and "my" + // alone is a valid name, so we use a single-token failure case here. + const r = parseAdd('server! npx pkg') + expect(r.ok).toBe(false) + }) + + it('rejects bad --env shape', () => { + expect(parseAdd('--env NOVAL srv cmd').ok).toBe(false) + expect(parseAdd('--env =val srv cmd').ok).toBe(false) + }) + + it('preserves Windows-style backslash paths in args', () => { + // Regression for the bug where `D:\res\x-code-cli\tmp` got mangled + // into `D:resx-code-clitmp` because the tokenizer ate the backslashes. + const r = parseAdd('fs npx -y @modelcontextprotocol/server-filesystem D:\\res\\x-code-cli\\tmp') + expect(r.ok).toBe(true) + if (r.ok) { + const cfg = r.command.config as { args: string[] } + expect(cfg.args).toEqual(['-y', '@modelcontextprotocol/server-filesystem', 'D:\\res\\x-code-cli\\tmp']) + } + }) + + it('omits empty args/env from the config', () => { + const r = parseAdd('srv cmd-only') + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.command.config).toEqual({ command: 'cmd-only' }) + } + }) + + it('requires at least name + command', () => { + expect(parseAdd('').ok).toBe(false) + expect(parseAdd('srv').ok).toBe(false) + }) +}) + +describe('parseAdd — http', () => { + it('parses --http with url', () => { + const r = parseAdd('--http sentry https://mcp.sentry.dev/mcp') + expect(r).toEqual({ + ok: true, + command: { + kind: 'add', + name: 'sentry', + scope: 'user', + config: { url: 'https://mcp.sentry.dev/mcp' }, + }, + }) + }) + + it('accepts --transport http as alias', () => { + const r = parseAdd('--transport http sentry https://mcp.sentry.dev/mcp') + expect(r.ok).toBe(true) + if (r.ok) { + expect((r.command.config as { url: string }).url).toBe('https://mcp.sentry.dev/mcp') + } + }) + + it('rejects --transport sse explicitly', () => { + const r = parseAdd('--transport sse sentry https://mcp.sentry.dev/mcp') + expect(r.ok).toBe(false) + if (!r.ok) expect(r.error).toMatch(/only supports "http"/) + }) + + it('collects multiple --header flags', () => { + const r = parseAdd('--http --header "X-A: 1" --header "X-B: 2" srv https://x.com') + expect(r.ok).toBe(true) + if (r.ok) { + const cfg = r.command.config as { headers?: Record } + expect(cfg.headers).toEqual({ 'X-A': '1', 'X-B': '2' }) + } + }) + + it('rejects --env with --http', () => { + const r = parseAdd('--http --env A=B srv https://x.com') + expect(r).toEqual({ ok: false, error: expect.stringContaining('--env is only valid for stdio') }) + }) + + it('rejects invalid url', () => { + const r = parseAdd('--http srv ftp://x.com') + expect(r.ok).toBe(false) + }) + + it('rejects extra positional args for http', () => { + const r = parseAdd('--http srv https://x.com extra-token') + expect(r.ok).toBe(false) + }) +}) + +describe('parseAddJson', () => { + it('parses a JSON blob into a config', () => { + const r = parseAddJson('myserver \'{"command":"node","args":["s.js"]}\'') + expect(r.ok).toBe(true) + if (r.ok) { + expect(r.command.name).toBe('myserver') + expect(r.command.config).toEqual({ command: 'node', args: ['s.js'] }) + } + }) + + it('accepts --scope project', () => { + const r = parseAddJson('--scope project srv \'{"command":"x"}\'') + expect(r.ok).toBe(true) + if (r.ok) expect(r.command.scope).toBe('project') + }) + + it('rejects invalid JSON', () => { + const r = parseAddJson("srv 'not-json'") + expect(r.ok).toBe(false) + }) + + it('rejects non-object JSON', () => { + const r = parseAddJson('srv \'["a","b"]\'') + expect(r.ok).toBe(false) + }) + + it('rejects missing JSON', () => { + expect(parseAddJson('srv').ok).toBe(false) + expect(parseAddJson('').ok).toBe(false) + }) + + it('rejects invalid name', () => { + const r = parseAddJson('bad name! \'{"command":"x"}\'') + expect(r.ok).toBe(false) + }) +}) + +describe('parseRemove', () => { + it('parses bare name', () => { + expect(parseRemove('sentry')).toEqual({ + ok: true, + command: { kind: 'remove', name: 'sentry', scope: undefined }, + }) + }) + + it('parses --scope user', () => { + const r = parseRemove('--scope user sentry') + expect(r.ok).toBe(true) + if (r.ok) expect(r.command.scope).toBe('user') + }) + + it('parses --scope project', () => { + const r = parseRemove('--scope project sentry') + expect(r.ok).toBe(true) + if (r.ok) expect(r.command.scope).toBe('project') + }) + + it('rejects extra args', () => { + expect(parseRemove('a b').ok).toBe(false) + }) + + it('rejects missing name', () => { + expect(parseRemove('').ok).toBe(false) + expect(parseRemove('--scope user').ok).toBe(false) + }) + + it('rejects unknown flag', () => { + expect(parseRemove('--force sentry').ok).toBe(false) + }) +}) diff --git a/packages/core/tests/mcp-config-writer.test.ts b/packages/core/tests/mcp-config-writer.test.ts new file mode 100644 index 0000000..98b6752 --- /dev/null +++ b/packages/core/tests/mcp-config-writer.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import fs from 'node:fs/promises' +import os from 'node:os' +import path from 'node:path' + +import { + detectScope, + getConfigPath, + readServerConfig, + removeServerFromConfig, + serverExists, + writeServerToConfig, +} from '../src/mcp/config-writer.js' + +/** Each test gets its own scratch ~/.x-code under tmpdir, plus a scratch + * project dir. We never touch the developer's real config.json. */ +function isolate(): { home: string; project: string } { + const home = path.join(os.tmpdir(), 'mcp-writer-home-' + Math.random().toString(36).slice(2)) + const project = path.join(os.tmpdir(), 'mcp-writer-proj-' + Math.random().toString(36).slice(2)) + process.env.X_CODE_HOME = home + return { home, project } +} + +async function readJson(file: string): Promise { + const raw = await fs.readFile(file, 'utf-8') + return JSON.parse(raw) +} + +describe('config-writer: user scope', () => { + let ctx: { home: string; project: string } + beforeEach(() => { + ctx = isolate() + }) + afterEach(() => { + delete process.env.X_CODE_HOME + }) + + it('writes a new server when config.json does not exist', async () => { + const res = await writeServerToConfig('sentry', { url: 'https://mcp.sentry.dev/mcp' }, 'user', ctx.project) + expect(res.path).toBe(getConfigPath('user', ctx.project)) + const written = (await readJson(res.path)) as { mcpServers: Record } + expect(written.mcpServers).toEqual({ sentry: { url: 'https://mcp.sentry.dev/mcp' } }) + }) + + it('preserves unrelated top-level fields', async () => { + const p = getConfigPath('user', ctx.project) + await fs.mkdir(path.dirname(p), { recursive: true }) + await fs.writeFile(p, JSON.stringify({ theme: 'dark', model: 'anthropic:foo' }), 'utf-8') + await writeServerToConfig('fs', { command: 'node', args: ['s.js'] }, 'user', ctx.project) + const data = (await readJson(p)) as Record + expect(data.theme).toBe('dark') + expect(data.model).toBe('anthropic:foo') + expect(data.mcpServers).toEqual({ fs: { command: 'node', args: ['s.js'] } }) + }) + + it('preserves sibling mcpServers entries when adding', async () => { + await writeServerToConfig('a', { command: 'node', args: ['a.js'] }, 'user', ctx.project) + await writeServerToConfig('b', { url: 'https://b.com/mcp' }, 'user', ctx.project) + const data = (await readJson(getConfigPath('user', ctx.project))) as { mcpServers: Record } + expect(Object.keys(data.mcpServers).sort()).toEqual(['a', 'b']) + }) + + it('overwrites the named server in-place (caller checks duplicates)', async () => { + await writeServerToConfig('s', { command: 'one' }, 'user', ctx.project) + await writeServerToConfig('s', { command: 'two' }, 'user', ctx.project) + const data = (await readJson(getConfigPath('user', ctx.project))) as { + mcpServers: Record + } + expect(data.mcpServers.s.command).toBe('two') + }) + + it('rejects an invalid config (schema validation runs before write)', async () => { + await expect( + writeServerToConfig('s', { command: 'node', url: 'https://x.com' } as never, 'user', ctx.project), + ).rejects.toThrow(/both.*command.*url/) + // And no file should have been created. + await expect(fs.stat(getConfigPath('user', ctx.project))).rejects.toBeTruthy() + }) + + it('throws on a corrupt config.json instead of overwriting', async () => { + const p = getConfigPath('user', ctx.project) + await fs.mkdir(path.dirname(p), { recursive: true }) + await fs.writeFile(p, '{this is not json', 'utf-8') + await expect(writeServerToConfig('s', { command: 'node' }, 'user', ctx.project)).rejects.toThrow(/not valid JSON/) + }) +}) + +describe('config-writer: project scope', () => { + let ctx: { home: string; project: string } + beforeEach(() => { + ctx = isolate() + }) + afterEach(() => { + delete process.env.X_CODE_HOME + }) + + it('writes to /.x-code/config.json', async () => { + const res = await writeServerToConfig('foo', { command: 'node', args: ['f.js'] }, 'project', ctx.project) + expect(res.path).toContain(path.join('.x-code', 'config.json')) + expect(res.path.startsWith(ctx.project)).toBe(true) + }) + + it('does not affect user-scope config', async () => { + await writeServerToConfig('user-srv', { command: 'a' }, 'user', ctx.project) + await writeServerToConfig('proj-srv', { command: 'b' }, 'project', ctx.project) + expect(await serverExists('user-srv', 'user', ctx.project)).toBe(true) + expect(await serverExists('user-srv', 'project', ctx.project)).toBe(false) + expect(await serverExists('proj-srv', 'project', ctx.project)).toBe(true) + expect(await serverExists('proj-srv', 'user', ctx.project)).toBe(false) + }) +}) + +describe('config-writer: remove', () => { + let ctx: { home: string; project: string } + beforeEach(() => { + ctx = isolate() + }) + afterEach(() => { + delete process.env.X_CODE_HOME + }) + + it('removes a present server', async () => { + await writeServerToConfig('s', { command: 'node' }, 'user', ctx.project) + const r = await removeServerFromConfig('s', 'user', ctx.project) + expect(r.removed).toBe(true) + expect(await serverExists('s', 'user', ctx.project)).toBe(false) + }) + + it('is idempotent when the server is missing', async () => { + const r = await removeServerFromConfig('nope', 'user', ctx.project) + expect(r.removed).toBe(false) + }) + + it('is idempotent when the file does not exist', async () => { + const r = await removeServerFromConfig('nope', 'project', ctx.project) + expect(r.removed).toBe(false) + }) + + it('preserves siblings and unrelated fields', async () => { + const p = getConfigPath('user', ctx.project) + await fs.mkdir(path.dirname(p), { recursive: true }) + await fs.writeFile( + p, + JSON.stringify({ + theme: 'dark', + mcpServers: { a: { command: 'a' }, b: { command: 'b' }, c: { command: 'c' } }, + }), + 'utf-8', + ) + const r = await removeServerFromConfig('b', 'user', ctx.project) + expect(r.removed).toBe(true) + const data = (await readJson(p)) as { theme: string; mcpServers: Record } + expect(data.theme).toBe('dark') + expect(Object.keys(data.mcpServers).sort()).toEqual(['a', 'c']) + }) + + it('leaves mcpServers as an empty object when the last entry is removed', async () => { + await writeServerToConfig('only', { command: 'node' }, 'user', ctx.project) + await removeServerFromConfig('only', 'user', ctx.project) + const data = (await readJson(getConfigPath('user', ctx.project))) as { + mcpServers: Record + } + expect(data.mcpServers).toEqual({}) + }) +}) + +describe('config-writer: detectScope', () => { + let ctx: { home: string; project: string } + beforeEach(() => { + ctx = isolate() + }) + afterEach(() => { + delete process.env.X_CODE_HOME + }) + + it('returns not-found when missing everywhere', async () => { + expect((await detectScope('nope', ctx.project)).kind).toBe('not-found') + }) + + it('returns user when only present in user scope', async () => { + await writeServerToConfig('s', { command: 'x' }, 'user', ctx.project) + expect((await detectScope('s', ctx.project)).kind).toBe('user') + }) + + it('returns project when only present in project scope', async () => { + await writeServerToConfig('s', { command: 'x' }, 'project', ctx.project) + expect((await detectScope('s', ctx.project)).kind).toBe('project') + }) + + it('returns both when present in both scopes', async () => { + await writeServerToConfig('s', { command: 'x' }, 'user', ctx.project) + await writeServerToConfig('s', { command: 'y' }, 'project', ctx.project) + expect((await detectScope('s', ctx.project)).kind).toBe('both') + }) +}) + +describe('config-writer: readServerConfig', () => { + let ctx: { home: string; project: string } + beforeEach(() => { + ctx = isolate() + }) + afterEach(() => { + delete process.env.X_CODE_HOME + }) + + it('returns the stored config object', async () => { + await writeServerToConfig('s', { command: 'node', args: ['a'] }, 'user', ctx.project) + expect(await readServerConfig('s', 'user', ctx.project)).toEqual({ command: 'node', args: ['a'] }) + }) + + it('returns null for missing servers', async () => { + expect(await readServerConfig('nope', 'user', ctx.project)).toBeNull() + }) +}) From 0d55c4717b5339e05c870d943a3d8e8b69fcead3 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Tue, 19 May 2026 16:33:54 +0800 Subject: [PATCH 6/9] fix(core/mcp): don't throw from redirectUrl getter on first boot MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The MCP SDK reads `redirectUrl` while constructing the authorize URL during the very first connect attempt with no stored token — which is BEFORE `redirectToAuthorization` runs. The previous version threw "Callback server not started", and the registry classifier didn't match that string against `/unauth|401|UnauthorizedError/i`, so HTTP servers showed up as `failed` on first launch after `/mcp add` instead of the intended `needs_auth`. Return the same loopback placeholder `clientMetadata.redirect_uris` already uses. RFC 8252 §7.3 requires auth servers to accept any port on a registered loopback redirect, and `redirectToAuthorization` rewrites the `redirect_uri` query param with the real port right before launching the browser, so the placeholder never reaches the auth server. --- packages/core/src/mcp/oauth/provider.ts | 21 +++++--- .../core/tests/mcp-oauth-provider.test.ts | 53 +++++++++++++++++++ 2 files changed, 66 insertions(+), 8 deletions(-) create mode 100644 packages/core/tests/mcp-oauth-provider.test.ts diff --git a/packages/core/src/mcp/oauth/provider.ts b/packages/core/src/mcp/oauth/provider.ts index ff1fadb..e0efd78 100644 --- a/packages/core/src/mcp/oauth/provider.ts +++ b/packages/core/src/mcp/oauth/provider.ts @@ -68,14 +68,19 @@ export class McpOAuthProvider implements OAuthClientProvider { // ── OAuthClientProvider ──────────────────────────────────────────────── get redirectUrl(): string { - // Caller MUST call ensureCallbackServer() before this getter is - // first used by the SDK. The SDK calls `redirectUrl` after - // `redirectToAuthorization` has been invoked (per its own flow), - // so the practical ordering holds. - if (!this.callbackServer) { - throw new Error('Callback server not started — redirectToAuthorization must be called first') - } - return this.callbackServer.url + // The SDK actually reads `redirectUrl` BEFORE `redirectToAuthorization` + // fires (e.g. while constructing the authorize URL during the very + // first connect attempt with no stored token). An earlier version + // threw here, which surfaced HTTP servers as `failed` instead of the + // intended `needs_auth` on the first launch after `/mcp add`. + // + // We return the same loopback placeholder `clientMetadata.redirect_uris` + // already uses. RFC 8252 §7.3 says authorisation servers MUST accept any + // port on a registered loopback redirect_uri, so the placeholder being + // port-less is fine for the registration roundtrip; `redirectToAuthorization` + // rewrites the actual `redirect_uri` query param with the real port + // right before launching the browser. + return this.callbackServer?.url ?? 'http://127.0.0.1/callback' } get clientMetadata(): OAuthClientMetadata { diff --git a/packages/core/tests/mcp-oauth-provider.test.ts b/packages/core/tests/mcp-oauth-provider.test.ts new file mode 100644 index 0000000..4024bf2 --- /dev/null +++ b/packages/core/tests/mcp-oauth-provider.test.ts @@ -0,0 +1,53 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import os from 'node:os' +import path from 'node:path' + +import { McpOAuthProvider } from '../src/mcp/oauth/provider.js' +import { McpTokenStorage } from '../src/mcp/oauth/token-storage.js' + +/** Isolate the test from the developer's real ~/.x-code/mcp-auth.json. */ +function isolate(): string { + const dir = path.join(os.tmpdir(), 'mcp-oauth-test-' + Math.random().toString(36).slice(2)) + process.env.X_CODE_HOME = dir + return dir +} + +function makeProvider(): McpOAuthProvider { + return new McpOAuthProvider({ + serverName: 'test-server', + serverUrl: 'https://example.com/mcp', + storage: new McpTokenStorage(), + }) +} + +describe('McpOAuthProvider.redirectUrl', () => { + beforeEach(() => { + isolate() + }) + afterEach(() => { + delete process.env.X_CODE_HOME + }) + + it('returns a loopback placeholder when no callback server is running', () => { + // Regression: a previous version threw here, which surfaced HTTP MCP + // servers as `failed` instead of `needs_auth` on first boot (the SDK + // reads redirectUrl while constructing the authorize URL, BEFORE + // redirectToAuthorization fires and starts the callback server). + const provider = makeProvider() + const url = provider.redirectUrl + expect(typeof url).toBe('string') + // Must be a loopback URL — per RFC 8252 the auth server must accept + // any port on this host, so the lack of a concrete port is fine. + expect(url).toMatch(/^http:\/\/127\.0\.0\.1/) + }) + + it('keeps clientMetadata.redirect_uris consistent with redirectUrl', () => { + // The placeholder used by both getters must agree, otherwise the + // dynamic-registration request includes one URL and the SDK builds + // the authorize URL with a different one — auth server returns + // redirect_uri_mismatch. + const provider = makeProvider() + expect(provider.clientMetadata.redirect_uris).toContain(provider.redirectUrl) + }) +}) From 6ded6b6c9da5b91941cdb682b3d8cd0b9c928f10 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Tue, 19 May 2026 17:01:50 +0800 Subject: [PATCH 7/9] refactor(core/mcp): drop redundant mcp__ prefix from callable names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MCP tool names now use `__` instead of `mcp____`. Codex and Gemini CLI both omit the prefix; only Claude Code keeps it, and that's purely a historical artefact — the prefix carries no information the description doesn't already convey, and it costs a few tokens per tool on every API request. - Routing is no longer name-pattern-based. `tool-execution.ts` decides MCP-vs-built-in by registry lookup (`mcpRegistry?.get(name)`), not `startsWith('mcp__')`. Built-in tools are camelCase, so name shape alone still disambiguates in practice — the registry is just the authoritative answer. - `mcp-permissions.json` auto-migrates: keys are stripped of any legacy `mcp__` prefix on load, so existing always-allow grants survive the rename. The on-disk file rewrites itself on the next approval (writePersisted runs from the in-memory set, which already holds migrated keys). - Drops the `MCP_PREFIX` and `isMcpCallableName` exports — they only made sense under the prefix scheme. --- .../cli/tests/e2e/scenarios/24-mcp-stdio.ts | 8 ++-- packages/core/src/agent/tool-execution.ts | 7 ++- packages/core/src/index.ts | 2 +- packages/core/src/mcp/arg-parser.ts | 6 +-- packages/core/src/mcp/name-mangling.ts | 31 +++++++------ packages/core/src/mcp/permissions.ts | 12 +++++- packages/core/src/mcp/registry.ts | 2 +- packages/core/src/mcp/types.ts | 2 +- packages/core/tests/mcp-integration.test.ts | 12 +++--- packages/core/tests/mcp-name-mangling.test.ts | 26 +++++------ packages/core/tests/mcp-permissions.test.ts | 43 +++++++++++++------ 11 files changed, 90 insertions(+), 61 deletions(-) diff --git a/packages/cli/tests/e2e/scenarios/24-mcp-stdio.ts b/packages/cli/tests/e2e/scenarios/24-mcp-stdio.ts index 83156a1..82ef98a 100644 --- a/packages/cli/tests/e2e/scenarios/24-mcp-stdio.ts +++ b/packages/cli/tests/e2e/scenarios/24-mcp-stdio.ts @@ -49,7 +49,7 @@ function handle(msg) { const scenario: Scenario = { id: '24-mcp-stdio', - name: 'MCP stdio: model calls mcp__mock__greet and quotes the server-stamped marker', + name: 'MCP stdio: model calls mock__greet and quotes the server-stamped marker', async run(ctx) { // 1. Write the inline mock MCP server into the tmpDir and the user // config that points to it. The harness already isolates @@ -77,12 +77,12 @@ const scenario: Scenario = { ) // 2. Run the CLI. --trust short-circuits the per-tool ask prompt so - // the model can call mcp__mock__greet without onAskPermission + // the model can call mock__greet without onAskPermission // blocking print-mode (no UI to answer the dialog). const r = await ctx.runCli( [ 'There is an MCP server named "mock" connected. It exposes a tool', - 'mcp__mock__greet that takes { name: string } and returns a greeting', + 'mock__greet that takes { name: string } and returns a greeting', 'string. Call it with name="World" and then quote the EXACT text the', 'tool returned in your reply.', ].join(' '), @@ -90,7 +90,7 @@ const scenario: Scenario = { ) ctx.expect.exitCode(r, 0) - ctx.expect.toolCalled(r, 'mcp__mock__greet', { name: 'World' }) + ctx.expect.toolCalled(r, 'mock__greet', { name: 'World' }) // The marker is a random-looking token the server stamps in. Models // that didn't actually wait for the tool result can't reproduce it. ctx.expect.assistantMentions(r, /MCP_MARKER_AB12CD34/) diff --git a/packages/core/src/agent/tool-execution.ts b/packages/core/src/agent/tool-execution.ts index 0446334..25bf8dc 100644 --- a/packages/core/src/agent/tool-execution.ts +++ b/packages/core/src/agent/tool-execution.ts @@ -4,7 +4,6 @@ import path from 'node:path' import type { ModelMessage } from 'ai' -import { isMcpCallableName } from '../mcp/name-mangling.js' import { classifyDecision } from '../mcp/permissions.js' import { checkPermission } from '../permissions/index.js' import { truncateToolResult } from '../tools/index.js' @@ -498,7 +497,11 @@ async function handleToolCall( // always-allow file) rather than the writeFile/edit/shell rules. They // still go through the loop-guard so the model can't spin on a // failing MCP call indefinitely. - if (isMcpCallableName(ctx.toolName)) { + // + // Routing is by registry lookup, not name pattern: MCP tool names are + // `__` (no special prefix), so the only authoritative + // "is this MCP?" answer is "is it registered with the MCP registry?". + if (ctx.options.mcpRegistry?.get(ctx.toolName)) { await handleMcpToolCall(ctx, deferred) return } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 622bb93..d99b7a3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -112,7 +112,7 @@ export { McpOAuthProvider, createOAuthProviderFactory } from './mcp/oauth/provid export { startCallbackServer } from './mcp/oauth/callback-server.js' export type { McpServerConfig, McpServerStatus, McpToolEntry, McpResourceEntry, McpCallResult } from './mcp/types.js' export { isStdioConfig, isHttpConfig } from './mcp/types.js' -export { buildCallableName, isMcpCallableName, MCP_PREFIX } from './mcp/name-mangling.js' +export { buildCallableName, MCP_MAX_NAME_LEN } from './mcp/name-mangling.js' export { expandEnvDeep, expandEnvString, EnvExpansionError } from './mcp/expand-env.js' export { parseServersBlock, parseServerConfig, mcpServersSchema } from './mcp/config-schema.js' export { parseAdd, parseAddJson, parseRemove, tokenize } from './mcp/arg-parser.js' diff --git a/packages/core/src/mcp/arg-parser.ts b/packages/core/src/mcp/arg-parser.ts index b40bb2e..00343e8 100644 --- a/packages/core/src/mcp/arg-parser.ts +++ b/packages/core/src/mcp/arg-parser.ts @@ -54,9 +54,9 @@ export type ParseResult = /** Names allowed in `mcpServers.`. Tightened relative to the runtime * name-mangling sanitizer because *config entry point* is a better place * to refuse weird names — surprising sanitisation post-add ("I typed - * `my server!` and got `mcp__my_server___xxx`") is worse than a clear - * rejection. Length 32 leaves headroom for the `mcp__{server}__{tool}` - * prefix to stay well under the model-side 64-char tool name limit. */ + * `my server!` and got `my_server___xxx`") is worse than a clear + * rejection. Length 32 leaves headroom for the `{server}__{tool}` + * format to stay well under the model-side 64-char tool name limit. */ const NAME_RE = /^[a-zA-Z0-9_-]{1,32}$/ // ── Top-level entry points ───────────────────────────────────────────────── diff --git a/packages/core/src/mcp/name-mangling.ts b/packages/core/src/mcp/name-mangling.ts index 080c2ec..8eb78fa 100644 --- a/packages/core/src/mcp/name-mangling.ts +++ b/packages/core/src/mcp/name-mangling.ts @@ -1,16 +1,26 @@ // @x-code-cli/core — MCP tool name mangling // -// We expose MCP tools to the model under prefixed names so they can't +// We expose MCP tools to the model under namespaced names so they can't // collide with built-in tools (readFile, shell, ...) and so the model // can tell at a glance "this came from server X": // -// mcp____ +// __ // // Both server and tool names are sanitised: any char outside // [A-Za-z0-9_] becomes `_`. We pick `__` (double underscore) as the // separator so a tool whose raw name contains a single underscore // (very common — `read_file`, `list_issues`) is unambiguous. // +// History: an earlier version added an extra `mcp__` prefix on the front +// (`mcp____`). That matched Claude Code's convention but +// burned tokens on a per-tool basis without telling the model anything +// the description doesn't already carry. Codex and Gemini CLI both omit +// the prefix; we follow them. Routing — "is this tool MCP or built-in?" +// — moved from a name-prefix check to a registry lookup in +// tool-execution.ts. The mcp-permissions.json loader strips legacy +// `mcp__` prefixes on read so users carry their old always-allow grants +// forward. +// // The model-facing tool name has a hard cap at 64 chars (OpenAI's // historical limit; Anthropic/Google are higher but 64 keeps us // portable). Over-length names are truncated and tagged with a 6-char @@ -22,12 +32,11 @@ // second. import { createHash } from 'node:crypto' -export const MCP_PREFIX = 'mcp__' export const MCP_MAX_NAME_LEN = 64 function sanitize(part: string): string { // Replace any run of disallowed chars with a single `_`. Trim leading - // / trailing underscores so we don't end up with `mcp___server__tool_`. + // / trailing underscores so we don't end up with `_server__tool_`. const cleaned = part.replace(/[^A-Za-z0-9_]+/g, '_').replace(/^_+|_+$/g, '') // Empty after sanitisation (e.g. all-CJK server name) → fall back to a // hash so we still produce a stable, valid identifier. @@ -53,14 +62,14 @@ export function buildCallableName(serverName: string, rawToolName: string, exist const s = sanitize(serverName) const t = sanitize(rawToolName) - let name = `${MCP_PREFIX}${s}__${t}` + let name = `${s}__${t}` - // Over-length: truncate while preserving the prefix + a content hash so + // Over-length: truncate while preserving a content hash so // truncated-different names don't collapse to the same string. if (name.length > MCP_MAX_NAME_LEN) { const hash = shortHash(`${serverName}::${rawToolName}`, 6) - const room = MCP_MAX_NAME_LEN - MCP_PREFIX.length - 1 /* underscore */ - hash.length - name = `${MCP_PREFIX}${(s + '__' + t).slice(0, room)}_${hash}` + const room = MCP_MAX_NAME_LEN - 1 /* underscore */ - hash.length + name = `${(s + '__' + t).slice(0, room)}_${hash}` } // Collision: append a 4-char server-name hash. If THAT still collides @@ -83,9 +92,3 @@ export function buildCallableName(serverName: string, rawToolName: string, exist return name } - -/** True iff a name looks like one of ours. - * Used by tool-execution to route `mcp__*` calls down the MCP path. */ -export function isMcpCallableName(name: string): boolean { - return name.startsWith(MCP_PREFIX) -} diff --git a/packages/core/src/mcp/permissions.ts b/packages/core/src/mcp/permissions.ts index 288aa81..11766b7 100644 --- a/packages/core/src/mcp/permissions.ts +++ b/packages/core/src/mcp/permissions.ts @@ -100,7 +100,17 @@ async function readPersisted(): Promise> { const raw = await fs.readFile(permissionsFile(), 'utf-8') const parsed = JSON.parse(raw) as StoreShape if (parsed && Array.isArray(parsed.alwaysAllow)) { - return new Set(parsed.alwaysAllow.filter((s): s is string => typeof s === 'string')) + // Migration: an earlier version prefixed callable names with `mcp__` + // (e.g. `mcp__fs__read_file`). Strip the prefix on load so users + // keep their always-allow grants after the rename to plain + // `__`. The strip is idempotent and runs every load + // — once the user next approves anything, writePersisted re-sorts + // and dedupes, which physically migrates the file on disk. + return new Set( + parsed.alwaysAllow + .filter((s): s is string => typeof s === 'string') + .map((s) => (s.startsWith('mcp__') ? s.slice('mcp__'.length) : s)), + ) } } catch { // missing / malformed — start with empty allow list, degrade to all-ask diff --git a/packages/core/src/mcp/registry.ts b/packages/core/src/mcp/registry.ts index 4d36103..6922734 100644 --- a/packages/core/src/mcp/registry.ts +++ b/packages/core/src/mcp/registry.ts @@ -70,7 +70,7 @@ export interface RestartSummary { export class McpRegistry { /** callableName → entry. callableName is the model-facing - * `mcp____` form; collisions resolved at insert time. */ + * `__` form; collisions resolved at insert time. */ private readonly entries = new Map() /** uri → entry. URIs are unique per spec; if two servers genuinely * expose the same URI we keep the first and warn (handled by loader). */ diff --git a/packages/core/src/mcp/types.ts b/packages/core/src/mcp/types.ts index 348f444..f04aaac 100644 --- a/packages/core/src/mcp/types.ts +++ b/packages/core/src/mcp/types.ts @@ -47,7 +47,7 @@ export type McpServerStatus = /** One MCP tool, after name-mangling. * - * callableName is the model-facing name (mcp____); + * callableName is the model-facing name (__); * rawName is what we pass back to client.callTool — MCP servers don't * know about our prefix scheme. */ export interface McpToolEntry { diff --git a/packages/core/tests/mcp-integration.test.ts b/packages/core/tests/mcp-integration.test.ts index 02fc958..a1869f6 100644 --- a/packages/core/tests/mcp-integration.test.ts +++ b/packages/core/tests/mcp-integration.test.ts @@ -89,7 +89,7 @@ describe('MCP integration (stdio)', () => { .list() .map((t) => t.callableName) .sort() - expect(before).toContain('mcp__mock__echo') + expect(before).toContain('mock__echo') const restarted = await registry.restartServer('mock') expect(restarted.status.kind).toBe('connected') @@ -104,7 +104,7 @@ describe('MCP integration (stdio)', () => { expect(after).toEqual(before) // Verify the new client (not the old, now-closed one) handles calls. - const r = await registry.callTool('mcp__mock__echo', { text: 'after-restart' }) + const r = await registry.callTool('mock__echo', { text: 'after-restart' }) expect(r.text).toBe('after-restart') } finally { await registry.shutdown() @@ -141,8 +141,8 @@ describe('MCP integration (stdio)', () => { .list() .map((t) => t.callableName) .sort() - expect(names).toContain('mcp__mock__echo') - expect(names).toContain('mcp__mock_b__echo') + expect(names).toContain('mock__echo') + expect(names).toContain('mock_b__echo') // Second restartAll: remove mock-b, change mock's args slightly. const configs2 = new Map([ @@ -158,8 +158,8 @@ describe('MCP integration (stdio)', () => { .list() .map((t) => t.callableName) .sort() - expect(afterRemoval).not.toContain('mcp__mock_b__echo') - expect(afterRemoval).toContain('mcp__mock__echo') + expect(afterRemoval).not.toContain('mock_b__echo') + expect(afterRemoval).toContain('mock__echo') } finally { await registry.shutdown() } diff --git a/packages/core/tests/mcp-name-mangling.test.ts b/packages/core/tests/mcp-name-mangling.test.ts index 354e57a..06a59ac 100644 --- a/packages/core/tests/mcp-name-mangling.test.ts +++ b/packages/core/tests/mcp-name-mangling.test.ts @@ -1,32 +1,34 @@ import { describe, expect, it } from 'vitest' -import { MCP_MAX_NAME_LEN, MCP_PREFIX, buildCallableName, isMcpCallableName } from '../src/mcp/name-mangling.js' +import { MCP_MAX_NAME_LEN, buildCallableName } from '../src/mcp/name-mangling.js' describe('buildCallableName', () => { - it('produces mcp____ for clean inputs', () => { + it('produces __ for clean inputs', () => { const name = buildCallableName('filesystem', 'read_file', new Set()) - expect(name).toBe('mcp__filesystem__read_file') + expect(name).toBe('filesystem__read_file') }) it('sanitises disallowed chars to underscore', () => { const name = buildCallableName('my-server.v2', 'foo:bar', new Set()) // Hyphens, dots, colons → "_"; runs collapse to a single underscore - expect(name).toBe('mcp__my_server_v2__foo_bar') + expect(name).toBe('my_server_v2__foo_bar') }) it('falls back to hash when sanitisation empties a part', () => { // All-CJK server name has no [A-Za-z0-9_] chars — must still produce - // a valid, unique identifier rather than `mcp____tool`. + // a valid, unique identifier rather than `__tool`. const name = buildCallableName('文件系统', 'read', new Set()) - expect(name).toMatch(/^mcp__[a-f0-9]{6}__read$/) + expect(name).toMatch(/^[a-f0-9]{6}__read$/) }) - it('keeps the prefix and stays under the 64-char cap', () => { + it('stays under the 64-char cap with truncation hash', () => { const longServer = 'x'.repeat(40) const longTool = 'y'.repeat(40) const name = buildCallableName(longServer, longTool, new Set()) expect(name.length).toBeLessThanOrEqual(MCP_MAX_NAME_LEN) - expect(name.startsWith(MCP_PREFIX)).toBe(true) + // Truncated form ends with `_<6-char hash>` so two long, similar + // names don't collapse to the same string. + expect(name).toMatch(/_[a-f0-9]{6}$/) }) it('disambiguates collisions across servers', () => { @@ -49,11 +51,3 @@ describe('buildCallableName', () => { expect(taken.size).toBe(5) }) }) - -describe('isMcpCallableName', () => { - it('detects the prefix', () => { - expect(isMcpCallableName('mcp__a__b')).toBe(true) - expect(isMcpCallableName('readFile')).toBe(false) - expect(isMcpCallableName('mcpFoo')).toBe(false) // single underscore — not ours - }) -}) diff --git a/packages/core/tests/mcp-permissions.test.ts b/packages/core/tests/mcp-permissions.test.ts index 1433e2e..e8ab73d 100644 --- a/packages/core/tests/mcp-permissions.test.ts +++ b/packages/core/tests/mcp-permissions.test.ts @@ -23,43 +23,62 @@ describe('McpPermissionStore', () => { it('starts empty', async () => { const store = new McpPermissionStore() - expect(await store.isApproved('mcp__foo__bar')).toBe(false) + expect(await store.isApproved('foo__bar')).toBe(false) }) it('approves for session only without persisting', async () => { const store = new McpPermissionStore() - store.approveForSession('mcp__foo__bar') - expect(await store.isApproved('mcp__foo__bar')).toBe(true) + store.approveForSession('foo__bar') + expect(await store.isApproved('foo__bar')).toBe(true) // New store instance — should still be unapproved (session-only). const store2 = new McpPermissionStore() - expect(await store2.isApproved('mcp__foo__bar')).toBe(false) + expect(await store2.isApproved('foo__bar')).toBe(false) }) it('approvePermanently persists across instances', async () => { const store = new McpPermissionStore() - await store.approvePermanently('mcp__foo__bar') + await store.approvePermanently('foo__bar') const store2 = new McpPermissionStore() - expect(await store2.isApproved('mcp__foo__bar')).toBe(true) + expect(await store2.isApproved('foo__bar')).toBe(true) }) it('writes a 0600 file with sorted entries', async () => { const store = new McpPermissionStore() - await store.approvePermanently('mcp__zeta__b') - await store.approvePermanently('mcp__alpha__a') + await store.approvePermanently('zeta__b') + await store.approvePermanently('alpha__a') const filePath = path.join(home, 'mcp-permissions.json') const raw = await fs.readFile(filePath, 'utf-8') const parsed = JSON.parse(raw) as { alwaysAllow: string[] } - expect(parsed.alwaysAllow).toEqual(['mcp__alpha__a', 'mcp__zeta__b']) + expect(parsed.alwaysAllow).toEqual(['alpha__a', 'zeta__b']) }) it('ignores re-approving an already-permanent entry', async () => { const store = new McpPermissionStore() - await store.approvePermanently('mcp__foo__bar') - await store.approvePermanently('mcp__foo__bar') - expect(await store.isApproved('mcp__foo__bar')).toBe(true) + await store.approvePermanently('foo__bar') + await store.approvePermanently('foo__bar') + expect(await store.isApproved('foo__bar')).toBe(true) + }) + + it('migrates legacy mcp__-prefixed entries on read', async () => { + // Simulate a permissions.json written by an earlier version, which + // saved names as `mcp____`. The prefix is stripped at + // load time so users keep their always-allow grants after the + // rename to plain `__`. + const filePath = path.join(home, 'mcp-permissions.json') + await fs.mkdir(home, { recursive: true }) + await fs.writeFile( + filePath, + JSON.stringify({ alwaysAllow: ['mcp__fs__read_file', 'mcp__sentry__find_issues'] }), + 'utf-8', + ) + const store = new McpPermissionStore() + expect(await store.isApproved('fs__read_file')).toBe(true) + expect(await store.isApproved('sentry__find_issues')).toBe(true) + // The old prefixed names should NOT match — migration is one-way. + expect(await store.isApproved('mcp__fs__read_file')).toBe(false) }) }) From f773f2dd54f9989e878f6cb1e36bf43d013148ab Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Tue, 19 May 2026 18:23:42 +0800 Subject: [PATCH 8/9] fix(core/mcp): make /mcp auth OAuth flow actually work end-to-end MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Five intertwined bugs that together made HTTP MCP server OAuth unusable. Each one masked the next, so they had to be peeled off in sequence by manual testing against the live Sentry MCP server. - Boot mode no longer auto-opens a browser. McpOAuthProvider has a default-off `interactive` flag; redirectToAuthorization returns silently in passive mode. The SDK still throws UnauthorizedError next, so the registry marks the server `needs_auth` correctly. Only `/mcp auth ` flips interactive=true and actually launches the browser — matching Claude Code / Gemini CLI / OpenCode semantics. - Pre-start the local callback server BEFORE the SDK builds the dynamic-registration request (new `prepareForAuth()` called from connectWithOAuth). Otherwise registration uses the port-less placeholder URI and Sentry (which doesn't honour RFC 8252 §7.3 loopback any-port) rejects the real-port redirect_uri with "Invalid redirect URI". - connect() now leaves the transport alive when the error is UnauthorizedError. The previous unconditional safeClose nuked `this.transport`, so runOAuthDance's `transport.finishAuth(code)` hit "Internal error: OAuth flow expected an HTTP transport". - waitForAuthCode no longer wipes `memoryCodeVerifier` in its finally block. The SDK reads the PKCE verifier during finishAuth(code), which runs AFTER waitForAuthCode resolves — wiping it there caused "No PKCE verifier set — auth flow not in progress". cancel() and the next saveCodeVerifier still clean it up on the abort / re-auth paths. - Windows openInBrowser switched from `cmd /c start "" ` to `rundll32 url.dll,FileProtocolHandler `. cmd.exe treats `&` as a command separator, and Node's argv escaping doesn't quote it (not a Windows-native special char), so the OAuth URL got truncated at the first `&` — the browser landed on `?response_type=code` with no client_id / redirect_uri / PKCE challenge, and Sentry rejected with "Invalid redirect URI". rundll32 bypasses cmd entirely. Adds a passive-mode regression test (no browser open, no callback server bind when interactive=false). The interactive path isn't covered by a test because it would actually spawn a real browser on the developer's machine on every `pnpm test`. --- packages/core/src/mcp/client.ts | 80 +++++++++++-------- packages/core/src/mcp/oauth/provider.ts | 75 +++++++++++++++-- .../core/tests/mcp-oauth-provider.test.ts | 41 ++++++++++ 3 files changed, 154 insertions(+), 42 deletions(-) diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 43c81b5..8d55929 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -85,8 +85,17 @@ export class McpClient { try { await this.client.connect(this.transport, { signal: ctrl.signal }) } catch (err) { - // Tear down the half-open transport so we don't leak a child process. - await this.safeClose() + // UnauthorizedError is the expected throw during an OAuth flow: + // the SDK has called redirectToAuthorization and now wants the + // caller to finishAuth(code) on the SAME transport. If we tear + // down here, runOAuthDance loses its handle and can't complete + // the exchange. Leave transport + client alive; the caller + // (runOAuthDance) or finally-shutdown path will clean up. For + // any other error we still safeClose to avoid leaking a child + // process / dangling HTTP connection. + if (!isUnauthorizedError(err)) { + await this.safeClose() + } throw this.enrichError(err) } finally { clearTimeout(timer) @@ -141,18 +150,18 @@ export class McpClient { * * The MCP SDK's StreamableHTTP transport handles auth lazily: a fresh * connect with no stored token calls `authProvider.redirectToAuthorization` - * (which opens the browser) and then throws `UnauthorizedError` because - * the token-exchange step has to wait for the user. The caller is - * expected to wait for the redirect callback to land, hand the - * authorization code to `transport.finishAuth(code)`, then retry - * connect — at which point tokens are saved and the next attempt - * succeeds. + * and then throws `UnauthorizedError` because the token-exchange step + * has to wait for the user. The caller is expected to wait for the + * redirect callback to land, hand the authorization code to + * `transport.finishAuth(code)`, then retry connect — at which point + * tokens are saved and the next attempt succeeds. * - * We encapsulate that dance here so that loader / registry can opt - * into "drive OAuth to completion" without each caller knowing about - * `finishAuth`. The default `connect()` path (no driveOAuth) keeps - * the lighter "throw UnauthorizedError, let caller mark needs_auth" - * behaviour so CLI boot doesn't accidentally pop a browser window. */ + * We encapsulate that dance here so that the `/mcp auth` handler can + * opt into "drive OAuth to completion" without knowing about + * `finishAuth`. The default `connect()` path keeps the OAuth provider + * PASSIVE — `redirectToAuthorization` is a no-op until we flip + * `setInteractive(true)` here, so CLI boot doesn't accidentally pop a + * browser window for servers in `needs_auth`. */ async connectWithOAuth(hooks: { onBrowserOpen?: (url: string) => void } = {}): Promise { if (!this.authProvider) { throw new Error(`MCP server "${this.serverName}" has no OAuth provider configured`) @@ -164,37 +173,40 @@ export class McpClient { } const provider = this.authProvider - // Forward the browser-open notification through the hook the caller - // wants. The provider was constructed with whatever onOpenBrowser - // was passed at factory time (printed via console.error in normal - // boot); the caller's hook fires alongside, so the /mcp auth - // handler can also print into the CLI scrollback. + + // Eagerly start the callback server so the real loopback port is + // bound to `clientMetadata.redirect_uris` and `redirectUrl` BEFORE + // the SDK builds the dynamic-registration request. Otherwise we + // register with a port-less placeholder and Sentry (and any other + // auth server that doesn't honour RFC 8252 §7.3 loopback any-port) + // rejects the auth URL's real-port redirect_uri as "Invalid". + await provider.prepareForAuth() + + // Tee the browser-open notification through the caller's hook so the + // /mcp auth handler can print into the CLI scrollback alongside the + // provider's own onOpenBrowser callback. We monkey-patch the method + // for the lifetime of THIS call (try/finally restores it). The + // provider doesn't expose an event API, but patching one method on + // one instance for one flow is bounded enough to be safe. + const originalRedirect = provider.redirectToAuthorization.bind(provider) if (hooks.onBrowserOpen) { - // McpOAuthProvider currently routes through its constructor hook; - // the simplest safe wiring is to tee via a one-shot listener on - // the next redirectToAuthorization call. We do that by wrapping - // the provider's redirect method, but only for THIS call — - // restoring on completion. The provider doesn't itself expose - // an event API, so we monkey-patch the method on the instance. - const original = provider.redirectToAuthorization.bind(provider) provider.redirectToAuthorization = async (url: URL) => { try { hooks.onBrowserOpen?.(url.toString()) } catch { // Hook failures must not abort the OAuth flow. } - return original(url) + return originalRedirect(url) } - // Restore once this connectWithOAuth call resolves either way. - // (Stashed via try/finally below.) - try { - return await this.runOAuthDance() - } finally { - provider.redirectToAuthorization = original + } + try { + return await this.runOAuthDance() + } finally { + provider.setInteractive(false) + if (hooks.onBrowserOpen) { + provider.redirectToAuthorization = originalRedirect } } - - return this.runOAuthDance() } /** The actual two-phase connect: attempt-1 fires redirect, then we diff --git a/packages/core/src/mcp/oauth/provider.ts b/packages/core/src/mcp/oauth/provider.ts index e0efd78..4596ef8 100644 --- a/packages/core/src/mcp/oauth/provider.ts +++ b/packages/core/src/mcp/oauth/provider.ts @@ -62,9 +62,37 @@ export class McpOAuthProvider implements OAuthClientProvider { /** Pending callback that the SDK will consume via `finishAuth` on * the transport. Caller of `waitForAuthCode()` retrieves it. */ private pendingCode: Promise<{ code: string; state?: string }> | null = null + /** Whether `redirectToAuthorization` should actually launch a browser. + * Default false — booting the CLI with an HTTP MCP server that has + * no stored token must NOT silently open a browser window. The flag + * is flipped on for the duration of `connectWithOAuth` (driven by + * `/mcp auth `) and back off in `finally`. */ + private interactive = false constructor(private readonly opts: CreateProviderOptions) {} + /** Caller (client.ts:connectWithOAuth) toggles this around an + * authenticated dance. Outside that window we stay passive. */ + setInteractive(value: boolean): void { + this.interactive = value + } + + /** Eagerly start the callback server, so the real loopback port is + * available to `redirectUrl` and `clientMetadata.redirect_uris` + * BEFORE the SDK constructs the dynamic-registration request. + * + * Why this matters: Sentry (and any auth server that doesn't follow + * RFC 8252 §7.3 strictly) validates the auth-URL `redirect_uri` against + * the value the client registered with. If we register with the + * port-less placeholder and then redirect to a concrete port, the + * server replies "Invalid redirect URI" and the whole flow dies. + * Pre-starting the server ensures registration and authorization use + * the SAME concrete `http://127.0.0.1:/callback`. */ + async prepareForAuth(): Promise { + this.interactive = true + await this.ensureCallbackServer() + } + // ── OAuthClientProvider ──────────────────────────────────────────────── get redirectUrl(): string { @@ -124,6 +152,20 @@ export class McpOAuthProvider implements OAuthClientProvider { } async redirectToAuthorization(authorizationUrl: URL): Promise { + // Passive (boot) mode: the SDK is in the middle of a "lazy" first + // connect with no stored token. We must NOT open a browser window + // unprompted — every other MCP-aware CLI (Claude Code, Gemini, + // OpenCode) waits for explicit user action before doing that, and + // a CLI start-up that hijacks the user's browser is a hostile + // surprise. Returning here is enough: the SDK will throw + // UnauthorizedError next, the registry classifies it as + // `needs_auth`, and `/mcp auth ` can drive the real flow + // (after setInteractive(true) flips us into the interactive path + // below). + if (!this.interactive) { + return + } + // Lazy-start the callback server right before we hand the auth URL // to the browser, so the URL we advertise (via `redirectUrl`) // matches what we'll listen on. We rebuild the auth URL with the @@ -144,7 +186,16 @@ export class McpOAuthProvider implements OAuthClientProvider { /** Block until the auth server has redirected back. Resolves with the * captured code; the caller then calls `transport.finishAuth(code)` - * on the SDK's StreamableHTTPClientTransport. */ + * on the SDK's StreamableHTTPClientTransport. + * + * We close the callback server here because we already have the code + * — Sentry won't call us back again on this flow. But we leave + * `memoryCodeVerifier` alive: the SDK reads it during + * `transport.finishAuth(code)`, which the caller runs AFTER this + * promise resolves. Nulling the verifier in this finally block was + * the cause of "No PKCE verifier set — auth flow not in progress". + * Cleanup of the verifier happens either via `cancel()` (abort + * path) or naturally on the next `saveCodeVerifier(...)` call. */ async waitForAuthCode(): Promise<{ code: string; state?: string }> { if (!this.pendingCode) { throw new Error('Auth flow not started — redirectToAuthorization was never invoked') @@ -153,7 +204,6 @@ export class McpOAuthProvider implements OAuthClientProvider { return await this.pendingCode } finally { this.pendingCode = null - this.memoryCodeVerifier = null this.callbackServer?.close() this.callbackServer = null } @@ -184,12 +234,21 @@ function openInBrowser(url: string): void { let cmd: string let args: string[] if (process.platform === 'win32') { - // `start` is a cmd builtin, so we go via cmd /c. - cmd = 'cmd' - // Empty "" arg is the window title — `start "title" "url"` so - // a URL containing spaces (rare but possible in test contexts) - // isn't interpreted as the title. - args = ['/c', 'start', '""', url] + // We deliberately AVOID `cmd /c start` here. cmd.exe treats `&` + // as a command separator, so an OAuth URL like + // https://x.com/auth?response_type=code&client_id=abc&code_challenge=... + // got silently truncated to `https://x.com/auth?response_type=code` + // — the user's browser landed on a URL with no client_id / + // redirect_uri / PKCE challenge and Sentry replied "Invalid + // redirect URI". Node's argv quoting doesn't quote `&` (it's not + // a Windows-native special char, only a cmd-builtin special char) + // so even passing the URL as a separate arg didn't save us. + // + // `rundll32 url.dll,FileProtocolHandler ` is the documented + // Win32 way to invoke the default browser's protocol handler. + // It bypasses cmd entirely, so `&` passes through verbatim. + cmd = 'rundll32' + args = ['url.dll,FileProtocolHandler', url] } else if (process.platform === 'darwin') { cmd = 'open' args = [url] diff --git a/packages/core/tests/mcp-oauth-provider.test.ts b/packages/core/tests/mcp-oauth-provider.test.ts index 4024bf2..f42cd6b 100644 --- a/packages/core/tests/mcp-oauth-provider.test.ts +++ b/packages/core/tests/mcp-oauth-provider.test.ts @@ -51,3 +51,44 @@ describe('McpOAuthProvider.redirectUrl', () => { expect(provider.clientMetadata.redirect_uris).toContain(provider.redirectUrl) }) }) + +describe('McpOAuthProvider.redirectToAuthorization (passive vs interactive)', () => { + beforeEach(() => { + isolate() + }) + afterEach(() => { + delete process.env.X_CODE_HOME + }) + + it('does NOT open a browser by default (passive mode)', async () => { + // Regression: a previous version unconditionally opened the browser + // here, which fired on CLI boot whenever an HTTP MCP server had no + // stored token. No competing CLI does that — they all wait for an + // explicit user action. We verify by checking that no callback + // server got started and no onOpenBrowser hook fired. + let opened: string | null = null + const provider = new McpOAuthProvider({ + serverName: 'test-server', + serverUrl: 'https://example.com/mcp', + storage: new McpTokenStorage(), + onOpenBrowser: (url) => { + opened = url + }, + }) + + const before = provider.redirectUrl + await provider.redirectToAuthorization(new URL('https://auth.example.com/authorize')) + const after = provider.redirectUrl + + expect(opened).toBeNull() + // Same placeholder before AND after — the callback server was never + // started, so the URL didn't change to include a real port. + expect(after).toBe(before) + }) + + // We deliberately don't test the interactive (setInteractive(true)) + // path here. That path calls openInBrowser → child_process.spawn, + // which would actually launch the developer's browser every time + // `pnpm test` runs. The interactive flow is covered by manual /mcp + // auth testing + the existing connectWithOAuth wiring. +}) From 8ee321e948cd3782de0f9fda6a1b59d81781f981 Mon Sep 17 00:00:00 2001 From: woai3c <411020382@qq.com> Date: Tue, 19 May 2026 19:40:10 +0800 Subject: [PATCH 9/9] fix(core/mcp): add Linux URL-opener fallback chain for OAuth browser open MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit openInBrowser on Linux/*BSD only tried `xdg-open`, which is missing on minimal containers, many headless server distros, and WSL distros that ship wslu instead. When it wasn't installed the OAuth flow silently deadlocked — the user saw "Opening browser..." in the CLI but no browser actually launched, and Esc was the only escape. Try a chain instead: xdg-open → gio open → wslview → kde-open → gnome-open. On the first candidate that exists and either exits cleanly or is still alive after a 500 ms grace window (some openers fork and stay running briefly), declare success and return. ENOENT or non-zero exit falls through to the next candidate. If every candidate fails, log `mcp.browser-open-no-opener` so the situation is diagnosable — the CLI's "Opened " line already gave the user the URL to paste manually. openInBrowser is now async (was sync fire-and-forget). The single caller in redirectToAuthorization adds an await. The grace window means worst-case latency is bounded at 500 ms × candidates-until-hit; the typical path of "xdg-open works, exits fast" stays sub-50 ms. --- packages/core/src/mcp/oauth/provider.ts | 100 ++++++++++++++++++++---- 1 file changed, 85 insertions(+), 15 deletions(-) diff --git a/packages/core/src/mcp/oauth/provider.ts b/packages/core/src/mcp/oauth/provider.ts index 4596ef8..7841bea 100644 --- a/packages/core/src/mcp/oauth/provider.ts +++ b/packages/core/src/mcp/oauth/provider.ts @@ -174,7 +174,7 @@ export class McpOAuthProvider implements OAuthClientProvider { authorizationUrl.searchParams.set('redirect_uri', this.callbackServer!.url) this.opts.onOpenBrowser?.(authorizationUrl.toString()) - openInBrowser(authorizationUrl.toString()) + await openInBrowser(authorizationUrl.toString()) // Stash the pending callback so the caller can `await` it through // `waitForAuthCode()` while the transport machinery handles the @@ -229,10 +229,8 @@ export class McpOAuthProvider implements OAuthClientProvider { * block on the browser process; stdio piped to /dev/null so output * doesn't smear into our terminal UI. Failures are logged but never * thrown — the user can still copy/paste the URL by hand. */ -function openInBrowser(url: string): void { +async function openInBrowser(url: string): Promise { try { - let cmd: string - let args: string[] if (process.platform === 'win32') { // We deliberately AVOID `cmd /c start` here. cmd.exe treats `&` // as a command separator, so an OAuth URL like @@ -247,23 +245,95 @@ function openInBrowser(url: string): void { // `rundll32 url.dll,FileProtocolHandler ` is the documented // Win32 way to invoke the default browser's protocol handler. // It bypasses cmd entirely, so `&` passes through verbatim. - cmd = 'rundll32' - args = ['url.dll,FileProtocolHandler', url] - } else if (process.platform === 'darwin') { - cmd = 'open' - args = [url] - } else { - cmd = 'xdg-open' - args = [url] + spawnDetached('rundll32', ['url.dll,FileProtocolHandler', url]) + return + } + if (process.platform === 'darwin') { + // macOS `open` is rock-solid for URLs, no quirks. + spawnDetached('open', [url]) + return + } + + // Linux / *BSD: no single command works everywhere. xdg-utils + // (`xdg-open`) is the de-facto standard but missing on minimal + // containers and many server distros; `gio open` covers newer + // GNOME stacks; `wslview` covers WSL → Windows browser (when + // xdg-open inside WSL doesn't reach the host); `kde-open` and + // `gnome-open` cover their respective legacy desktops. + // + // We try each in turn, falling through on ENOENT or non-zero exit. + // Failing silently with no opener would leave the user staring at + // the CLI scrollback wondering why nothing happened — we surface a + // `mcp.browser-open-no-opener` debug entry so the situation is at + // least diagnosable, and the CLI's "Opened …" line already gave + // them the URL to copy/paste by hand. + const candidates: Array<[string, string[]]> = [ + ['xdg-open', [url]], + ['gio', ['open', url]], + ['wslview', [url]], + ['kde-open', [url]], + ['gnome-open', [url]], + ] + for (const [cmd, args] of candidates) { + if (await trySpawnOpener(cmd, args)) return } - const child = spawn(cmd, args, { stdio: 'ignore', detached: true }) - child.unref() - child.on('error', (err) => debugLog('mcp.browser-open-failed', String(err))) + debugLog('mcp.browser-open-no-opener', `no working URL opener found; advised user to copy/paste manually`) } catch (err) { debugLog('mcp.browser-open-threw', String(err)) } } +/** Fire a child process, detach, walk away. Used on Windows/macOS where + * the command is known-good — failure-detection is just a debug log. */ +function spawnDetached(cmd: string, args: string[]): void { + const child = spawn(cmd, args, { stdio: 'ignore', detached: true }) + child.unref() + child.on('error', (err) => debugLog('mcp.browser-open-failed', String(err))) +} + +/** Try one Linux URL opener candidate. Resolves true if the binary + * exists and either exited cleanly OR is still alive after a brief + * grace window (most openers exec into a browser and exit ~immediately, + * but a few — notably wslview on cold start — fork and stay running for + * a moment). Resolves false on ENOENT or non-zero exit, signalling the + * caller to try the next candidate. */ +function trySpawnOpener(cmd: string, args: string[]): Promise { + return new Promise((resolve) => { + let settled = false + const settle = (ok: boolean) => { + if (settled) return + settled = true + resolve(ok) + } + let child: ReturnType + try { + child = spawn(cmd, args, { stdio: 'ignore', detached: true }) + } catch { + settle(false) + return + } + child.on('error', () => settle(false)) + child.on('exit', (code) => { + if (code === 0) { + child.unref() + settle(true) + } else { + settle(false) + } + }) + // Grace window for openers that fork-and-stay-alive. 500 ms is well + // under any user-perceptible delay yet covers the slowest reasonable + // launch path; anything still alive at this point is almost certainly + // the real browser-launching process. + setTimeout(() => { + if (!settled) { + child.unref() + settle(true) + } + }, 500) + }) +} + /** Factory used by loader.ts. Returns undefined for stdio servers — the * loader skips OAuth construction for those. */ export function createOAuthProviderFactory(