Skip to content

feat: Model Context Protocol (MCP) client support#11

Merged
woai3c merged 9 commits into
mainfrom
feat/mcp-support
May 19, 2026
Merged

feat: Model Context Protocol (MCP) client support#11
woai3c merged 9 commits into
mainfrom
feat/mcp-support

Conversation

@woai3c
Copy link
Copy Markdown
Owner

@woai3c woai3c commented May 17, 2026

Summary

Adds end-to-end MCP client support to xc so 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 existing mcpServers snippets migrate verbatim.

// ~/.x-code/config.json
{
  "mcpServers": {
    "filesystem": { "command": "npx", "args": ["-y", "@modelcontextprotocol/server-filesystem", "/tmp"] },
    "sentry":     { "url": "https://mcp.sentry.dev" }
  }
}

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

  • stdio (local subprocess; covers ~90% of community servers)
  • Streamable HTTP — optional OAuth 2.1 with automatic token refresh (~/.x-code/mcp-auth.json mode 0600)

Trust & security

  • Project-level .x-code/config.json requires explicit consent on first launch (readline-based dialog showing the actual command strings). Trusted paths persist 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.
  • Plan mode denies all MCP tools (their side effects are opaque to us, so we can't honor plan's read-only invariant otherwise).

Slash commands

Command Behavior
/mcp / /mcp list per-server connection status
/mcp tools [server] list discovered tools
/mcp auth <name> OAuth flow guidance (auto-triggered at next launch)
/mcp logout <name> clear stored tokens for one server
/mcp refresh reminds the user to restart (we intentionally don't hot-reload — see below)

System prompt

Added a {mcpCapabilities} placeholder to BASE_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 Tools section is appended.

Design decisions worth flagging

  • Frozen registry, no hot-reload. Same constraint as sub-agents (CLAUDE.md). /mcp refresh doesn't actually rebuild the registry in-place — it prints "restart to apply" — because mid-session changes would force systemPromptCache to 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.
  • MCP tools are dispatched manually, not via the AI SDK's execute() path. They're declared without execute, so processToolCalls gets them in result.toolCalls and can gate every call through applyLoopGuard + the MCP permission store + abortSignal threading — same pattern as shell/writeFile/edit.
  • Tool name mangling: 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 like read_file.
  • OAuth tokens stored as 0600 file, not OS keychain. Linux keychain (libsecret/dbus) availability is too uneven across distros for a v1 default; the file lives under ~/.x-code so practical leak surface is bounded to the same user.

What's NOT in (intentional)

  • MCP Prompts → slash commands. Almost no community server actually exposes prompts; dynamic slash registration would add real complexity to App.tsx's flat-switch dispatch.
  • MCP sampling. Niche; rarely wired up by server authors.
  • Hot-reload of mcpServers config. See above.
  • tmp/mcp-implementation-plan.md — kept out of the commit because tmp/ is gitignored; was an interactive design artifact, not durable docs.

Test plan

  • pnpm typecheck — clean
  • pnpm 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 resolution
    • mcp-expand-env.test.ts (8) — ${VAR} / ${VAR:-default} / missing-var error
    • mcp-config-schema.test.ts (9) — stdio / http / both-fields / neither-field paths
    • mcp-trust.test.ts (11) — persistence, path normalization, dialog branching
    • mcp-permissions.test.ts (6) — session vs persisted, atomic write, sort order
    • mcp-integration.test.ts (4) — real child process implementing the protocol; connect → listTools → callTool → readResource → close
  • pnpm build — clean
  • pnpm test:e2e --list — new scenario 24-mcp-stdio auto-discovered

Not yet run (requires API key + token spend, deferred to reviewer or CI):

  • pnpm test:e2e --filter mcp — drives xc -p against an inline mock MCP server, asserts the model quotes a random server-stamped marker (catches "model didn't actually wait for the tool result")
  • Manual smoke: install @modelcontextprotocol/server-filesystem, declare it in mcpServers, ask the model to read a file via it
  • Manual OAuth: point at a real OAuth-protected server (e.g. Sentry), verify browser opens + token persists

Files changed

+1,700 / -2  across 31 files
  • New: 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)
  • Modified: 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}
  • Tests: 6 new vitest files + 1 e2e scenario + 1 fixture (mock-mcp-server.mjs)
  • Dep: @modelcontextprotocol/sdk@^1.29.0

woai3c added 9 commits May 17, 2026 23:46
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.
@woai3c woai3c merged commit c7cffec into main May 19, 2026
5 checks passed
@woai3c woai3c deleted the feat/mcp-support branch May 19, 2026 11:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant