feat: Model Context Protocol (MCP) client support#11
Merged
Conversation
Adds end-to-end MCP client support so users can plug community / custom
MCP servers into xc with the same `mcpServers` config shape used by
Claude Code and Gemini CLI.
Transports
- stdio (local subprocess; ~90% of community servers)
- Streamable HTTP (remote servers, with optional OAuth 2.1)
Primitives
- Tools: every connected server's tools are exposed under
`mcp__<server>__<tool>` and routed through the existing permission
/ loop-guard / abortSignal pipeline.
- Resources: surfaced via two built-in tools (`listMcpResources`,
`readMcpResource`) so the model pulls them on demand instead of
auto-injecting into context.
- Prompts: intentionally not wired up (rarely used in the wild).
Trust & security
- Project-level `.x-code/config.json` requires explicit consent
on first launch (readline-based dialog showing the actual
command strings). Trusted paths persisted to
`~/.x-code/trusted-projects.json`.
- Per-tool permission: every MCP tool starts at ask; "always
allow" choices persist to `~/.x-code/mcp-permissions.json`.
- OAuth tokens kept in `~/.x-code/mcp-auth.json` (mode 0600).
- Plan mode denies all MCP tools (opaque side effects).
Slash commands
/mcp list / tools / auth / logout / refresh
System prompt
`{mcpCapabilities}` placeholder added to BASE_SYSTEM_PROMPT —
expands to "" when no MCP is configured, keeping prefix-cache
byte-stable for the existing single-provider flow.
Tests
- 6 vitest files, 45 unit + integration tests (uses a real
Node child process implementing the protocol).
- One e2e scenario (24-mcp-stdio) drives `xc -p` against an
inline mock MCP server and asserts the model quotes a
server-stamped marker.
/mcp refresh and /mcp auth previously just printed "restart the CLI" hints. Gemini CLI is the only competitor that runs them as real operations, so this aligns with that behavior: - McpRegistry gains restartServer, restartAll, and authenticateServer. Configs + OAuth factory now live on the registry so it can rebuild servers in place without churning the AgentOptions reference held by the agent loop. - McpClient.connectWithOAuth drives the full OAuth round-trip: catch UnauthorizedError, await the callback server's code, finishAuth on the transport, retry connect. The default connect() path is unchanged so CLI boot still surfaces auth-needed servers as needs_auth instead of unconditionally popping a browser. - loadMergedConfigsFromDisk reads + trust-gates the config without spawning anything, so /mcp refresh can hand the merged map straight to restartAll for an in-place reload. - useAgent exposes invalidateSystemPromptCache so the slash-command handler can null the cache after the tool surface changes - prefix caching on OpenAI-compatible providers would otherwise send a stale tool list on the next turn. - /mcp refresh prints an added/removed/changed/reconnected summary so users can see what the reload actually did.
…g binary pnpm 10's default sandbox skips install scripts for every dep that isn't in onlyBuiltDependencies. @vscode/ripgrep relies on its postinstall to fetch the rg binary from GitHub Releases, so on a fresh CI runner the binary never lands on disk and glob-tool / utils tests spawn rg with ENOENT. Local runs were fine only because the pnpm store had a cached copy from a pre-10 install — fresh runners have no such cache, which is why the failure was CI-only.
…'t 403 After whitelisting @vscode/ripgrep for builds in the previous commit, its postinstall started running on CI and immediately hit api.github.com/repos/microsoft/ripgrep-prebuilt/releases/tags/v15.0.0 with a 403. The anonymous GitHub API quota is 60 req/h per IP, and Actions runners share IP pools - concurrent runs across the org exhaust it. postinstall.js reads process.env.GITHUB_TOKEN and forwards it as a bearer token, which lifts us into the 5000/h authenticated bucket. secrets.GITHUB_TOKEN is auto-provisioned per workflow run so there's nothing to configure.
Three new slash subcommands that let users manage MCP server config without hand-editing config.json: /mcp add <name> <cmd> [args...] stdio server /mcp add --http <name> <url> Streamable HTTP server /mcp add-json <name> '<json>' raw JSON for complex configs /mcp remove <name> delete (with y/N confirm) - Defaults to user scope (~/.x-code/config.json). --scope project writes to <repo>/.x-code/config.json AND auto-adds the project to trusted-projects.json, so the user running the add doesn't trip their own consent dialog on next launch. Collaborators cloning the repo still go through the trust prompt normally. - /mcp remove auto-detects which scope contains the server; ambiguous (both scopes) requires --scope to avoid silent wrong-pick. - Duplicate add prints the existing config as JSON so the user can see what they were about to clobber. - Tokenizer guards Windows paths: backslash escapes only whitespace, quotes, and itself; `D:\res\x-code\tmp` survives verbatim instead of collapsing to `D:resx-codetmp`. Not implemented (rejected by design): - --scope local (no third config tier in our architecture) - --trust flag a la Gemini (would bypass the ask-first permission model)
The MCP SDK reads `redirectUrl` while constructing the authorize URL during the very first connect attempt with no stored token — which is BEFORE `redirectToAuthorization` runs. The previous version threw "Callback server not started", and the registry classifier didn't match that string against `/unauth|401|UnauthorizedError/i`, so HTTP servers showed up as `failed` on first launch after `/mcp add` instead of the intended `needs_auth`. Return the same loopback placeholder `clientMetadata.redirect_uris` already uses. RFC 8252 §7.3 requires auth servers to accept any port on a registered loopback redirect, and `redirectToAuthorization` rewrites the `redirect_uri` query param with the real port right before launching the browser, so the placeholder never reaches the auth server.
MCP tool names now use `<server>__<tool>` instead of
`mcp__<server>__<tool>`. Codex and Gemini CLI both omit the prefix;
only Claude Code keeps it, and that's purely a historical artefact —
the prefix carries no information the description doesn't already
convey, and it costs a few tokens per tool on every API request.
- Routing is no longer name-pattern-based. `tool-execution.ts` decides
MCP-vs-built-in by registry lookup (`mcpRegistry?.get(name)`), not
`startsWith('mcp__')`. Built-in tools are camelCase, so name shape
alone still disambiguates in practice — the registry is just the
authoritative answer.
- `mcp-permissions.json` auto-migrates: keys are stripped of any
legacy `mcp__` prefix on load, so existing always-allow grants
survive the rename. The on-disk file rewrites itself on the next
approval (writePersisted runs from the in-memory set, which already
holds migrated keys).
- Drops the `MCP_PREFIX` and `isMcpCallableName` exports — they only
made sense under the prefix scheme.
Five intertwined bugs that together made HTTP MCP server OAuth unusable. Each one masked the next, so they had to be peeled off in sequence by manual testing against the live Sentry MCP server. - Boot mode no longer auto-opens a browser. McpOAuthProvider has a default-off `interactive` flag; redirectToAuthorization returns silently in passive mode. The SDK still throws UnauthorizedError next, so the registry marks the server `needs_auth` correctly. Only `/mcp auth <name>` flips interactive=true and actually launches the browser — matching Claude Code / Gemini CLI / OpenCode semantics. - Pre-start the local callback server BEFORE the SDK builds the dynamic-registration request (new `prepareForAuth()` called from connectWithOAuth). Otherwise registration uses the port-less placeholder URI and Sentry (which doesn't honour RFC 8252 §7.3 loopback any-port) rejects the real-port redirect_uri with "Invalid redirect URI". - connect() now leaves the transport alive when the error is UnauthorizedError. The previous unconditional safeClose nuked `this.transport`, so runOAuthDance's `transport.finishAuth(code)` hit "Internal error: OAuth flow expected an HTTP transport". - waitForAuthCode no longer wipes `memoryCodeVerifier` in its finally block. The SDK reads the PKCE verifier during finishAuth(code), which runs AFTER waitForAuthCode resolves — wiping it there caused "No PKCE verifier set — auth flow not in progress". cancel() and the next saveCodeVerifier still clean it up on the abort / re-auth paths. - Windows openInBrowser switched from `cmd /c start "" <url>` to `rundll32 url.dll,FileProtocolHandler <url>`. cmd.exe treats `&` as a command separator, and Node's argv escaping doesn't quote it (not a Windows-native special char), so the OAuth URL got truncated at the first `&` — the browser landed on `?response_type=code` with no client_id / redirect_uri / PKCE challenge, and Sentry rejected with "Invalid redirect URI". rundll32 bypasses cmd entirely. Adds a passive-mode regression test (no browser open, no callback server bind when interactive=false). The interactive path isn't covered by a test because it would actually spawn a real browser on the developer's machine on every `pnpm test`.
…open openInBrowser on Linux/*BSD only tried `xdg-open`, which is missing on minimal containers, many headless server distros, and WSL distros that ship wslu instead. When it wasn't installed the OAuth flow silently deadlocked — the user saw "Opening browser..." in the CLI but no browser actually launched, and Esc was the only escape. Try a chain instead: xdg-open → gio open → wslview → kde-open → gnome-open. On the first candidate that exists and either exits cleanly or is still alive after a 500 ms grace window (some openers fork and stay running briefly), declare success and return. ENOENT or non-zero exit falls through to the next candidate. If every candidate fails, log `mcp.browser-open-no-opener` so the situation is diagnosable — the CLI's "Opened <url>" line already gave the user the URL to paste manually. openInBrowser is now async (was sync fire-and-forget). The single caller in redirectToAuthorization adds an await. The grace window means worst-case latency is bounded at 500 ms × candidates-until-hit; the typical path of "xdg-open works, exits fast" stays sub-50 ms.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds end-to-end MCP client support to
xcso users can plug community / custom MCP servers (filesystem, git, Sentry, Linear, GitHub, custom in-house tools, …) into the agent loop. Configuration shape is intentionally aligned with Claude Code / Gemini CLI so existingmcpServerssnippets migrate verbatim.Discovered tools appear to the model as
mcp__<server>__<tool>and route through the existing permission / loop-guard / abortSignal pipeline. MCP Resources are surfaced via two built-in tools (listMcpResources/readMcpResource) so they're pulled on demand, not auto-injected into context.What's in
Transports
~/.x-code/mcp-auth.jsonmode 0600)Trust & security
.x-code/config.jsonrequires explicit consent on first launch (readline-based dialog showing the actualcommandstrings). Trusted paths persist to~/.x-code/trusted-projects.json.ask; "always allow" choices persist to~/.x-code/mcp-permissions.json.plan's read-only invariant otherwise).Slash commands
/mcp//mcp list/mcp tools [server]/mcp auth <name>/mcp logout <name>/mcp refreshSystem prompt
Added a
{mcpCapabilities}placeholder toBASE_SYSTEM_PROMPT. When no MCP is configured it expands to"", leaving the prompt byte-identical to pre-MCP — preserving prefix-cache hits on OpenAI-compatible providers (DeepSeek, Moonshot, Alibaba, etc.). When MCP is active, a## MCP Toolssection is appended.Design decisions worth flagging
/mcp refreshdoesn't actually rebuild the registry in-place — it prints "restart to apply" — because mid-session changes would forcesystemPromptCacheto invalidate, and OpenAI-compatible providers' prefix caches would miss for the next API call. Codex/Gemini take the same line; only Claude Code (Anthropic-only) auto-watches the config file.execute()path. They're declared withoutexecute, soprocessToolCallsgets them inresult.toolCallsand can gate every call throughapplyLoopGuard+ the MCP permission store + abortSignal threading — same pattern asshell/writeFile/edit.mcp__<server>__<tool>, sanitized + truncated to 64 chars + collision-resolved with a hash suffix. Picked__(double underscore) over_to avoid colliding with common server tool names likeread_file.~/.x-codeso practical leak surface is bounded to the same user.What's NOT in (intentional)
App.tsx's flat-switch dispatch.mcpServersconfig. See above.tmp/mcp-implementation-plan.md— kept out of the commit becausetmp/is gitignored; was an interactive design artifact, not durable docs.Test plan
pnpm typecheck— cleanpnpm lint— clean (one pre-existing unrelated warning)pnpm test— 434 passing (45 new in 6 MCP files, the rest unchanged)mcp-name-mangling.test.ts(7) — sanitize / truncation / collision resolutionmcp-expand-env.test.ts(8) —${VAR}/${VAR:-default}/ missing-var errormcp-config-schema.test.ts(9) — stdio / http / both-fields / neither-field pathsmcp-trust.test.ts(11) — persistence, path normalization, dialog branchingmcp-permissions.test.ts(6) — session vs persisted, atomic write, sort ordermcp-integration.test.ts(4) — real child process implementing the protocol; connect → listTools → callTool → readResource → closepnpm build— cleanpnpm test:e2e --list— new scenario24-mcp-stdioauto-discoveredNot yet run (requires API key + token spend, deferred to reviewer or CI):
pnpm test:e2e --filter mcp— drivesxc -pagainst an inline mock MCP server, asserts the model quotes a random server-stamped marker (catches "model didn't actually wait for the tool result")@modelcontextprotocol/server-filesystem, declare it inmcpServers, ask the model to read a file via itFiles changed
packages/core/src/mcp/(14 files: types, schema, env expansion, name mangling, client, registry, loader, trust, permissions, tool-bridge, resources +oauth/{provider,callback-server,token-storage}.ts)core/src/{agent/loop.ts, agent/system-prompt.ts, agent/tool-execution.ts, config/index.ts, types/index.ts, index.ts},cli/src/{index.ts, ui/components/App.tsx}mock-mcp-server.mjs)@modelcontextprotocol/sdk@^1.29.0