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