Skip to content

Commit 12aaf65

Browse files
committed
test(authz): dogfood proof for the MCP tool-execution identity gate (#3167)
Upgrades the `mcp-tool-exec-identity` conformance row from cited-tests-only to a real end-to-end boundary proof (ADR-0096 D4 requires one for an agent/ external-reachable surface). `showcase-mcp-identity.dogfood.test.ts` boots the showcase with MCPServerPlugin registered (via the harness `extraPlugins` — the verify harness boots the plugin set directly, not through the CLI capability push) and asserts on the real in-process HTTP stack: - anonymous POST /mcp (tools/list) → 401 (fail-closed: not 404/501, so the surface IS served and the service IS registered — the denial is identity); - an authenticated member clears the gate (not 401); - GET /mcp/skill → 200 (surface served default-on while tool-exec is gated). Wires it in as the row's `proof:` and adds the row to the test's HIGH_RISK set (so the surface must carry a proof). Adds @objectstack/mcp to the private @objectstack/dogfood dev deps (lockfile updated). Validated locally: `pnpm --filter @objectstack/dogfood test` authz-conformance + showcase-mcp-identity → 10/10 pass. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0155A3k1CMJQb44V4iwTLUcH
1 parent 30c4ddd commit 12aaf65

5 files changed

Lines changed: 91 additions & 1 deletion

File tree

packages/qa/dogfood/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
"@objectstack/connector-rest": "workspace:*",
1515
"@objectstack/example-crm": "workspace:*",
1616
"@objectstack/example-showcase": "workspace:*",
17+
"@objectstack/mcp": "workspace:*",
1718
"@objectstack/objectql": "workspace:*",
1819
"@objectstack/plugin-audit": "workspace:*",
1920
"@objectstack/plugin-auth": "workspace:*",

packages/qa/dogfood/test/authz-conformance.matrix.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
9999
{ id: 'mcp-tool-exec-identity', summary: 'MCP tool execution runs under the caller principal, fail-closed on the default-on /api/v1/mcp surface (#3167 D1, ADR-0096 D4, ADR-0100)', state: 'enforced',
100100
enforcement: 'runtime/http-dispatcher.ts handleMcp — 401 fail-closed unless the request resolves a real principal (ec.userId || ec.isSystem; a guest context has no userId → 401, no dev-admin fallback, no fall-open), then the OAuth scope→tool-family gate (#2698: no MCP scope → 403 insufficient_scope). buildMcpBridge binds every object-CRUD/query/aggregate + action call to the caller ExecutionContext via callData(..., ec) — the same RLS/FLS/permission path REST /data uses; principal resolved by runtime/security/resolve-execution-context.ts (osk_ API-key owner / better-auth session human / OAuth agent-on-behalf-of-human = ADR-0090 D10 ceiling∩user). Business actions additionally gated at invoke time by ai.exposed (#2849) + the ADR-0066 D4 capability gate; bodies run trusted by design (#2964).',
101101
covers: ['mcp:http-dispatcher.ts:handleMcp(identity-fail-closed)', 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'],
102-
note: 'Surface posture: user (caller identity), ENFORCED and live — this is the default-on serve-side surface (unlike the latent graphql-identity-thread / realtime-delivery-authz D4 rows). Answers the ADR-0096 Evidence row "REST/MCP object CRUD — caller ExecutionContext, threaded" as a checked property. Fail-closed 401 unit-proven in runtime/http-dispatcher.mcp.test.ts (anonymous) + http-dispatcher.mcp-oauth.test.ts (scope gate); the principal-bound identity binding + capability + ai.exposed gates are exercised end-to-end against the real buildMcpBridge over JSON-RPC in examples/app-todo/test/mcp-actions.e2e.ts; OAuth tool-family narrowing in packages/mcp mcp-http-tools.scopes.test.ts. A dedicated dogfood-boot proof is the natural M2 follow-up (same latency the graphql D4 row carries). Removing the 401 gate or the bridge threading makes a covers key STALE → red CI.' },
102+
proof: 'showcase-mcp-identity.dogfood.test.ts',
103+
note: 'Surface posture: user (caller identity), ENFORCED and live — this is the default-on serve-side surface (unlike the latent graphql-identity-thread / realtime-delivery-authz D4 rows). Answers the ADR-0096 Evidence row "REST/MCP object CRUD — caller ExecutionContext, threaded" as a checked property. Default-on + fail-closed posture proven on a real showcase boot in the proof (anonymous POST /mcp → 401 not 404/501, an authenticated member clears the gate, public GET /mcp/skill → 200). Also unit-proven in runtime/http-dispatcher.mcp.test.ts (anonymous 401) + http-dispatcher.mcp-oauth.test.ts (scope gate); the principal-bound identity binding + capability + ai.exposed gates are exercised end-to-end against the real buildMcpBridge over JSON-RPC in examples/app-todo/test/mcp-actions.e2e.ts; OAuth tool-family narrowing in packages/mcp mcp-http-tools.scopes.test.ts. Removing the 401 gate or the bridge threading makes a covers key STALE → red CI.' },
103104
{ id: 'mcp-stdio-resource-read', summary: 'stdio MCP resource reads (record_by_id / bridgeResources) run context-less — trusted local process only, NOT on the default HTTP surface (#3167, ADR-0096 E1, ADR-0100 D1/D3)', state: 'experimental',
104105
covers: ['mcp:mcp-server-runtime.ts:record_by_id(stdio-context-less-read)'],
105106
note: 'Surface posture: system (trusted-implicit), opt-in. The persistent MCPServerRuntime.bridgeResources record_by_id template calls dataEngine.findOne with NO ExecutionContext (mcp/src/mcp-server-runtime.ts) → the plugin-security empty-principal skip = full-authority cross-tenant read (ADR-0096 Evidence E1). Reachable ONLY over the stdio transport, which the dispatcher NEVER exposes on the network and which auto-starts only under explicit opt-in (MCPServerPlugin autoStart / OS_MCP_SERVER_ENABLED=true). The default-on surface is HTTP-only and builds a fresh per-request McpServer with ONLY the principal-bound object/action tools (buildMcpBridge) — never these resources. NOT patched with an ad-hoc { isSystem: true } literal (ADR-0096 D2 retires those; the systemContext(reason) constructor does not exist yet). ADMISSION REQUIREMENT before stdio is ever networked or default-wired: thread the caller/session ExecutionContext (or an explicit ADR-0096 D2 grant) into the resource reads. The probe on dataEngine.findOne( keeps this classified so the fall-open cannot silently ride onto a networked transport; ADR-0100 D3 keeps stdio explicit-opt-in for exactly this reason.' },

packages/qa/dogfood/test/authz-conformance.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,9 @@ const HIGH_RISK = [
167167
// is false compliance (declared ≠ enforced) and approval/status columns are
168168
// one direct PATCH away from self-approval.
169169
'readonly-static-write',
170+
// #3167 — the MCP tool-execution surface is agent/external-reachable and
171+
// default-on, so it must carry an end-to-end boundary proof (ADR-0096 D4).
172+
'mcp-tool-exec-identity',
170173
];
171174

172175
describe('ADR-0056 D10 — authorization conformance matrix', () => {
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #3167 / ADR-0096 D4 / ADR-0100 — the serve-side MCP tool-execution surface
4+
// (@objectstack/mcp) is DEFAULT-ON and agent/external-reachable, so its identity
5+
// posture is a checked property (the `mcp-tool-exec-identity` authz-conformance
6+
// row), not incidental prose. The matrix row + source probes pin the code; this
7+
// proof pins the runtime BEHAVIOUR on a real showcase boot: the /api/v1/mcp
8+
// surface is served by default, yet tool execution is FAIL-CLOSED on identity —
9+
// a principal-less caller gets 401, never a fall-open. The MCPServerPlugin is
10+
// registered via `extraPlugins` because the verify harness boots the plugin set
11+
// directly (it does not run the CLI capability push at serve.ts that wires MCP
12+
// in `os dev`/`serve`); registering it here reproduces the default-on surface.
13+
//
14+
// The authenticated principal-bound execution path (tools run AS the caller's
15+
// ExecutionContext through buildMcpBridge, RLS/FLS-bounded, with the ai.exposed
16+
// + ADR-0066 D4 capability action gates) is exercised end-to-end against the
17+
// real bridge in examples/app-todo/test/mcp-actions.e2e.ts; here we assert the
18+
// complementary property a real HTTP boot uniquely shows — the fail-closed gate
19+
// and the default-on service resolution.
20+
21+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
22+
import showcaseStack from '@objectstack/example-showcase';
23+
import { bootStack, type VerifyStack } from '@objectstack/verify';
24+
import { MCPServerPlugin } from '@objectstack/mcp';
25+
26+
const MCP = '/mcp';
27+
const TOOLS_LIST = { jsonrpc: '2.0', id: 1, method: 'tools/list' } as const;
28+
const MCP_HEADERS = {
29+
'content-type': 'application/json',
30+
// The Streamable-HTTP transport advertises both JSON and SSE responses.
31+
accept: 'application/json, text/event-stream',
32+
} as const;
33+
34+
describe('showcase: serve-side MCP tool execution is default-on and fail-closed on identity (#3167 / ADR-0100)', () => {
35+
let stack: VerifyStack;
36+
let memberToken: string;
37+
38+
beforeAll(async () => {
39+
// Platform default (deny anonymous) + the default-on MCP surface registered
40+
// exactly as `os dev`/`serve` would via the isMcpServerEnabled() capability push.
41+
stack = await bootStack(showcaseStack, { extraPlugins: [new MCPServerPlugin()] });
42+
await stack.signIn(); // seed the dev admin (first user)
43+
memberToken = await stack.signUp('mcp-identity-member@verify.test');
44+
}, 60_000);
45+
46+
afterAll(async () => {
47+
await stack?.stop();
48+
});
49+
50+
// ── The gate: no principal → no tool execution (fail-closed, not fall-open) ──
51+
it('anonymous POST /mcp (tools/list) is denied 401 — no dev-admin fallback, no fall-open', async () => {
52+
const r = await stack.api(MCP, {
53+
method: 'POST',
54+
headers: MCP_HEADERS,
55+
body: JSON.stringify(TOOLS_LIST),
56+
});
57+
// 401 (not 404/501): the surface IS served and the service IS registered —
58+
// the denial is the identity gate, exactly what mcp-tool-exec-identity pins.
59+
expect(r.status, 'a principal-less MCP tool call must be 401').toBe(401);
60+
});
61+
62+
it('an authenticated member clears the MCP identity gate (not 401)', async () => {
63+
// Past the gate the transport may 200/400/406 depending on the exact MCP
64+
// handshake — the point is it is NOT the anonymous 401. Mirrors the #2567
65+
// GraphQL "authenticated clears the gate" assertion.
66+
const r = await stack.api(MCP, {
67+
method: 'POST',
68+
headers: { ...MCP_HEADERS, Authorization: `Bearer ${memberToken}` },
69+
body: JSON.stringify(TOOLS_LIST),
70+
});
71+
expect(r.status, 'an authenticated MCP caller must clear the 401 identity gate').not.toBe(401);
72+
});
73+
74+
// ── Default-on: the public SKILL.md download proves the surface is advertised
75+
// even though tool execution is gated (isMcpServerEnabled() default true). ──
76+
it('GET /mcp/skill is served publicly (surface is default-on)', async () => {
77+
const r = await stack.api(`${MCP}/skill`, { method: 'GET' });
78+
expect(r.status, 'the public MCP SKILL.md must be served (default-on surface)').toBe(200);
79+
const body = await r.text();
80+
expect(body.length, 'SKILL.md must have content').toBeGreaterThan(0);
81+
});
82+
});

pnpm-lock.yaml

Lines changed: 3 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)