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
1 change: 1 addition & 0 deletions packages/qa/dogfood/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@objectstack/connector-rest": "workspace:*",
"@objectstack/example-crm": "workspace:*",
"@objectstack/example-showcase": "workspace:*",
"@objectstack/mcp": "workspace:*",
"@objectstack/objectql": "workspace:*",
"@objectstack/plugin-audit": "workspace:*",
"@objectstack/plugin-auth": "workspace:*",
Expand Down
3 changes: 2 additions & 1 deletion packages/qa/dogfood/test/authz-conformance.matrix.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,8 @@ export const AUTHZ_CONFORMANCE: AuthzPrimitive[] = [
{ id: 'mcp-http-identity', summary: 'MCP HTTP surface (/api/v1/mcp) admits the caller identity — anonymous denied, OAuth scope-gated, caller ExecutionContext threaded to every tool\'s data op', state: 'enforced',
enforcement: 'runtime/http-dispatcher.ts handleMcp — requires ec.userId||ec.isSystem (401 else, RFC 9728 WWW-Authenticate advertised when the OAuth track is live); OAuth-token provenance narrows the exposed tool families to the granted MCP scopes (403 on none, #2698); buildMcpBridge(context) threads the caller ExecutionContext into every bridge op (callData(..., ec)), and mcp-server-runtime.ts handleHttpRequest builds a fresh per-request McpServer from that principal-bound bridge (registerObjectTools/registerActionTools) — so RLS / FLS / tenant apply exactly as on REST /data',
covers: ['mcp:http-dispatcher.ts:handleMcp', 'mcp:http-dispatcher.ts:buildMcpBridge(context-threaded)'],
note: 'The per-request principal-bound tool server is isolated from the long-lived UNSCOPED stdio server (see mcp-stdio-authority). NOT marked high-risk here only because its end-to-end dogfood proof (anonymous → 401; principal → RLS/FLS-scoped tool results) lands with the dev-default-wiring follow-up (#3167 PR-B), not because the surface is latent — it is a live route. Dropping the buildMcpBridge(context) threading (or building an unscoped/system bridge for HTTP) makes the context-threaded key STALE → red CI; a new sibling MCP data handler appears as an UNCLASSIFIED surface until a row covers it.' },
proof: 'showcase-mcp-http-identity.dogfood.test.ts',
note: 'The per-request principal-bound tool server is isolated from the long-lived UNSCOPED stdio server (see mcp-stdio-authority). HIGH-RISK, proven end-to-end (#3167 PR-B): the proof boots the real showcase + security + MCP plugin and drives POST /api/v1/mcp — an anonymous tools/call is 401 before any tool runs, and a member\'s query_records over the owner-private showcase_private_note returns ONLY their own rows (if the tool ran unscoped/system — the stdio posture — the other owner\'s rows would leak). Dropping the buildMcpBridge(context) threading (or building an unscoped/system bridge for HTTP) makes the context-threaded key STALE → red CI; a new sibling MCP data handler appears as an UNCLASSIFIED surface until a row covers it. Dispatcher-level unit coverage: http-dispatcher.mcp.test.ts (401, EC-to-bridge) + http-dispatcher.mcp-oauth.test.ts (scope 403).' },
{ id: 'mcp-stdio-authority', summary: 'MCP stdio transport runs UNSCOPED — the long-lived server bridges the raw metadata service + data engine with no per-request principal (opt-in: autoStart / OS_MCP_SERVER_ENABLED=true)', state: 'experimental',
covers: ['mcp:plugin.ts:bridgeResources(unscoped-stdio)'],
note: 'Surface posture: process-authority, opt-in. Unlike the HTTP path (mcp-http-identity), MCPServerPlugin.start() bridges resources/tools onto the long-lived this.mcpServer from the RAW metadata service + data engine (bridgeResources(metadataService, dataEngine)) — there is no ExecutionContext, so a stdio-attached client reads metadata + records with full, unscoped authority (no RLS/FLS/tenant). This is safe ONLY as a single-operator LOCAL tool: the operator who can attach stdio already owns the process and its dev database, so the transport grants nothing they lack. It is deliberately NOT default (the shouldStart gate in plugin.ts keeps stdio opt-in, stricter than the default-on HTTP surface). ADMISSION REQUIREMENT before stdio is ever promoted to default-on OR served in a multi-user / hosted context: thread a principal (a configured service identity, or a per-session ExecutionContext) into the long-lived bridge, mirroring the HTTP path\'s buildMcpBridge. Changing the raw-service bridging makes the unscoped-stdio key STALE → forces re-classification in CI.' },
Expand Down
4 changes: 4 additions & 0 deletions packages/qa/dogfood/test/authz-conformance.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ const HIGH_RISK = [
// is false compliance (declared ≠ enforced) and approval/status columns are
// one direct PATCH away from self-approval.
'readonly-static-write',
// #3167 — the MCP HTTP surface guards the same object data as REST /data
// through a sibling execution surface (tool dispatch); proven e2e that a
// member's MCP tools/call is RLS-scoped and anonymous is denied.
'mcp-http-identity',
];

describe('ADR-0056 D10 — authorization conformance matrix', () => {
Expand Down
111 changes: 111 additions & 0 deletions packages/qa/dogfood/test/showcase-mcp-http-identity.dogfood.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
//
// ADR-0096 / #3167 — the MCP HTTP surface (/api/v1/mcp) is an identity-admitted
// execution surface, proven END-TO-END through the real showcase + security +
// MCP stack. This is the e2e proof the `mcp-http-identity` matrix row deferred
// (#3202): the dispatcher unit tests prove handleMcp passes the caller EC to the
// bridge; THIS proves the whole chain — an MCP `tools/call` runs under the
// caller's RLS, so a member sees only their own rows, and an anonymous caller is
// denied before any tool executes.
//
// The target object is `showcase_private_note` (OWD `private`, owner-only) — the
// same object the declarative-OWD proof uses. Owner isolation is enforced by the
// engine, so if the MCP tool ran unscoped (the stdio posture, mcp-stdio-authority)
// this test would see cross-owner rows and FAIL.

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';

const OBJ = '/data/showcase_private_note';
const idOf = (b: any) => b?.id ?? b?.record?.id ?? b?.data?.id ?? b?.recordId;

/** A JSON-RPC MCP request over Streamable-HTTP (JSON response mode). */
function mcpBody(method: string, params?: unknown, id = 1) {
return { jsonrpc: '2.0', id, method, ...(params !== undefined ? { params } : {}) };
}

describe('showcase: MCP HTTP surface is identity-admitted (ADR-0096 / #3167)', () => {
let stack: VerifyStack;
let aliceToken: string;
let bobToken: string;

/** POST /api/v1/mcp with the Streamable-HTTP Accept header; `token` null = anonymous. */
const mcp = (token: string | null, body: unknown) =>
stack.api('/mcp', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
// The transport requires the client to accept both content types.
Accept: 'application/json, text/event-stream',
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify(body),
});

/** Call an MCP tool and return the parsed JSON its text-content carries. */
async function callTool(token: string, name: string, args: Record<string, unknown>): Promise<any> {
const res = await mcp(token, mcpBody('tools/call', { name, arguments: args }));
expect(res.status, `tools/call ${name}: ${res.status} ${await res.clone().text()}`).toBe(200);
const rpc: any = await res.json();
expect(rpc.error, `tools/call ${name} JSON-RPC error: ${JSON.stringify(rpc.error)}`).toBeUndefined();
const text = rpc.result?.content?.[0]?.text;
expect(typeof text, 'tool result carries text content').toBe('string');
return JSON.parse(text);
}

const titlesOf = (queryResult: any): string[] =>
(queryResult.records ?? queryResult.data ?? queryResult.rows ?? []).map((r: any) => r.title);

beforeAll(async () => {
// The MCP plugin registers the `'mcp'` service the dispatcher's /mcp route
// needs (in production `os serve`/`dev` auto-load it via isMcpServerEnabled;
// bootStack's lean harness injects it explicitly). isMcpServerEnabled() is
// default-on, so the route is live.
stack = await bootStack(showcaseStack, { extraPlugins: [new MCPServerPlugin()] });
await stack.signIn(); // seed dev admin (first user)
aliceToken = await stack.signUp('mcp-alice@verify.test');
bobToken = await stack.signUp('mcp-bob@verify.test');

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();
const b = await stack.apiAs(bobToken, 'POST', OBJ, { title: 'Bob MCP note' });
expect(b.status, 'bob creates note').toBeLessThan(300);
}, 60_000);

afterAll(async () => {
await stack?.stop();
});

it('denies an anonymous MCP request before any tool runs (fail-closed, 401)', async () => {
const res = await mcp(null, mcpBody('tools/call', { name: 'query_records', arguments: { objectName: 'showcase_private_note' } }));
expect(res.status).toBe(401);
});

it('runs a tools/call under the CALLER identity — a member sees only their own rows (RLS through MCP)', async () => {
const result = await callTool(aliceToken, 'query_records', { objectName: 'showcase_private_note' });
const titles = titlesOf(result);
expect(titles, `alice via MCP saw: ${JSON.stringify(titles)}`).toContain('Alice MCP note');
// The load-bearing assertion: if the tool ran unscoped/system (the stdio
// posture), Bob's note would be here. Owner-RLS through the MCP bridge hides it.
expect(titles).not.toContain('Bob MCP note');
});

it('is symmetric — the other member sees only their own row', async () => {
const result = await callTool(bobToken, 'query_records', { objectName: 'showcase_private_note' });
const titles = titlesOf(result);
expect(titles).toContain('Bob MCP note');
expect(titles).not.toContain('Alice MCP note');
});

it('the caller identity also gates tool discovery — an authed member gets a tool list', async () => {
const res = await mcp(aliceToken, mcpBody('tools/list'));
expect(res.status).toBe(200);
const rpc: any = await res.json();
const names: string[] = (rpc.result?.tools ?? []).map((t: any) => t.name);
expect(names).toContain('query_records');
expect(names).toContain('describe_object');
});
});
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.