Skip to content

Commit 698e8d4

Browse files
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

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@objectstack/mcp': patch
3+
'@objectstack/runtime': patch
4+
---
5+
6+
feat(mcp): a host can observe and refuse every MCP tool call through one neutral interceptor on the `mcp` service
7+
8+
Clause-②: yes (widening) — `MCPServerRuntime` gains `setToolInterceptor`, `clearToolInterceptor` and `hasToolInterceptor`, `handleHttpRequest` gains an optional `caller`, and `@objectstack/mcp` exports the types `McpToolInterceptor`, `McpToolCallContext`, `McpToolCallPrincipal`, `McpToolCallRefusal`, `McpToolCallResult`, `McpToolTransport`, `McpPrincipalKind`, `McpCallerExecutionContext` and `McpHttpCaller`. With no interceptor installed, every tool call answers exactly as before.
9+
Metadata-protocol: none
10+
11+
- **`@objectstack/mcp`:** `runtime.setToolInterceptor(fn)` installs one interceptor (a single slot, read at each call) around every tool call on both transports: the object, action, file and federation tools, the host-injected authoring family, the bridged AI tools, and any tool a plugin registers on the runtime's server. The interceptor receives a frozen context built on the server side — `door: 'mcp'`, `transport` (`http` or `stdio`), the tool name, a copy of the validated arguments, and the principal (`userId`, `principalKind`, `onBehalfOf`, `performedBy.clientId`, `organizationId`, `environmentId`, and `isSystem` for a system caller) projected from the host's resolved `ExecutionContext`, never from a request header. It allows by calling `next()` (and may return a replacement result), or denies by returning `{ outcome: 'deny', code, message, status }`, which the client receives in the coded-refusal envelope every MCP tool error uses. A throw from the interceptor refuses the call the same way; any other verdict is refused as `500 INTERNAL_ERROR`. The package holds no policy: audit, seat checks and kill switches belong to whichever host installs the interceptor.
12+
- **`@objectstack/mcp`, stdio:** once the plugin has resolved `OS_MCP_STDIO_API_KEY`, the AI tools it bridges from the AI service's tool registry run with that key's user as their execution context (`actor`: id, positions, permissions), resolved per call, so a registry that needs an actor no longer refuses them as unauthenticated, and a key that stops resolving refuses the next call. A runtime with no stdio principal bound calls the registry exactly as before.
13+
- **`@objectstack/runtime`:** the `/mcp` dispatcher passes the request's resolved `ExecutionContext` and the environment it dispatched for to `handleHttpRequest` as `caller`, the input an interceptor's principal is projected from.

‎content/docs/permissions/system-context.mdx‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,7 @@ One of the largest consumers of the flag;
219219

220220
### 6. Reads that only carry the flag onward
221221

222-
Not behaviours: these three sites read the flag solely to copy it into another
222+
Not behaviours: these four sites read the flag solely to copy it into another
223223
context or envelope. They are listed because the census counts them, and because
224224
a reader tracing where elevation travels needs them.
225225

@@ -228,6 +228,7 @@ a reader tracing where elevation travels needs them.
228228
| 63 | `buildSession` | objectql | Propagates `isSystem` into the hook session so hooks can tell engine self-writes from user writes |
229229
| 64 | `isSystem` | objectql | `ScopedContext.isSystem` getter — re-exposes the underlying execution context's flag |
230230
| 65 | `executionContextFromHook` | runtime | Rebuilds an `ExecutionContext` from a hook session, carrying the flag across |
231+
| 66 | `toolCallPrincipalOf` | mcp | Projects the host's resolved `ExecutionContext` into the frozen principal an installed MCP tool-call interceptor reads, carrying `isSystem: true` only for a system caller. It decides nothing: the tool still runs under its own context |
231232

232233
---
233234

@@ -397,7 +398,7 @@ restates none of its figures:
397398
| Of those reads: reads of one of the unrelated metadata fields | subtracted — the collision this page opens with |
398399
| Of those reads: reads of `ExecutionContext.isSystem` | **the census answer**: every one sits in a symbol a row above names |
399400
| — behaviour-bearing (rows 1–62 above) | sections 1 to 5 |
400-
| — carry the flag onward only (rows 63–65 above) | section 6 |
401+
| — carry the flag onward only (rows 63–66 above) | section 6 |
401402

402403
`node scripts/isystem-census.mjs` prints how many appearances fell in each
403404
syntactic slot, the subtraction, the answer and the packages and files that
@@ -532,6 +533,7 @@ and CI refuses one that differs from what the tree produces.
532533
|:---|--:|:---|
533534
| `packages/core/src/security/anonymous-deny.ts#shouldDenyAnonymous` | 1 | 10 |
534535
| `packages/mcp/src/stdio-data-bridge.ts#enforceApiExposure` | 1 | 15 |
536+
| `packages/mcp/src/tool-call-interceptor.ts#toolCallPrincipalOf` | 2 | 66 |
535537
| `packages/metadata-core/src/meta-write-capability.ts#metaWriteCapabilityVerdict` | 1 | 51 |
536538
| `packages/metadata-core/src/meta-write-capability.ts#walledCatalogWriteVerdict` | 1 | 55b |
537539
| `packages/metadata-core/src/object-schema-fls.ts#isObjectSchemaMaskExempt` | 1 | 8 |

‎packages/mcp/src/__tests__/plugin-execution-context.test.ts‎

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@
3333
// `accessToken: authz.accessToken`.
3434

3535
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
36+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
37+
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js';
3638
import type { ExecutionContext } from '@objectstack/spec/kernel';
39+
import type { AIToolDefinition, ToolCallPart, ToolExecutionContext } from '@objectstack/spec/contracts';
40+
import type { McpToolCallContext } from '../types.js';
3741

3842
/** The resolved envelope the API key stands for — the input this face converts. */
3943
const AUTHZ = {
@@ -77,7 +81,7 @@ interface FindCall {
7781
options: { context?: ExecutionContext; where?: unknown; limit?: number };
7882
}
7983

80-
function createHarness() {
84+
function createHarness(extraServices: Record<string, unknown> = {}) {
8185
const finds: FindCall[] = [];
8286
const ql = {
8387
find: vi.fn(async (object: string, options: FindCall['options']) => {
@@ -99,7 +103,7 @@ function createHarness() {
99103
unregister: vi.fn(),
100104
};
101105
const settings = { get: vi.fn(async () => undefined) };
102-
const services: Record<string, unknown> = { objectql: ql, metadata, settings };
106+
const services: Record<string, unknown> = { objectql: ql, metadata, settings, ...extraServices };
103107
const ctx = {
104108
registerService: vi.fn(),
105109
getService: vi.fn((name: string) => {
@@ -260,3 +264,66 @@ describe('#7279 — stdio ExecutionContext, assembled by the shared assembler',
260264
await expect(getRecord('crm_account', 'r_1')).rejects.toThrow(/no longer valid/);
261265
});
262266
});
267+
268+
describe('[#31605] the stdio principal reaches the bridged AI tools and the tool-call interceptor', () => {
269+
const originalEnv = { ...process.env };
270+
const AI_TOOL: AIToolDefinition = {
271+
name: 'summarize_pipeline',
272+
description: 'Summarize the open pipeline.',
273+
parameters: { type: 'object', properties: {} },
274+
};
275+
276+
beforeEach(() => {
277+
process.env = { ...originalEnv };
278+
process.env.OS_MCP_STDIO_API_KEY = 'osk_test';
279+
resolveAuthzContextMock.mockClear();
280+
});
281+
282+
afterEach(() => {
283+
process.env = { ...originalEnv };
284+
});
285+
286+
it('a bridged tool runs as the key\'s user, and an interceptor is told that user, re-resolved per call', async () => {
287+
const executed: Array<{ call: ToolCallPart; context?: ToolExecutionContext }> = [];
288+
const toolRegistry = {
289+
getAll: () => [AI_TOOL],
290+
async execute(call: ToolCallPart, context?: ToolExecutionContext) {
291+
executed.push({ call, context });
292+
return { type: 'tool-result', toolCallId: call.toolCallId, toolName: call.toolName, output: { type: 'text', value: 'ok' } };
293+
},
294+
};
295+
const { ctx } = createHarness({ ai: { toolRegistry } });
296+
await startStdio(ctx);
297+
const runtime = (ctx.registerService.mock.calls.find((c: unknown[]) => c[0] === 'mcp') as unknown[])[1] as InstanceType<
298+
typeof MCPServerRuntime
299+
>;
300+
const seen: McpToolCallContext[] = [];
301+
runtime.setToolInterceptor(async (call, next) => {
302+
seen.push(call);
303+
return next();
304+
});
305+
306+
const [clientSide, serverSide] = InMemoryTransport.createLinkedPair();
307+
await runtime.server.connect(serverSide);
308+
const client = new Client({ name: 'stdio-principal-pin', version: '0.0.0' });
309+
await client.connect(clientSide);
310+
const before = resolveAuthzContextMock.mock.calls.length;
311+
try {
312+
const answer = await client.callTool({ name: 'summarize_pipeline', arguments: {} });
313+
expect(answer.isError).toBeFalsy();
314+
} finally {
315+
await client.close();
316+
}
317+
318+
expect(executed).toHaveLength(1);
319+
expect(executed[0]!.context).toEqual({
320+
actor: { id: 'u_stdio', positions: ['sales_rep'], permissions: ['records:read'] },
321+
});
322+
expect(seen).toHaveLength(1);
323+
expect(seen[0]).toMatchObject({ door: 'mcp', transport: 'stdio', toolName: 'summarize_pipeline' });
324+
expect(seen[0]!.principal).toEqual({ userId: 'u_stdio', principalKind: 'human', organizationId: 'org_stdio' });
325+
// Once for the interceptor's principal, once for the tool's context: the
326+
// identity is never cached across calls (ADR-0101 D1).
327+
expect(resolveAuthzContextMock.mock.calls.length - before).toBe(2);
328+
});
329+
});

‎packages/mcp/src/index.ts‎

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
export { MCPServerPlugin } from './plugin.js';
1414
export type { MCPServerPluginOptions } from './plugin.js';
1515
export { MCPServerRuntime } from './mcp-server-runtime.js';
16-
export type { MCPServerRuntimeConfig } from './mcp-server-runtime.js';
16+
export type { MCPServerRuntimeConfig, McpHttpCaller } from './mcp-server-runtime.js';
1717
// [#26464] The host-injected authoring bridge behind `metadata:write`: the
1818
// shapes a host passes to `handleHttpRequest` (`authoring`) and the door
1919
// constant its access check branches on.
@@ -25,6 +25,19 @@ export type {
2525
McpAuthoringToolRegistry,
2626
McpAuthoringInjection,
2727
} from './types.js';
28+
// [#31605] The host-installed tool-call interceptor
29+
// (`MCPServerRuntime.setToolInterceptor`): what it is told about each call,
30+
// and the verdicts it may return.
31+
export type {
32+
McpToolInterceptor,
33+
McpToolCallContext,
34+
McpToolCallPrincipal,
35+
McpToolCallRefusal,
36+
McpToolCallResult,
37+
McpToolTransport,
38+
McpPrincipalKind,
39+
} from './types.js';
40+
export type { McpCallerExecutionContext } from './tool-call-interceptor.js';
2841
export {
2942
registerObjectTools,
3043
registerActionTools,

‎packages/mcp/src/mcp-http-tools.ts‎

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -524,8 +524,12 @@ function errorResultFromThrown(err: unknown, request: string, objectName?: strin
524524
* The runtime dispatcher drops those `details` from the wire, so serving them
525525
* here would disclose on MCP what REST withholds. `code`, `message` and
526526
* `status` are what every boundary serves.
527+
*
528+
* [#31605] Exported for the tool-call interceptor, which answers a throw from
529+
* a host's interceptor as the tools' own catches answer one; not on the
530+
* package entry.
527531
*/
528-
function errorResultWithCode(err: unknown) {
532+
export function errorResultWithCode(err: unknown) {
529533
const envelope = thrownRefusalEnvelope(err);
530534
return envelope ? codedErrorResult({ ...envelope }) : errorResult(messageOf(err));
531535
}
@@ -813,7 +817,14 @@ function landedWriteResultOrError(
813817
return textResult(earned);
814818
}
815819

816-
function codedErrorResult(error: {
820+
/**
821+
* The coded-refusal envelope every MCP tool error with a code answers with:
822+
* `isError: true`, text `{ "error": { code, message, status, … } }`.
823+
* [#31605] Exported for the tool-call interceptor's refusal
824+
* (`tool-call-interceptor.ts`), so a host's deny reaches the client in the
825+
* envelope the tools' own refusals use; not on the package entry.
826+
*/
827+
export function codedErrorResult(error: {
817828
code?: string;
818829
message: string;
819830
status?: number;

0 commit comments

Comments
 (0)