Skip to content

fix(mcp): gate mcp serve through the tool dispatcher - #257

Merged
oratis merged 1 commit into
mainfrom
fix/mcp-serve-gate
Aug 9, 2026
Merged

fix(mcp): gate mcp serve through the tool dispatcher#257
oratis merged 1 commit into
mainfrom
fix/mcp-serve-gate

Conversation

@oratis

@oratis oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner

Stacked on #254 (needs ToolContext.contract), which is stacked on #252.

Found while auditing where the file contract reaches. It is a bigger hole than the one I was looking for.

What mcp serve did

deepcode mcp serve exposes Read / Write / Edit / Bash / Grep / Glob to whatever MCP client connects — typically another agent, configured once in Claude Desktop and forgotten. packages/core/src/mcp/serve.ts called tool.execute directly:

  • no mode policy
  • no permission rules
  • no file contract
  • no PreToolUse hooks

and apps/cli/src/mcp-cmd.ts passed no sandboxConfig either, so Bash ran unsandboxed on top.

Same shape as the runAgent bypass fixed in #181 — a host that assembles tools without the central gate — in an entry point that fix did not reach.

It was known. docs/DEVELOPMENT_PLAN.md §risks:

deepcode mcp serve 反向暴露的线程/权限模型缺失 … 推迟到 v1.1,v1 先实现 client;M3 出独立 design doc

The design doc was never written. The feature shipped.

Change

Every call goes through dispatchToolCall.

gate is a required field on BuildMcpServerOpts, not an optional one. AGENTS.md: "Never make safety depend on a host remembering to pass an optional argument." Optional is exactly how this happened.

Posture, for a surface with nobody attached

ask Refused. There is no user on that pipe. Granting would make "whoever connected" the authority on what may run.
Permissive defaultMode Clamped to default, via the same resolveTriggerMode a scheduled job uses. bypassPermissions is a decision about sitting at a REPL.
--mode The explicit opt-in back out of the clamp.
--sandbox Now applies. It did not before.
Project settings Still trust-gated, so an untrusted checkout cannot widen the posture of the server serving it.

The startup banner names the mode in effect and explains the clamp when one happens — a silent clamp is as surprising as a silent grant.

Breaking

A peer can now do what permissions.allow says it can, and nothing else. Anyone relying on the old behaviour has to write rules down, or start the server with --mode. That is a real capability reduction and it is the point.

Verification

typecheck, lint, format, docs clean; full suite green through the pre-commit hook. 11 new tests:

  • a denied call is refused and the file is never written (asserted, not inferred);
  • the gate sees the arguments the peer actually sent;
  • ask → refused, with a reason that names the fix;
  • permissions.allow works with no mode change;
  • a contract deny holds even under bypassPermissions;
  • the clamp fires, announces itself, and --mode opts back out.

🤖 Generated with Claude Code

@oratis

oratis commented Aug 9, 2026

Copy link
Copy Markdown
Owner Author

Review — approve, no changes

The severity here is understated by the diff size. mcp serve handed Read/Write/Edit/Bash to whatever connected — with no mode, no rules, no contract, no hooks, and no sandbox — and the typical peer is another agent configured once in a desktop client and then forgotten. Same shape as the #181 runAgent bypass, in the entry point that fix did not reach.

Checked:

  • gate is required on BuildMcpServerOpts, and ServeMcpStdioOpts extends it, so there is no path to a server without one. This is the whole design and it is the right one — AGENTS.md's "never make safety depend on a host remembering to pass an optional argument" is exactly the rule that was broken, so making it structurally impossible beats adding a default.
  • The gate runs before tool.execute, and the test asserts the file is never written rather than inferring it from the refusal. That distinction is worth having.
  • ask → refused is correct and the reason string is genuinely actionable: it names permissions.allow and --mode rather than just saying no.
  • The clamp reuses resolveTriggerMode, so the unattended posture is one rule with two callers instead of two rules that drift. --mode as the documented opt-in keeps it from being a wall.
  • Trust gating is applied, so an untrusted checkout cannot widen the posture of the server serving it — that would have been an easy thing to miss.
  • contract reaches the ToolContext, which closes the hole fix(core): make the file contract reach where it claimed to #254 explicitly left open.
  • Task is in MCP_SERVE_EXCLUDE, so there is no delegation path around any of this.

The banner naming the mode, and naming the clamp when one happens, is the right amount of noise: a silent clamp surprises people the same way a silent grant does.

The ⚠️ Breaking label is honest. This is a real capability reduction for anyone relying on the old behaviour, and the PR says so plainly instead of framing it as a pure fix.

@oratis
oratis force-pushed the feat/contract-result-filter branch from 3cae1f2 to 4aa9a23 Compare August 9, 2026 15:39
@oratis
oratis changed the base branch from feat/contract-result-filter to main August 9, 2026 15:43
`deepcode mcp serve` exposes Read/Write/Edit/Bash/Grep/Glob to whatever MCP
client connects — usually another agent. It called `tool.execute` directly: no
mode, no permission rules, no file contract, no PreToolUse hooks. The CLI did
not pass a sandbox config either, so Bash ran unsandboxed as well.

This is the same shape as the `runAgent` bypass fixed in #181, in an entry point
that fix did not reach. DEVELOPMENT_PLAN §"风险" listed it — "`deepcode mcp
serve` 反向暴露的线程/权限模型缺失", with the mitigation "M3 出独立 design
doc". The design doc was never written and the feature shipped anyway.

Every call now goes through `dispatchToolCall`. `gate` is a required field on
`BuildMcpServerOpts` rather than an optional one, because AGENTS.md's rule is
that safety must not depend on a host remembering an argument — optional is how
this happened.

Nobody is attached to that pipe, so `ask` is refused rather than granted;
otherwise "whoever connected" becomes the authority on what may run. A
permissive `permissions.defaultMode` is clamped to `default` through the same
`resolveTriggerMode` a scheduled job uses, since `bypassPermissions` is a
decision about sitting at a REPL. `--mode` is the explicit opt-in back out, and
`--sandbox` now applies too. Directory trust gates project settings, so an
untrusted checkout cannot widen the posture of the server serving it.

This is breaking: a peer can now do what `permissions.allow` says and nothing
else. That is the point, and the startup banner says which mode is in effect.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@oratis
oratis force-pushed the fix/mcp-serve-gate branch from 11481bf to eca27d1 Compare August 9, 2026 15:44
@oratis
oratis merged commit d9d6d95 into main Aug 9, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant