Skip to content

Commit 6ba2977

Browse files
committed
test(authz): e2e-prove MCP HTTP identity admission — anonymous→401, RLS-scoped == REST (#3167)
The `mcp-http-identity` conformance row was pinned only STATICALLY (source probes: handleMcp requires a principal; buildMcpBridge(context) threads the caller EC) and its own note deferred the end-to-end proof to the dev-default-wiring follow-up. This lands that runtime proof. mcp-http-identity.dogfood.test.ts boots MCPServerPlugin default-on exactly as `os dev`/`serve` wire it (isMcpServerEnabled → requires.push('mcp') → `new MCPServerPlugin()`) over the owner-scoped cbp_account fixture, then drives POST /api/v1/mcp to prove: - anonymous tools/call → 401 (no principal admitted); - an admitted member's query_records/get_record are RLS-scoped through the threaded caller ExecutionContext — own rows only, never the admin's — and the MCP id-set equals REST /data for the same principal (one admission); - by-id get_record of a row the member cannot see is denied. Flips the matrix row from "proof deferred to PR-B" to proof: mcp-http-identity.dogfood.test.ts. Test-only (private @objectstack/ dogfood gate); @objectstack/mcp added as a dogfood dep. No runtime change. Refs #3167, ADR-0096. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0115eg8dAaCfWaDYYAm3ma36
1 parent efbcfe1 commit 6ba2977

4 files changed

Lines changed: 166 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: '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). End-to-end proven in mcp-http-identity.dogfood.test.ts (#3167): booting MCPServerPlugin default-on exactly as os dev/serve wire it, then driving POST /api/v1/mcp — anonymous tools/call → 401, and an admitted member\'s query_records/get_record are RLS-scoped through the threaded caller EC, with the MCP id-set equal to REST /data for the same principal. 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.' },
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.' },
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// MCP HTTP execution-surface identity admission — the RUNTIME proof the
4+
// `mcp-http-identity` row (authz-conformance.matrix.ts, ADR-0096 / #3167)
5+
// deferred to "PR-B". That row pins the admission STATICALLY (source probes:
6+
// `handleMcp` requires a principal; `buildMcpBridge(context)` threads the caller
7+
// ExecutionContext). This is the end-to-end complement its own note promised.
8+
//
9+
// The boot mirrors `os dev` / `serve` exactly: MCPServerPlugin is wired
10+
// default-on — `isMcpServerEnabled()` (default true) → `requires.push('mcp')` →
11+
// `new MCPServerPlugin()` (packages/cli/src/commands/serve.ts). Nothing sets a
12+
// route explicitly; the default wiring is what yields a connectable endpoint.
13+
//
14+
// It then drives POST /api/v1/mcp to prove the two claims the note owes:
15+
// • anonymous `tools/call` → 401 (handleMcp: no `ec.userId && !ec.isSystem`);
16+
// • an admitted principal's tool results are RLS-scoped through the threaded
17+
// caller EC, IDENTICALLY to REST /data — a member sees only their own
18+
// owner-scoped rows via `query_records`, never the admin's, the MCP id-set
19+
// equals the REST id-set for the same token, and a by-id `get_record` of a
20+
// row the member cannot see is denied.
21+
//
22+
// Fixture: the owner-scoped `cbp_account` master (RLS.ownerPolicy('cbp_account',
23+
// 'created_by')) reused from controlled-by-parent — a member is walled to their
24+
// own account, so the scoping actually bites rather than passing vacuously.
25+
26+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
27+
import { bootStack, type VerifyStack } from '@objectstack/verify';
28+
import { MCPServerPlugin } from '@objectstack/mcp';
29+
import { cbpStack, cbpSecurity } from './fixtures/cbp-fixture.js';
30+
31+
// Relative to /api/v1 — `stack.api` prefixes it, matching `handleMcp`'s route.
32+
const MCP_PATH = '/mcp';
33+
// The Streamable-HTTP transport 406s unless the client accepts BOTH media types.
34+
const MCP_ACCEPT = 'application/json, text/event-stream';
35+
36+
let rpcId = 0;
37+
function toolsCall(name: string, args: Record<string, unknown>) {
38+
return { jsonrpc: '2.0', id: ++rpcId, method: 'tools/call', params: { name, arguments: args } };
39+
}
40+
41+
/** POST a JSON-RPC body to /api/v1/mcp, optionally as a bearer principal. */
42+
function mcpPost(stack: VerifyStack, body: unknown, token?: string): Promise<Response> {
43+
return stack.api(MCP_PATH, {
44+
method: 'POST',
45+
headers: {
46+
'Content-Type': 'application/json',
47+
accept: MCP_ACCEPT,
48+
...(token ? { Authorization: `Bearer ${token}` } : {}),
49+
},
50+
body: JSON.stringify(body),
51+
});
52+
}
53+
54+
/** The `content[0].text` payload of a non-error tool result. */
55+
async function toolPayload(res: Response): Promise<any> {
56+
expect(res.status, `MCP call expected 200, got ${res.status}`).toBe(200);
57+
const env = (await res.json()) as any;
58+
expect(
59+
env.result?.isError,
60+
`unexpected MCP tool error: ${JSON.stringify(env.result?.content ?? env.error)}`,
61+
).toBeFalsy();
62+
return JSON.parse(env.result.content[0].text);
63+
}
64+
65+
/** Record-id set from a `query_records` tool result. */
66+
async function mcpQueryIds(res: Response): Promise<Set<string>> {
67+
const payload = await toolPayload(res);
68+
const records = Array.isArray(payload.records) ? payload.records : [];
69+
return new Set(records.map((r: any) => String(r.id)));
70+
}
71+
72+
function idOf(j: any): string {
73+
const id = j?.id ?? j?.record?.id;
74+
expect(id, 'expected an id from create').toBeTruthy();
75+
return id as string;
76+
}
77+
78+
describe('MCP HTTP surface — execution-surface identity admission (ADR-0096 / #3167)', () => {
79+
let stack: VerifyStack;
80+
let adminToken: string;
81+
let memberToken: string;
82+
let adminAccountId: string;
83+
let memberAccountId: string;
84+
85+
beforeAll(async () => {
86+
// MCPServerPlugin registered default-on, exactly as serve.ts auto-loads it.
87+
stack = await bootStack(cbpStack, {
88+
security: cbpSecurity(),
89+
extraPlugins: [new MCPServerPlugin()],
90+
});
91+
adminToken = await stack.signIn();
92+
memberToken = await stack.signUp('mcp-identity-member@verify.test');
93+
94+
const adminAcc = await stack.apiAs(adminToken, 'POST', '/data/cbp_account', { name: 'admin account' });
95+
expect(adminAcc.status, `admin account create: ${adminAcc.status} ${await adminAcc.clone().text()}`).toBeLessThan(300);
96+
adminAccountId = idOf(await adminAcc.json());
97+
98+
const memberAcc = await stack.apiAs(memberToken, 'POST', '/data/cbp_account', { name: 'member account' });
99+
expect(memberAcc.status, `member account create: ${memberAcc.status} ${await memberAcc.clone().text()}`).toBeLessThan(300);
100+
memberAccountId = idOf(await memberAcc.json());
101+
}, 60_000);
102+
103+
afterAll(async () => {
104+
await stack?.stop();
105+
});
106+
107+
it('anonymous tools/call → 401 (no principal admitted)', async () => {
108+
const res = await mcpPost(stack, toolsCall('query_records', { objectName: 'cbp_account' }));
109+
expect(res.status, 'anonymous MCP tool execution must be denied').toBe(401);
110+
});
111+
112+
it('admitted member: query_records is RLS-scoped to their own rows, never the admin\'s', async () => {
113+
const ids = await mcpQueryIds(
114+
await mcpPost(stack, toolsCall('query_records', { objectName: 'cbp_account' }), memberToken),
115+
);
116+
expect(ids.has(memberAccountId), 'member should see their own account via MCP').toBe(true);
117+
expect(ids.has(adminAccountId), 'member must NOT see the admin account via MCP (RLS)').toBe(false);
118+
});
119+
120+
it('admitted admin: query_records sees the admin-owned row the member is walled from', async () => {
121+
const ids = await mcpQueryIds(
122+
await mcpPost(stack, toolsCall('query_records', { objectName: 'cbp_account' }), adminToken),
123+
);
124+
// The contrast with the member result proves the scoping actually bites.
125+
expect(ids.has(adminAccountId), 'admin should see the admin account via MCP').toBe(true);
126+
});
127+
128+
it('MCP and REST share one admission: identical row-scoping for the same principal', async () => {
129+
const mcpIds = await mcpQueryIds(
130+
await mcpPost(stack, toolsCall('query_records', { objectName: 'cbp_account' }), memberToken),
131+
);
132+
const restRes = await stack.apiAs(memberToken, 'GET', '/data/cbp_account');
133+
expect(restRes.status).toBe(200);
134+
const restRecords = ((await restRes.json()) as any).records ?? [];
135+
const restIds = new Set(restRecords.map((r: any) => String(r.id)));
136+
expect(mcpIds, 'MCP query_records must scope identically to REST /data for the same principal').toEqual(restIds);
137+
});
138+
139+
it('by-id get_record is RLS-scoped too: member is denied the admin row but reads their own', async () => {
140+
// The admin's row is invisible → the bridge returns null → not-found tool error.
141+
const denied = await mcpPost(
142+
stack,
143+
toolsCall('get_record', { objectName: 'cbp_account', recordId: adminAccountId }),
144+
memberToken,
145+
);
146+
expect(denied.status).toBe(200); // JSON-RPC envelope; the denial is inside the result
147+
const deniedEnv = (await denied.json()) as any;
148+
expect(deniedEnv.result?.isError, 'member get_record on the admin row must be denied under RLS').toBe(true);
149+
150+
// ...and is not over-blocked on the row they own.
151+
const own = await toolPayload(
152+
await mcpPost(
153+
stack,
154+
toolsCall('get_record', { objectName: 'cbp_account', recordId: memberAccountId }),
155+
memberToken,
156+
),
157+
);
158+
expect(String(own.id)).toBe(memberAccountId);
159+
});
160+
});

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)