Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) =>
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<string> =>
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));
});
});
Original file line number Diff line number Diff line change
@@ -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();
}
});
});