|
| 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 | +}); |
0 commit comments