Skip to content

Commit 50cffb0

Browse files
committed
test(dogfood): strengthen MCP HTTP e2e + prove serve-side self-connection (#3167)
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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0115eg8dAaCfWaDYYAm3ma36
1 parent 15dbe18 commit 50cffb0

2 files changed

Lines changed: 137 additions & 1 deletion

File tree

packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts

Lines changed: 46 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ describe('showcase: MCP HTTP surface is identity-admitted (ADR-0096 / #3167)', (
3030
let stack: VerifyStack;
3131
let aliceToken: string;
3232
let bobToken: string;
33+
let aliceNoteId: string;
34+
let bobNoteId: string;
3335

3436
/** POST /api/v1/mcp with the Streamable-HTTP Accept header; `token` null = anonymous. */
3537
const mcp = (token: string | null, body: unknown) =>
@@ -70,9 +72,12 @@ describe('showcase: MCP HTTP surface is identity-admitted (ADR-0096 / #3167)', (
7072

7173
const a = await stack.apiAs(aliceToken, 'POST', OBJ, { title: 'Alice MCP note' });
7274
expect(a.status, 'alice creates note').toBeLessThan(300);
73-
expect(idOf(await a.json()), 'alice note id').toBeTruthy();
75+
aliceNoteId = idOf(await a.json());
76+
expect(aliceNoteId, 'alice note id').toBeTruthy();
7477
const b = await stack.apiAs(bobToken, 'POST', OBJ, { title: 'Bob MCP note' });
7578
expect(b.status, 'bob creates note').toBeLessThan(300);
79+
bobNoteId = idOf(await b.json());
80+
expect(bobNoteId, 'bob note id').toBeTruthy();
7681
}, 60_000);
7782

7883
afterAll(async () => {
@@ -108,4 +113,44 @@ describe('showcase: MCP HTTP surface is identity-admitted (ADR-0096 / #3167)', (
108113
expect(names).toContain('query_records');
109114
expect(names).toContain('describe_object');
110115
});
116+
117+
// #3167 — MCP and REST are ONE admission, not two independently-scoped ones.
118+
it('MCP query_records scopes IDENTICALLY to REST /data for the same principal', async () => {
119+
const idsOf = (r: any): Set<string> =>
120+
new Set((r.records ?? r.data ?? r.rows ?? []).map((x: any) => String(x.id)));
121+
122+
const mcpIds = idsOf(await callTool(aliceToken, 'query_records', { objectName: 'showcase_private_note' }));
123+
const restRes = await stack.apiAs(aliceToken, 'GET', OBJ);
124+
expect(restRes.status).toBe(200);
125+
const restIds = idsOf(await restRes.json());
126+
127+
expect(mcpIds, 'MCP and REST must return the same rows for the same caller').toEqual(restIds);
128+
// Non-vacuous: the shared set is exactly alice's own row, never bob's.
129+
expect(mcpIds.has(String(aliceNoteId))).toBe(true);
130+
expect(mcpIds.has(String(bobNoteId))).toBe(false);
131+
});
132+
133+
// The by-id read path (bridge.get → callData('get', …, ec)) is RLS-scoped too,
134+
// not just the list query.
135+
it('by-id get_record is RLS-scoped — a member cannot fetch another owner\'s row', async () => {
136+
// Alice fetches Bob's private note by id → denied (not-found under owner RLS),
137+
// surfaced as an MCP tool error, not a leaked row.
138+
const denied: any = await (
139+
await mcp(aliceToken, mcpBody('tools/call', {
140+
name: 'get_record',
141+
arguments: { objectName: 'showcase_private_note', recordId: bobNoteId },
142+
}))
143+
).json();
144+
expect(
145+
denied.result?.isError,
146+
`alice get_record on bob's row must be denied: ${JSON.stringify(denied.result)}`,
147+
).toBe(true);
148+
149+
// ...but she reads her own by id.
150+
const own = await callTool(aliceToken, 'get_record', {
151+
objectName: 'showcase_private_note',
152+
recordId: aliceNoteId,
153+
});
154+
expect(String(own.id)).toBe(String(aliceNoteId));
155+
});
111156
});
Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #3167 optional extension — "the platform connecting to itself". Proves the
4+
// full serve↔consume loop end-to-end in ONE process: the app's own MCP HTTP
5+
// surface (`/api/v1/mcp`, ADR-0096 / #3228) is reachable by an MCP *client*
6+
// (connector-mcp) that authenticates with an `osk_` API key (ADR-0101 —
7+
// anonymous is denied even to itself), and that client can discover and call
8+
// the app's own generated tools.
9+
//
10+
// Deliberately HAND-WIRED (connect AFTER boot), NOT the declarative
11+
// `provider: 'mcp'` at boot: #3167 warns the dogfood gate must not become
12+
// timing-sensitive, and a declarative self-connection races the boot order
13+
// (automation `start()` runs before the HTTP server listens) and heals only via
14+
// the #3049 degrade+retry. This test sidesteps that race entirely — it connects
15+
// once the server is listening and a key exists — so it proves the capability
16+
// without a flaky gate. The declarative-at-boot form (with #3049 heal) remains a
17+
// separate, carefully-gated follow-up.
18+
19+
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
20+
import showcaseStack from '@objectstack/example-showcase';
21+
import { bootStack, type VerifyStack } from '@objectstack/verify';
22+
import { MCPServerPlugin } from '@objectstack/mcp';
23+
import { createMcpConnector } from '@objectstack/connector-mcp';
24+
25+
describe('showcase: the platform connects to its OWN MCP endpoint (#3167 self-connection)', () => {
26+
let stack: VerifyStack;
27+
let selfUrl: string;
28+
let apiKey: string;
29+
30+
beforeAll(async () => {
31+
// Serve side up (isMcpServerEnabled default-on; the lean harness injects the
32+
// plugin the way `os dev`/`serve` auto-load it).
33+
stack = await bootStack(showcaseStack, { extraPlugins: [new MCPServerPlugin()] });
34+
const adminToken = await stack.signIn();
35+
36+
// Mint an osk_ key — the self-connection's identity (acts AS the admin caller).
37+
const keyRes = await stack.apiAs(adminToken, 'POST', '/keys', { name: 'self-mcp' });
38+
expect(keyRes.status, `mint key: ${keyRes.status} ${await keyRes.clone().text()}`).toBe(201);
39+
apiKey = ((await keyRes.json()) as { data?: { key?: string } }).data?.key ?? '';
40+
expect(apiKey, 'raw osk_ key returned once').toMatch(/^osk_/);
41+
42+
// The app really listens on an ephemeral port (HonoServerPlugin({ port: 0 })),
43+
// so the connector reaches it over real HTTP exactly like an external client.
44+
const httpServer = await stack.kernel.getServiceAsync<{ getPort(): number }>('http-server');
45+
const port = httpServer.getPort();
46+
expect(port, 'app bound a real port').toBeGreaterThan(0);
47+
selfUrl = `http://127.0.0.1:${port}/api/v1/mcp`;
48+
}, 60_000);
49+
50+
afterAll(async () => {
51+
await stack?.stop();
52+
});
53+
54+
it('refuses an UNAUTHENTICATED self-connection (identity admission holds even to itself)', async () => {
55+
await expect(
56+
createMcpConnector({ name: 'self_anon', transport: { kind: 'http', url: selfUrl } }),
57+
).rejects.toThrow();
58+
});
59+
60+
it('connects to itself with an osk_ key and discovers its own generated tools', async () => {
61+
const bundle = await createMcpConnector({
62+
name: 'self_mcp',
63+
transport: { kind: 'http', url: selfUrl, headers: { 'x-api-key': apiKey } },
64+
});
65+
try {
66+
const toolKeys = bundle.def.actions?.map((a) => a.key) ?? [];
67+
// The app's own generated CRUD spine, discovered over the self-connection.
68+
expect(toolKeys, `self tools: ${JSON.stringify(toolKeys)}`).toContain('list_objects');
69+
expect(toolKeys).toContain('query_records');
70+
expect(toolKeys).toContain('describe_object');
71+
} finally {
72+
await bundle.close();
73+
}
74+
});
75+
76+
it('operates itself end-to-end — a self tools/call round-trips the app\'s own schema', async () => {
77+
const bundle = await createMcpConnector({
78+
name: 'self_mcp_call',
79+
transport: { kind: 'http', url: selfUrl, headers: { 'x-api-key': apiKey } },
80+
});
81+
try {
82+
const handler = bundle.handlers['list_objects'];
83+
expect(handler, 'list_objects handler present').toBeDefined();
84+
const result = await handler!({});
85+
// The app's own object list, round-tripped back through its own MCP surface.
86+
expect(JSON.stringify(result), `self list_objects result: ${JSON.stringify(result)}`).toContain('showcase_');
87+
} finally {
88+
await bundle.close();
89+
}
90+
});
91+
});

0 commit comments

Comments
 (0)