From 50cffb009b7f5f2319f72af189a9157d41ceefc8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 10:54:05 +0000 Subject: [PATCH] test(dogfood): strengthen MCP HTTP e2e + prove serve-side self-connection (#3167) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P3 — showcase-mcp-http-identity.dogfood.test.ts: add two assertions the mcp-http-identity proof lacked: - MCP query_records id-set == REST /data id-set for the same principal (the two surfaces are ONE admission, not two independently-scoped ones); - by-id get_record is RLS-scoped too — a member cannot fetch another owner's row. P2 — showcase-mcp-self-connection.dogfood.test.ts (new): the #3167 optional extension, "the platform connecting to itself". Hand-wired post-boot (not the declarative-at-boot form, which #3167 says must not make the dogfood gate timing-sensitive): mint an osk_ key, point connector-mcp at the app's own /api/v1/mcp over real HTTP, and prove it discovers + calls its own tools — while an unauthenticated self-connection is refused (ADR-0101 holds even to itself). Test-only (private @objectstack/dogfood gate). mcp dogfood 11/11. Refs #3167, ADR-0096, ADR-0101. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_0115eg8dAaCfWaDYYAm3ma36 --- ...showcase-mcp-http-identity.dogfood.test.ts | 47 +++++++++- ...owcase-mcp-self-connection.dogfood.test.ts | 91 +++++++++++++++++++ 2 files changed, 137 insertions(+), 1 deletion(-) create mode 100644 packages/qa/dogfood/test/showcase-mcp-self-connection.dogfood.test.ts diff --git a/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts b/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts index 4784bb20ed..3609374cb7 100644 --- a/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts +++ b/packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts @@ -30,6 +30,8 @@ describe('showcase: MCP HTTP surface is identity-admitted (ADR-0096 / #3167)', ( let stack: VerifyStack; let aliceToken: string; let bobToken: string; + let aliceNoteId: string; + let bobNoteId: string; /** POST /api/v1/mcp with the Streamable-HTTP Accept header; `token` null = anonymous. */ const mcp = (token: string | null, body: unknown) => @@ -70,9 +72,12 @@ describe('showcase: MCP HTTP surface is identity-admitted (ADR-0096 / #3167)', ( const a = await stack.apiAs(aliceToken, 'POST', OBJ, { title: 'Alice MCP note' }); expect(a.status, 'alice creates note').toBeLessThan(300); - expect(idOf(await a.json()), 'alice note id').toBeTruthy(); + aliceNoteId = idOf(await a.json()); + expect(aliceNoteId, 'alice note id').toBeTruthy(); const b = await stack.apiAs(bobToken, 'POST', OBJ, { title: 'Bob MCP note' }); expect(b.status, 'bob creates note').toBeLessThan(300); + bobNoteId = idOf(await b.json()); + expect(bobNoteId, 'bob note id').toBeTruthy(); }, 60_000); afterAll(async () => { @@ -108,4 +113,44 @@ describe('showcase: MCP HTTP surface is identity-admitted (ADR-0096 / #3167)', ( expect(names).toContain('query_records'); expect(names).toContain('describe_object'); }); + + // #3167 — MCP and REST are ONE admission, not two independently-scoped ones. + it('MCP query_records scopes IDENTICALLY to REST /data for the same principal', async () => { + const idsOf = (r: any): Set => + new Set((r.records ?? r.data ?? r.rows ?? []).map((x: any) => String(x.id))); + + const mcpIds = idsOf(await callTool(aliceToken, 'query_records', { objectName: 'showcase_private_note' })); + const restRes = await stack.apiAs(aliceToken, 'GET', OBJ); + expect(restRes.status).toBe(200); + const restIds = idsOf(await restRes.json()); + + expect(mcpIds, 'MCP and REST must return the same rows for the same caller').toEqual(restIds); + // Non-vacuous: the shared set is exactly alice's own row, never bob's. + expect(mcpIds.has(String(aliceNoteId))).toBe(true); + expect(mcpIds.has(String(bobNoteId))).toBe(false); + }); + + // The by-id read path (bridge.get → callData('get', …, ec)) is RLS-scoped too, + // not just the list query. + it('by-id get_record is RLS-scoped — a member cannot fetch another owner\'s row', async () => { + // Alice fetches Bob's private note by id → denied (not-found under owner RLS), + // surfaced as an MCP tool error, not a leaked row. + const denied: any = await ( + await mcp(aliceToken, mcpBody('tools/call', { + name: 'get_record', + arguments: { objectName: 'showcase_private_note', recordId: bobNoteId }, + })) + ).json(); + expect( + denied.result?.isError, + `alice get_record on bob's row must be denied: ${JSON.stringify(denied.result)}`, + ).toBe(true); + + // ...but she reads her own by id. + const own = await callTool(aliceToken, 'get_record', { + objectName: 'showcase_private_note', + recordId: aliceNoteId, + }); + expect(String(own.id)).toBe(String(aliceNoteId)); + }); }); diff --git a/packages/qa/dogfood/test/showcase-mcp-self-connection.dogfood.test.ts b/packages/qa/dogfood/test/showcase-mcp-self-connection.dogfood.test.ts new file mode 100644 index 0000000000..6f18a229a1 --- /dev/null +++ b/packages/qa/dogfood/test/showcase-mcp-self-connection.dogfood.test.ts @@ -0,0 +1,91 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #3167 optional extension — "the platform connecting to itself". Proves the +// full serve↔consume loop end-to-end in ONE process: the app's own MCP HTTP +// surface (`/api/v1/mcp`, ADR-0096 / #3228) is reachable by an MCP *client* +// (connector-mcp) that authenticates with an `osk_` API key (ADR-0101 — +// anonymous is denied even to itself), and that client can discover and call +// the app's own generated tools. +// +// Deliberately HAND-WIRED (connect AFTER boot), NOT the declarative +// `provider: 'mcp'` at boot: #3167 warns the dogfood gate must not become +// timing-sensitive, and a declarative self-connection races the boot order +// (automation `start()` runs before the HTTP server listens) and heals only via +// the #3049 degrade+retry. This test sidesteps that race entirely — it connects +// once the server is listening and a key exists — so it proves the capability +// without a flaky gate. The declarative-at-boot form (with #3049 heal) remains a +// separate, carefully-gated follow-up. + +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import showcaseStack from '@objectstack/example-showcase'; +import { bootStack, type VerifyStack } from '@objectstack/verify'; +import { MCPServerPlugin } from '@objectstack/mcp'; +import { createMcpConnector } from '@objectstack/connector-mcp'; + +describe('showcase: the platform connects to its OWN MCP endpoint (#3167 self-connection)', () => { + let stack: VerifyStack; + let selfUrl: string; + let apiKey: string; + + beforeAll(async () => { + // Serve side up (isMcpServerEnabled default-on; the lean harness injects the + // plugin the way `os dev`/`serve` auto-load it). + stack = await bootStack(showcaseStack, { extraPlugins: [new MCPServerPlugin()] }); + const adminToken = await stack.signIn(); + + // Mint an osk_ key — the self-connection's identity (acts AS the admin caller). + const keyRes = await stack.apiAs(adminToken, 'POST', '/keys', { name: 'self-mcp' }); + expect(keyRes.status, `mint key: ${keyRes.status} ${await keyRes.clone().text()}`).toBe(201); + apiKey = ((await keyRes.json()) as { data?: { key?: string } }).data?.key ?? ''; + expect(apiKey, 'raw osk_ key returned once').toMatch(/^osk_/); + + // The app really listens on an ephemeral port (HonoServerPlugin({ port: 0 })), + // so the connector reaches it over real HTTP exactly like an external client. + const httpServer = await stack.kernel.getServiceAsync<{ getPort(): number }>('http-server'); + const port = httpServer.getPort(); + expect(port, 'app bound a real port').toBeGreaterThan(0); + selfUrl = `http://127.0.0.1:${port}/api/v1/mcp`; + }, 60_000); + + afterAll(async () => { + await stack?.stop(); + }); + + it('refuses an UNAUTHENTICATED self-connection (identity admission holds even to itself)', async () => { + await expect( + createMcpConnector({ name: 'self_anon', transport: { kind: 'http', url: selfUrl } }), + ).rejects.toThrow(); + }); + + it('connects to itself with an osk_ key and discovers its own generated tools', async () => { + const bundle = await createMcpConnector({ + name: 'self_mcp', + transport: { kind: 'http', url: selfUrl, headers: { 'x-api-key': apiKey } }, + }); + try { + const toolKeys = bundle.def.actions?.map((a) => a.key) ?? []; + // The app's own generated CRUD spine, discovered over the self-connection. + expect(toolKeys, `self tools: ${JSON.stringify(toolKeys)}`).toContain('list_objects'); + expect(toolKeys).toContain('query_records'); + expect(toolKeys).toContain('describe_object'); + } finally { + await bundle.close(); + } + }); + + it('operates itself end-to-end — a self tools/call round-trips the app\'s own schema', async () => { + const bundle = await createMcpConnector({ + name: 'self_mcp_call', + transport: { kind: 'http', url: selfUrl, headers: { 'x-api-key': apiKey } }, + }); + try { + const handler = bundle.handlers['list_objects']; + expect(handler, 'list_objects handler present').toBeDefined(); + const result = await handler!({}); + // The app's own object list, round-tripped back through its own MCP surface. + expect(JSON.stringify(result), `self list_objects result: ${JSON.stringify(result)}`).toContain('showcase_'); + } finally { + await bundle.close(); + } + }); +});