Repository navigation
Commit 698e8d4
feat(mcp): a host can observe and refuse every MCP tool call through one neutral interceptor (#31633)
<!-- claude-projects-attribution: {"github_login":"os-zhuang"} -->
_Requested by **Sam** · [project
thread](https://claude.ai/code/project/chan_01GsBogozeZG5sdpAARrN7HP?thread=cmsg_01GsBogozeZG5sdpAARrN7HPMDj36ULsuXoWvUkSAxFz39)_
Part of #31605
The card stays open on merge on purpose: this branch is a project-thread
branch, which no `Claim:` can name, so the PR-body claim guard would
refuse a closing keyword. The landing seat finishes the card by hand.
## What changes
A host can now install one interceptor on the `mcp` service that sees
every MCP tool call, on both transports, and can allow it, refuse it, or
replace its result. With nothing installed, every call runs exactly as
before.
- **`MCPServerRuntime.setToolInterceptor(fn)`** (plus
`clearToolInterceptor`, `hasToolInterceptor`): a single slot, read at
each call, so it applies to tools that registered before it was
installed and to every later per-request server.
- **One seam per server, not per tool.** `routeToolCallsThrough` (new
`tool-call-interceptor.ts`) wraps the server's own `registerTool` /
`tool` methods right after the platform builds the server: the
long-lived (stdio) server in the constructor, the per-request HTTP
server in `handleHttpRequest`. Every tool registered on either server
afterwards runs through it: the object, action, file and federation
tools (`wireBridgeTools`), the host-injected authoring family
(`registerAuthoringTools`), the bridged AI tools (`bridgeTools`), and
any tool a plugin registers on `runtime.server` on `mcp:ready`,
including a handler a later `RegisteredTool.update({ callback })`
installs.
- **The context is server-built.** `door: 'mcp'`, `transport` (`http` |
`stdio`), the registered tool name, a deep-frozen copy of the
SDK-validated arguments, and a frozen principal: `userId`,
`principalKind`, `onBehalfOf`, `performedBy.clientId`, `organizationId`
(from `tenantId`), `environmentId`, and `isSystem: true` for a system
caller. On HTTP it is projected from a new `handleHttpRequest` option,
`caller: { executionContext, environmentId }`, which the runtime's
`/mcp` dispatcher now fills from the request's resolved
`ExecutionContext` and dispatched environment. The inbound `Request`
never reaches the projection, so no header or JSON-RPC member can set a
member. On stdio it is the `OS_MCP_STDIO_API_KEY` identity, resolved per
call (ADR-0101); the stdio transport stamps no `environmentId`.
- **Contract.** Allow = call `next()` and return its result (or a
replacement). Deny = return `{ outcome: 'deny', code, message, status?
}` without calling `next()`; the client receives the coded-refusal
envelope every MCP tool error uses (`isError: true`, text `{ "error": {
code, message, status } }`). `outcome` is a discriminator, so a later
`hold` outcome (cloud#3820 O6) can be added beside `deny` without
reshaping it; today anything other than a result or a well-formed deny
is refused as `500 INTERNAL_ERROR` and logged. A throw from the
interceptor refuses the call (a coded throw keeps its code and status);
a tool's own throw that the interceptor lets through keeps the answer it
has with no interceptor. `next()` runs the tool at most once.
- **Item 5: `bridgeTools` gets the stdio principal.** Once the plugin
has resolved the stdio key it binds the per-call resolver
(`setRuntimeStdioPrincipal`, package-internal), and each bridged AI tool
call passes `{ actor: { id, positions, permissions } }` as the
registry's execution context. A runtime with no stdio principal bound
still calls `execute(call)` with one argument (the existing `[#26464]
stdio parity` pin is untouched and green).
- **No policy in open code.** No licence logic, no audit table, no seat
rule: the same rule as the AI `ToolRegistry`'s interceptor seam.
- `@objectstack/mcp` exports the new types; `codedErrorResult` and
`errorResultWithCode` are exported from `mcp-http-tools.ts` for the seam
(not on the package entry).
- `content/docs/permissions/system-context.mdx`: the principal's
`isSystem` copy is a new census read, so it gets row 66 in section 6
(reads that only carry the flag onward), and the site register is
regenerated.
## Why
External AI clients reach data through the MCP tools' own bridge
(`callData` on HTTP, the stdio data bridge), never through the AI
`ToolRegistry`, so a host that governs in-product AI through the
registry's interceptor cannot see or stop an external AI's calls. On a
licensed EE, those calls never reach `sys_ai_audit_log`. This is the
open-source half of cloud#3820 card 0; the cloud consumers (C1 audit row
per MCP call, C2 AI seat check on the MCP door, C4 kill switch) install
the interceptor after a pin.
## Verification
Tree: objectstack, this branch at the head named in the dev report (main
merged at `133f75ae`).
- New `packages/mcp/src/tool-call-interceptor.test.ts`, 43 cases:
- nothing installed: on all six paths the tool runs once and its answer
equals the answer after an install-then-clear; the HTTP `caller` getters
are read zero times and the stdio resolver is called zero times;
- allow, deny and replace on each path (HTTP object tool
`create_record`, HTTP action tool `list_actions`, HTTP file tool
`read_file`, HTTP authoring family; stdio data tool, stdio bridged AI
tool): allow serves the tool's own answer with the exact context; deny
never runs the tool and answers the envelope; replace runs the tool once
and serves the replacement;
- the principal: ten spoofed identity headers and a JSON-RPC `_meta`
change no member; no `caller` gives `{}`; nothing beyond the named
members is projected; context, principal and arguments are frozen and
the host's context is not; the stdio principal is re-resolved per call;
a stdio key that fails to resolve refuses the call before the
interceptor, with its code;
- the edges: coded throw, malformed verdicts (`undefined`, `null`, a
string, `{ outcome: 'hold' }`, a deny with no code, a bare object), a
result seen and then not vouched for, double `next()`, a tool's own
throw;
- coverage: `registerTool`, the deprecated `tool()`, a handler installed
by `update()`, and a renamed tool.
- `packages/mcp/src/__tests__/plugin-execution-context.test.ts`: a new
describe starts the real plugin on stdio and pins that a bridged tool
runs as the key's user and the interceptor is told that user, with the
identity resolved per call.
- `packages/runtime/src/http-dispatcher.mcp.test.ts`: a new describe
pins that `/mcp` passes the resolved `ExecutionContext` (by identity)
and the dispatched environment as `caller`, whatever identity headers
the request carries.
- Ablations (each restored from the commit): removing the HTTP routing
reds 21 cases, removing the stdio routing reds 11, resolving the
principal with no interceptor installed reds the two "reads nothing"
pins, dropping the plugin's binding reds the plugin pin, dropping
`caller` in the dispatcher reds the dispatcher pin.
- `@objectstack/mcp`: 61 files, 951 tests passed; `pnpm typecheck` (tsc
+ test-layer ledger) exit 0. `@objectstack/runtime`:
`src/http-dispatcher.mcp*.test.ts` + `src/domains/`: 203 files, 5455
tests passed; `pnpm typecheck` exit 0. eslint on every changed file:
clean.
- `dispatch-gates --commands --changed`: all 99 derived commands run
locally, all exit 0, after a full `turbo run build` (120/120) for
`check:dual-build-cjs-loads`. `check:system-context-census` was red on
the new `isSystem` read until row 66 was added. `pnpm --filter
@objectstack/spec check:generated`: 14/14 current.
Clause-②: yes (widening)
Metadata-protocol: none
## Acceptance notes
- **Item 5 changes one no-interceptor behaviour, as the card asks.** On
a stdio process with `OS_MCP_STDIO_API_KEY`, bridged AI tools now run
with the key's user as `actor`. Before, they ran with no context, which
an actor-requiring registry refuses as unauthenticated (and EE
governance denies as `denied_no_actor`). A key that stops resolving now
refuses the next bridged call. Nothing changes for HTTP, or for a
runtime nobody bound.
- **Not a tool call, not intercepted:** stdio `resources/read` of
`objectstack://objects/{object}/records/{id}` reads a record under the
stdio key without a tool call, so an interceptor does not see it. The
per-request HTTP server registers no resources. Whether C1 needs that
read audited is the cloud card's call.
- **Cost on stdio with an interceptor installed:** the principal is
resolved once more per call (for the context), beside the tool's own
resolution. Zero extra reads with none installed.
- **Deny codes are relayed verbatim.** The runtime does not demote an
unregistered code; a host should deny with a registered ADR-0112 code
(for example `PERMISSION_DENIED`) or register its own.
- A bridged AI tool whose stdio principal fails to resolve answers the
thrown message through the bridge's existing catch (no code); the
interceptor path, when installed, answers the coded envelope first.
- Experimental task-based tools (`experimental.tasks.registerToolTask`)
are not routed; nothing in the platform registers one. A host that calls
the exported `registerObjectTools` etc. on its own `McpServer` is
outside the `mcp` service's slot.
## Human Minimum
- [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic.
(`packages/spec` not touched.)
- [x] 2. Every name I added uses the casing for its kind.
- [x] 3. No formatter ran; `pnpm lint` passes (eslint on the changed
files).
- [x] 4. One issue, one branch off an up-to-date `main`, one pull
request.
- [x] 5. A change that publishes carries a changeset, and both
declaration lines are filled in here and in the changeset.
- [x] 6. `packages/spec` touched: its generated artifacts are
regenerated and committed. (Not touched.)
- [x] 7. No hand edits to `content/docs/references/`,
`packages/*/CHANGELOG.md` or `content/docs/releases/`.
- [x] 8. Title, body and comments are in English.
- [x] The *Before Submitting* list in CONTRIBUTING.md is done.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01QUs3BtuEpqL6CUvaguGLUm)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent 90457e7 commit 698e8d4
12 files changed
Lines changed: 1535 additions & 14 deletions
File tree
- .changeset
- content/docs/permissions
- packages
- mcp/src
- __tests__
- runtime/src
- domains
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
219 | 219 | | |
220 | 220 | | |
221 | 221 | | |
222 | | - | |
| 222 | + | |
223 | 223 | | |
224 | 224 | | |
225 | 225 | | |
| |||
228 | 228 | | |
229 | 229 | | |
230 | 230 | | |
| 231 | + | |
231 | 232 | | |
232 | 233 | | |
233 | 234 | | |
| |||
397 | 398 | | |
398 | 399 | | |
399 | 400 | | |
400 | | - | |
| 401 | + | |
401 | 402 | | |
402 | 403 | | |
403 | 404 | | |
| |||
532 | 533 | | |
533 | 534 | | |
534 | 535 | | |
| 536 | + | |
535 | 537 | | |
536 | 538 | | |
537 | 539 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
33 | 33 | | |
34 | 34 | | |
35 | 35 | | |
| 36 | + | |
| 37 | + | |
36 | 38 | | |
| 39 | + | |
| 40 | + | |
37 | 41 | | |
38 | 42 | | |
39 | 43 | | |
| |||
77 | 81 | | |
78 | 82 | | |
79 | 83 | | |
80 | | - | |
| 84 | + | |
81 | 85 | | |
82 | 86 | | |
83 | 87 | | |
| |||
99 | 103 | | |
100 | 104 | | |
101 | 105 | | |
102 | | - | |
| 106 | + | |
103 | 107 | | |
104 | 108 | | |
105 | 109 | | |
| |||
260 | 264 | | |
261 | 265 | | |
262 | 266 | | |
| 267 | + | |
| 268 | + | |
| 269 | + | |
| 270 | + | |
| 271 | + | |
| 272 | + | |
| 273 | + | |
| 274 | + | |
| 275 | + | |
| 276 | + | |
| 277 | + | |
| 278 | + | |
| 279 | + | |
| 280 | + | |
| 281 | + | |
| 282 | + | |
| 283 | + | |
| 284 | + | |
| 285 | + | |
| 286 | + | |
| 287 | + | |
| 288 | + | |
| 289 | + | |
| 290 | + | |
| 291 | + | |
| 292 | + | |
| 293 | + | |
| 294 | + | |
| 295 | + | |
| 296 | + | |
| 297 | + | |
| 298 | + | |
| 299 | + | |
| 300 | + | |
| 301 | + | |
| 302 | + | |
| 303 | + | |
| 304 | + | |
| 305 | + | |
| 306 | + | |
| 307 | + | |
| 308 | + | |
| 309 | + | |
| 310 | + | |
| 311 | + | |
| 312 | + | |
| 313 | + | |
| 314 | + | |
| 315 | + | |
| 316 | + | |
| 317 | + | |
| 318 | + | |
| 319 | + | |
| 320 | + | |
| 321 | + | |
| 322 | + | |
| 323 | + | |
| 324 | + | |
| 325 | + | |
| 326 | + | |
| 327 | + | |
| 328 | + | |
| 329 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
13 | 13 | | |
14 | 14 | | |
15 | 15 | | |
16 | | - | |
| 16 | + | |
17 | 17 | | |
18 | 18 | | |
19 | 19 | | |
| |||
25 | 25 | | |
26 | 26 | | |
27 | 27 | | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
28 | 41 | | |
29 | 42 | | |
30 | 43 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
524 | 524 | | |
525 | 525 | | |
526 | 526 | | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
527 | 531 | | |
528 | | - | |
| 532 | + | |
529 | 533 | | |
530 | 534 | | |
531 | 535 | | |
| |||
813 | 817 | | |
814 | 818 | | |
815 | 819 | | |
816 | | - | |
| 820 | + | |
| 821 | + | |
| 822 | + | |
| 823 | + | |
| 824 | + | |
| 825 | + | |
| 826 | + | |
| 827 | + | |
817 | 828 | | |
818 | 829 | | |
819 | 830 | | |
| |||
0 commit comments