Skip to content

Commit f7b4267

Browse files
committed
test(authz): e2e-prove the MCP HTTP surface is identity-admitted; upgrade the matrix row to high-risk (#3167)
PR-B of #3167. During implementation the premise shifted: the CLI ALREADY auto-loads MCPServerPlugin in `os serve`/`dev` when isMcpServerEnabled() (serve.ts — requires.push('mcp')), on the SAME decision point that gates the dispatcher /mcp route, with stdio staying opt-in. So decisions A/B/C (dev default-on, HTTP-only, unified off-switch) were already shipped — my #3167 "wired nowhere" claim was a stale read (a grep of the wrong path). The one genuinely-missing piece was the end-to-end proof the mcp-http-identity row (#3202) explicitly deferred. This lands that proof: showcase-mcp-http-identity.dogfood.test.ts boots the real showcase + security + MCPServerPlugin and drives POST /api/v1/mcp through the real HTTP + RLS stack: - an anonymous MCP tools/call is 401 before any tool runs (fail-closed); - a member's query_records over the owner-private showcase_private_note returns ONLY their own rows, symmetric across two members — if the tool had run unscoped/system (the stdio posture, mcp-stdio-authority) the other owner's rows would leak, so this is the load-bearing RLS-through-MCP assertion; - tool discovery (tools/list) is gated on the same admitted identity. The mcp-http-identity matrix row is upgraded to HIGH_RISK with this proof, closing the deferral noted on it in #3202. Dispatcher-level unit coverage (http-dispatcher.mcp.test.ts 401 + EC-to-bridge; .mcp-oauth.test.ts scope 403) already existed; this adds the whole-stack authorization outcome the unit mocks cannot show. Test-only + a dev-dep on @objectstack/mcp in the private @objectstack/dogfood gate — no runtime change, no changeset. authz-conformance + the new proof: 13/13 green locally. Refs #3167, ADR-0096, ADR-0056 D10, #3202, #2698. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pbmw3pMqNJfPbkvwh9Fkjs
1 parent 2f3c641 commit f7b4267

5 files changed

Lines changed: 121 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
@@ -98,7 +98,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
9898
{ id: 'mcp-http-identity', summary: 'MCP HTTP surface (/api/v1/mcp) admits the caller identity — anonymous denied, OAuth scope-gated, caller ExecutionContext threaded to every tool\'s data op', state: 'enforced',
9999
enforcement: 'runtime/http-dispatcher.ts handleMcp — requires ec.userId||ec.isSystem (401 else, RFC 9728 WWW-Authenticate advertised when the OAuth track is live); OAuth-token provenance narrows the exposed tool families to the granted MCP scopes (403 on none, #2698); buildMcpBridge(context) threads the caller ExecutionContext into every bridge op (callData(..., ec)), and mcp-server-runtime.ts handleHttpRequest builds a fresh per-request McpServer from that principal-bound bridge (registerObjectTools/registerActionTools) — so RLS / FLS / tenant apply exactly as on REST /data',
100100
covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'],
101-
note: 'The per-request principal-bound tool server is isolated from the long-lived UNSCOPED stdio server (see mcp-stdio-authority). NOT marked high-risk here only because its end-to-end dogfood proof (anonymous → 401; principal → RLS/FLS-scoped tool results) lands with the dev-default-wiring follow-up (#3167 PR-B), not because the surface is latent — it is a live route. Dropping the buildMcpBridge(context) threading (or building an unscoped/system bridge for HTTP) makes the context-threaded key STALE → red CI; a new sibling MCP data handler appears as an UNCLASSIFIED surface until a row covers it.' },
101+
proof: 'showcase-mcp-http-identity.dogfood.test.ts',
102+
note: 'The per-request principal-bound tool server is isolated from the long-lived UNSCOPED stdio server (see mcp-stdio-authority). HIGH-RISK, proven end-to-end (#3167 PR-B): the proof boots the real showcase + security + MCP plugin and drives POST /api/v1/mcp — an anonymous tools/call is 401 before any tool runs, and a member\'s query_records over the owner-private showcase_private_note returns ONLY their own rows (if the tool ran unscoped/system — the stdio posture — the other owner\'s rows would leak). Dropping the buildMcpBridge(context) threading (or building an unscoped/system bridge for HTTP) makes the context-threaded key STALE → red CI; a new sibling MCP data handler appears as an UNCLASSIFIED surface until a row covers it. Dispatcher-level unit coverage: http-dispatcher.mcp.test.ts (401, EC-to-bridge) + http-dispatcher.mcp-oauth.test.ts (scope 403).' },
102103
{ id: 'mcp-stdio-authority', summary: 'MCP stdio transport runs UNSCOPED — the long-lived server bridges the raw metadata service + data engine with no per-request principal (opt-in: autoStart / OS_MCP_SERVER_ENABLED=true)', state: 'experimental',
103104
covers: ['mcp:plugin.ts:bridgeResources(unscoped-stdio)'],
104105
note: 'Surface posture: process-authority, opt-in. Unlike the HTTP path (mcp-http-identity), MCPServerPlugin.start() bridges resources/tools onto the long-lived this.mcpServer from the RAW metadata service + data engine (bridgeResources(metadataService, dataEngine)) — there is no ExecutionContext, so a stdio-attached client reads metadata + records with full, unscoped authority (no RLS/FLS/tenant). This is safe ONLY as a single-operator LOCAL tool: the operator who can attach stdio already owns the process and its dev database, so the transport grants nothing they lack. It is deliberately NOT default (the shouldStart gate in plugin.ts keeps stdio opt-in, stricter than the default-on HTTP surface). ADMISSION REQUIREMENT before stdio is ever promoted to default-on OR served in a multi-user / hosted context: thread a principal (a configured service identity, or a per-session ExecutionContext) into the long-lived bridge, mirroring the HTTP path\'s buildMcpBridge. Changing the raw-service bridging makes the unscoped-stdio key STALE → forces re-classification in CI.' },

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,10 @@ const HIGH_RISK = [
164164
// is false compliance (declared ≠ enforced) and approval/status columns are
165165
// one direct PATCH away from self-approval.
166166
'readonly-static-write',
167+
// #3167 — the MCP HTTP surface guards the same object data as REST /data
168+
// through a sibling execution surface (tool dispatch); proven e2e that a
169+
// member's MCP tools/call is RLS-scoped and anonymous is denied.
170+
'mcp-http-identity',
167171
];
168172

169173
describe('ADR-0056 D10 — authorization conformance matrix', () => {
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// ADR-0096 / #3167 — the MCP HTTP surface (/api/v1/mcp) is an identity-admitted
4+
// execution surface, proven END-TO-END through the real showcase + security +
5+
// MCP stack. This is the e2e proof the `mcp-http-identity` matrix row deferred
6+
// (#3202): the dispatcher unit tests prove handleMcp passes the caller EC to the
7+
// bridge; THIS proves the whole chain — an MCP `tools/call` runs under the
8+
// caller's RLS, so a member sees only their own rows, and an anonymous caller is
9+
// denied before any tool executes.
10+
//
11+
// The target object is `showcase_private_note` (OWD `private`, owner-only) — the
12+
// same object the declarative-OWD proof uses. Owner isolation is enforced by the
13+
// engine, so if the MCP tool ran unscoped (the stdio posture, mcp-stdio-authority)
14+
// this test would see cross-owner rows and FAIL.
15+
16+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
17+
import showcaseStack from '@objectstack/example-showcase';
18+
import { bootStack, type VerifyStack } from '@objectstack/verify';
19+
import { MCPServerPlugin } from '@objectstack/mcp';
20+
21+
const OBJ = '/data/showcase_private_note';
22+
const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId;
23+
24+
/** A JSON-RPC MCP request over Streamable-HTTP (JSON response mode). */
25+
function mcpBody(method: string, params?: unknown, id = 1) {
26+
return { jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) };
27+
}
28+
29+
describe('showcase: MCP HTTP surface is identity-admitted (ADR-0096 / #3167)', () => {
30+
let stack: VerifyStack;
31+
let aliceToken: string;
32+
let bobToken: string;
33+
34+
/** POST /api/v1/mcp with the Streamable-HTTP Accept header; `token` null = anonymous. */
35+
const mcp = (token: string | null, body: unknown) =>
36+
stack.api('/mcp', {
37+
method: 'POST',
38+
headers: {
39+
'Content-Type': 'application/json',
40+
// The transport requires the client to accept both content types.
41+
Accept: 'application/json, text/event-stream',
42+
...(token ? { Authorization: `Bearer ${token}` } : {}),
43+
},
44+
body: JSON.stringify(body),
45+
});
46+
47+
/** Call an MCP tool and return the parsed JSON its text-content carries. */
48+
async function callTool(token: string, name: string, args: Record<string, unknown>): Promise<any> {
49+
const res = await mcp(token, mcpBody('tools/call', { name, arguments: args }));
50+
expect(res.status, `tools/call ${name}: ${res.status} ${await res.clone().text()}`).toBe(200);
51+
const rpc: any = await res.json();
52+
expect(rpc.error, `tools/call ${name} JSON-RPC error: ${JSON.stringify(rpc.error)}`).toBeUndefined();
53+
const text = rpc.result?.content?.[0]?.text;
54+
expect(typeof text, 'tool result carries text content').toBe('string');
55+
return JSON.parse(text);
56+
}
57+
58+
const titlesOf = (queryResult: any): string[] =>
59+
(queryResult.records ?? queryResult.data ?? queryResult.rows ?? []).map((r: any) => r.title);
60+
61+
beforeAll(async () => {
62+
// The MCP plugin registers the `'mcp'` service the dispatcher's /mcp route
63+
// needs (in production `os serve`/`dev` auto-load it via isMcpServerEnabled;
64+
// bootStack's lean harness injects it explicitly). isMcpServerEnabled() is
65+
// default-on, so the route is live.
66+
stack = await bootStack(showcaseStack, { extraPlugins: [new MCPServerPlugin()] });
67+
await stack.signIn(); // seed dev admin (first user)
68+
aliceToken = await stack.signUp('mcp-alice@verify.test');
69+
bobToken = await stack.signUp('mcp-bob@verify.test');
70+
71+
const a = await stack.apiAs(aliceToken, 'POST', OBJ, { title: 'Alice MCP note' });
72+
expect(a.status, 'alice creates note').toBeLessThan(300);
73+
expect(idOf(await a.json()), 'alice note id').toBeTruthy();
74+
const b = await stack.apiAs(bobToken, 'POST', OBJ, { title: 'Bob MCP note' });
75+
expect(b.status, 'bob creates note').toBeLessThan(300);
76+
}, 60_000);
77+
78+
afterAll(async () => {
79+
await stack?.stop();
80+
});
81+
82+
it('denies an anonymous MCP request before any tool runs (fail-closed, 401)', async () => {
83+
const res = await mcp(null, mcpBody('tools/call', { name: 'query_records', arguments: { objectName: 'showcase_private_note' } }));
84+
expect(res.status).toBe(401);
85+
});
86+
87+
it('runs a tools/call under the CALLER identity — a member sees only their own rows (RLS through MCP)', async () => {
88+
const result = await callTool(aliceToken, 'query_records', { objectName: 'showcase_private_note' });
89+
const titles = titlesOf(result);
90+
expect(titles, `alice via MCP saw: ${JSON.stringify(titles)}`).toContain('Alice MCP note');
91+
// The load-bearing assertion: if the tool ran unscoped/system (the stdio
92+
// posture), Bob's note would be here. Owner-RLS through the MCP bridge hides it.
93+
expect(titles).not.toContain('Bob MCP note');
94+
});
95+
96+
it('is symmetric — the other member sees only their own row', async () => {
97+
const result = await callTool(bobToken, 'query_records', { objectName: 'showcase_private_note' });
98+
const titles = titlesOf(result);
99+
expect(titles).toContain('Bob MCP note');
100+
expect(titles).not.toContain('Alice MCP note');
101+
});
102+
103+
it('the caller identity also gates tool discovery — an authed member gets a tool list', async () => {
104+
const res = await mcp(aliceToken, mcpBody('tools/list'));
105+
expect(res.status).toBe(200);
106+
const rpc: any = await res.json();
107+
const names: string[] = (rpc.result?.tools ?? []).map((t: any) => t.name);
108+
expect(names).toContain('query_records');
109+
expect(names).toContain('describe_object');
110+
});
111+
});

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)