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 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": { 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..861cead 100644 --- a/packages/cli/src/ui/components/App.tsx +++ b/packages/cli/src/ui/components/App.tsx @@ -7,16 +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' @@ -66,6 +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 / add / add-json / remove / auth / logout / refresh)', + }, { name: '/exit', description: 'Exit (flushes session)' }, ] as const @@ -243,6 +259,7 @@ export function App({ switchModel, setThinking, getThinking, + invalidateSystemPromptCache, addInfoMessage, addUserMessage, addCommandMessage, @@ -573,6 +590,10 @@ export function App({ handleMemory() return + case 'mcp': + await handleMcp(text, arg) + return + case 'exit': await cleanup() exit() @@ -1021,6 +1042,411 @@ 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 + } + 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': { + if (!subArg) { + addCommandMessage(text, 'Usage: /mcp logout ') + return + } + try { + await getTokenStorage().clear(subArg) + 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': { + 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 + } + 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, 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/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/cli/tests/e2e/scenarios/24-mcp-stdio.ts b/packages/cli/tests/e2e/scenarios/24-mcp-stdio.ts new file mode 100644 index 0000000..82ef98a --- /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 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 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', + '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, '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..25bf8dc 100644 --- a/packages/core/src/agent/tool-execution.ts +++ b/packages/core/src/agent/tool-execution.ts @@ -4,6 +4,7 @@ import path from 'node:path' import type { ModelMessage } from 'ai' +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 +19,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 +268,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 +350,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 +493,19 @@ 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. + // + // 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 + } + if (await applyLoopGuard(ctx, deferred)) return if (!(await checkWriteOrShellPermission(ctx))) return @@ -417,6 +515,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..d99b7a3 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -90,3 +90,46 @@ 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, + 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' +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, 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' +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..00343e8 --- /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 `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 ───────────────────────────────────────────────── + +/** 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/client.ts b/packages/core/src/mcp/client.ts new file mode 100644 index 0000000..8d55929 --- /dev/null +++ b/packages/core/src/mcp/client.ts @@ -0,0 +1,398 @@ +// @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, 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' +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, + 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) { + // 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) + } + + // 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 + } + + /** 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` + * 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 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`) + } + 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 + + // 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) { + provider.redirectToAuthorization = async (url: URL) => { + try { + hooks.onBrowserOpen?.(url.toString()) + } catch { + // Hook failures must not abort the OAuth flow. + } + return originalRedirect(url) + } + } + try { + return await this.runOAuthDance() + } finally { + provider.setInteractive(false) + if (hooks.onBrowserOpen) { + provider.redirectToAuthorization = originalRedirect + } + } + } + + /** 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( + { 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 + } +} + +/** 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 + * 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/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/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..11ead0c --- /dev/null +++ b/packages/core/src/mcp/loader.ts @@ -0,0 +1,281 @@ +// @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 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 { parseServersBlock } from './config-schema.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 } from './types.js' + +// 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. */ + 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, + }) +} + +/** 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 { + 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. + // 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: 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]) => { + return connectOneServer(name, rawConfig, options.oauthProviderFor) + }) + 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 = buildCallable(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 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 } +} + +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' + } +} + +/** 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..8eb78fa --- /dev/null +++ b/packages/core/src/mcp/name-mangling.ts @@ -0,0 +1,94 @@ +// @x-code-cli/core — MCP tool name mangling +// +// 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": +// +// __ +// +// 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 +// 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_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 `_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 = `${s}__${t}` + + // 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 - 1 /* underscore */ - hash.length + name = `${(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 +} 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..7841bea --- /dev/null +++ b/packages/core/src/mcp/oauth/provider.ts @@ -0,0 +1,351 @@ +// @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 + /** 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 { + // 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 { + 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 { + // 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 + // updated redirect_uri reflecting our actual port. + await this.ensureCallbackServer() + authorizationUrl.searchParams.set('redirect_uri', this.callbackServer!.url) + + this.opts.onOpenBrowser?.(authorizationUrl.toString()) + await 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. + * + * 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') + } + try { + return await this.pendingCode + } finally { + this.pendingCode = 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. */ +async function openInBrowser(url: string): Promise { + try { + if (process.platform === 'win32') { + // 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. + 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 + } + 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( + 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..11766b7 --- /dev/null +++ b/packages/core/src/mcp/permissions.ts @@ -0,0 +1,130 @@ +// @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)) { + // 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 + } + 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..6922734 --- /dev/null +++ b/packages/core/src/mcp/registry.ts @@ -0,0 +1,430 @@ +// @x-code-cli/core — MCP registry +// +// 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 + 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 +} + +/** 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 + * `__` 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() + /** 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[] + /** 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 ─────────────────────────────────────────────────────── + + /** 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) + } + + getConfig(serverName: string): McpServerConfig | undefined { + return this.configs.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 (internally) by `restartAll` before rebuilding. */ + 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) + } + + // ── 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 + * in config, or trust dialog rejected). Cheaper than null-checking the + * registry everywhere downstream. */ +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/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..f04aaac --- /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 (__); + * 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-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-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-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() + }) +}) 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..a1869f6 --- /dev/null +++ b/packages/core/tests/mcp-integration.test.ts @@ -0,0 +1,212 @@ +// 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 { 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') + +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('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('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('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('mock__echo') + expect(names).toContain('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('mock_b__echo') + expect(afterRemoval).toContain('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 { + 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..06a59ac --- /dev/null +++ b/packages/core/tests/mcp-name-mangling.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest' + +import { MCP_MAX_NAME_LEN, buildCallableName } from '../src/mcp/name-mangling.js' + +describe('buildCallableName', () => { + it('produces __ for clean inputs', () => { + const name = buildCallableName('filesystem', 'read_file', new Set()) + 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('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 `__tool`. + const name = buildCallableName('文件系统', 'read', new Set()) + expect(name).toMatch(/^[a-f0-9]{6}__read$/) + }) + + 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) + // 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', () => { + 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) + }) +}) 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..f42cd6b --- /dev/null +++ b/packages/core/tests/mcp-oauth-provider.test.ts @@ -0,0 +1,94 @@ +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) + }) +}) + +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. +}) diff --git a/packages/core/tests/mcp-permissions.test.ts b/packages/core/tests/mcp-permissions.test.ts new file mode 100644 index 0000000..e8ab73d --- /dev/null +++ b/packages/core/tests/mcp-permissions.test.ts @@ -0,0 +1,91 @@ +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('foo__bar')).toBe(false) + }) + + it('approves for session only without persisting', async () => { + const store = new McpPermissionStore() + 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('foo__bar')).toBe(false) + }) + + it('approvePermanently persists across instances', async () => { + const store = new McpPermissionStore() + await store.approvePermanently('foo__bar') + + const store2 = new McpPermissionStore() + expect(await store2.isApproved('foo__bar')).toBe(true) + }) + + it('writes a 0600 file with sorted entries', async () => { + const store = new McpPermissionStore() + 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(['alpha__a', 'zeta__b']) + }) + + it('ignores re-approving an already-permanent entry', async () => { + const store = new McpPermissionStore() + 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) + }) +}) + +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