Files
TREK/server/tests/unit/mcp/demo-tool-gate.test.ts
Maurice bb6eb1ab89 fix(mcp): the six write tools that never had a demo check stay open to demo accounts under the central gate
sync_trip_documents and the five manual school-holiday tools carried no demo check of their own before the gate moved into the registry, so a demo account could call them. The gate now lets exactly those through, and a test pins the list. upload_trip_file keeps the gate's order: a demo account is refused before its trip access is checked, where the tool used to answer a trip it cannot see with the no-access result first.
2026-10-10 20:16:13 +02:00

229 lines
10 KiB
TypeScript

/**
* The demo-mode write block, held for every registered MCP tool at once.
*
* The block used to be the first line of each write tool; it now runs in the
* registry (trekDemoToolGate, wired into McpModule.forRoot, which the MCP test
* registry is taken from). This suite attaches the real registry to a capturing server and
* calls every write tool as the demo account: each one has to answer with the
* canned refusal before its handler runs. Every read-only tool has to pass the
* gate. Besides that it pins the two markers the gate relies on to each other,
* so a tool declared `mode: 'write'` but annotated read-only (or the reverse)
* fails here instead of slipping past the gate.
*/
import { ADDON_IDS } from '../../../src/addons';
import { db as testDb } from '../../../src/db/database';
import { DEMO_UNGATED_WRITE_TOOLS, isDemoGatedTool, trekDemoToolGate } from '../../../src/mcp/nest-mcp-policy';
import type { McpContext, McpRegistry, McpRegistryListing } from '../../../src/nest-mcp';
import { withRequestContext } from '../../../src/nest/database/request-context';
import { createUser } from '../../helpers/factories';
import { createMcpTestRegistry } from '../../helpers/mcp-test-controllers';
import { bootTestApp } from '../../helpers/test-app';
import { resetTestDb, setAddonEnabled } from '../../helpers/test-db';
import { MikroORM } from '@mikro-orm/core';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { describe, it, expect, vi, beforeAll, beforeEach, afterEach, afterAll } from 'vitest';
vi.mock('../../../src/db/database', async () => {
const { createSnapshotTestDb, buildDbMock } = await import('../../helpers/db-mock');
return buildDbMock(createSnapshotTestDb());
});
const DEMO_REFUSAL = {
content: [{ type: 'text', text: 'Write operations are disabled in demo mode.' }],
isError: true,
};
interface CapturedTool {
annotations?: Record<string, unknown>;
call: (args?: Record<string, unknown>) => Promise<unknown>;
}
/** Attaches the registry to a stand-in server that keeps every tool callback instead of serving it. */
async function captureTools(registry: McpRegistry, ctx: McpContext): Promise<Map<string, CapturedTool>> {
const tools = new Map<string, CapturedTool>();
const server = {
registerTool: (
name: string,
config: { annotations?: Record<string, unknown> },
cb: (...a: unknown[]) => unknown,
) => {
// Arguments as a client would send nothing: the gate answers before
// the handler ever reads them.
tools.set(name, {
annotations: config.annotations,
call: async (args = {}) => withRequestContext(orm, () => cb(args, {})),
});
},
registerResource: () => undefined,
registerPrompt: () => undefined,
} as unknown as McpServer;
// The attach reads the addon toggles, which production does inside the /mcp
// request's ORM context; the calls open their own below.
await withRequestContext(orm, () => registry.attach(server, ctx));
return tools;
}
let registry: McpRegistry;
let orm: MikroORM;
let toolListings: McpRegistryListing[];
beforeAll(async () => {
registry = await createMcpTestRegistry();
orm = (await bootTestApp()).get(MikroORM);
toolListings = registry.list().filter((entry) => entry.kind === 'tool');
});
beforeEach(() => {
resetTestDb(testDb);
for (const id of Object.values(ADDON_IDS)) setAddonEnabled(testDb, id, true);
});
afterEach(() => {
delete process.env.DEMO_MODE;
});
afterAll(() => {
testDb.close();
});
describe('demo tool gate over the whole registry', () => {
it('DEMOGATE-001: every write tool refuses the demo account with the canned body, before its handler', async () => {
process.env.DEMO_MODE = 'true';
const { user: demo } = createUser(testDb, { email: 'demo@trek.app' });
const tools = await captureTools(registry, { userId: demo.id, scopes: null, isStaticToken: false });
const writes = [...tools].filter(
([name, t]) => t.annotations?.readOnlyHint !== true && !DEMO_UNGATED_WRITE_TOOLS.has(name),
);
expect(writes.length).toBeGreaterThan(150);
const answers = await Promise.all(writes.map(async ([name, tool]) => ({ name, result: await tool.call() })));
expect(answers).toEqual(writes.map(([name]) => ({ name, result: DEMO_REFUSAL })));
});
it('DEMOGATE-007: the write tools that never had a demo check stay open to the demo account', async () => {
process.env.DEMO_MODE = 'true';
const { user: demo } = createUser(testDb, { email: 'demo@trek.app' });
const ctx: McpContext = { userId: demo.id, scopes: null, isStaticToken: false };
const tools = await captureTools(registry, ctx);
const gate = trekDemoToolGate(async () => true);
const open = [...DEMO_UNGATED_WRITE_TOOLS].sort();
expect(open).toEqual([
'create_manual_school_holiday_country',
'create_manual_school_holiday_region',
'delete_manual_school_holiday_country',
'delete_manual_school_holiday_region',
'sync_trip_documents',
'update_manual_school_holiday_region',
]);
for (const name of open) {
expect(tools.get(name), name).toBeDefined();
expect(tools.get(name)?.annotations?.readOnlyHint, name).not.toBe(true);
}
const refusals = await Promise.all(
open.map(async (name) => ({
name,
refusal: await gate({ name, annotations: tools.get(name)?.annotations }, ctx),
})),
);
expect(refusals).toEqual(open.map((name) => ({ name, refusal: undefined })));
});
it('DEMOGATE-002: no read-only tool is held back for the demo account', async () => {
process.env.DEMO_MODE = 'true';
const { user: demo } = createUser(testDb, { email: 'demo@trek.app' });
const ctx: McpContext = { userId: demo.id, scopes: null, isStaticToken: false };
const gate = trekDemoToolGate(async () => true);
const tools = await captureTools(registry, ctx);
const reads = [...tools].filter(([, t]) => t.annotations?.readOnlyHint === true);
expect(reads.length).toBeGreaterThan(80);
const refusals = await Promise.all(
reads.map(async ([name, tool]) => ({ name, refusal: await gate({ name, annotations: tool.annotations }, ctx) })),
);
expect(refusals).toEqual(reads.map(([name]) => ({ name, refusal: undefined })));
});
it('DEMOGATE-003: an ordinary account reaches the write handlers in demo mode', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'alice@example.com' });
const tools = await captureTools(registry, { userId: user.id, scopes: null, isStaticToken: false });
// create_tag has no trip to check and no addon gate: it writes and answers.
const result = (await tools.get('create_tag')?.call({ name: 'Mine' })) as {
isError?: boolean;
content: { text: string }[];
};
expect(result.isError).toBeUndefined();
expect(result.content[0].text).toContain('Mine');
});
it('DEMOGATE-004: outside demo mode the demo account is an ordinary account', async () => {
const { user: demo } = createUser(testDb, { email: 'demo@trek.app' });
const tools = await captureTools(registry, { userId: demo.id, scopes: null, isStaticToken: false });
const result = (await tools.get('create_tag')?.call({ name: 'Mine' })) as { isError?: boolean };
expect(result.isError).toBeUndefined();
});
it('DEMOGATE-005: with every addon on, only the photo-library tools stay detached, so the sweep above covers the rest', async () => {
const { user } = createUser(testDb);
const tools = await captureTools(registry, { userId: user.id, scopes: null, isStaticToken: false });
const missing = toolListings.map((t) => t.name).filter((name) => !tools.has(name));
// These three wait for a connected Immich or Synology provider
// (anyPhotoProviderEnabled), and all three are reads.
expect(missing.sort()).toEqual(['list_provider_album_photos', 'list_provider_albums', 'search_provider_photos']);
});
it('DEMOGATE-006: the scope mode and the read-only annotation agree on every declarative tool', async () => {
const { user } = createUser(testDb);
const tools = await captureTools(registry, { userId: user.id, scopes: null, isStaticToken: false });
const mismatched: string[] = [];
for (const listing of toolListings) {
const access = listing.access;
if (access === undefined || typeof access === 'function') continue;
const tool = tools.get(listing.name);
if (!tool) continue; // the detached photo-library reads of DEMOGATE-005
const readOnly = tool.annotations?.readOnlyHint === true;
if (access.mode === 'read' && !readOnly) mismatched.push(`${listing.name}: mode read, not annotated read-only`);
if ((access.mode === 'write' || access.mode === 'delete') && readOnly) {
mismatched.push(`${listing.name}: mode ${access.mode}, annotated read-only`);
}
}
expect(mismatched).toEqual([]);
});
});
describe('trekDemoToolGate', () => {
const ctx: McpContext = { userId: 7, scopes: null, isStaticToken: false };
it('DEMOGATE-010: refuses a write tool for a demo account and asks with the caller id', async () => {
const isDemo = vi.fn(async () => true);
expect(await trekDemoToolGate(isDemo)({ name: 'w', annotations: { readOnlyHint: false } }, ctx)).toEqual(
DEMO_REFUSAL,
);
expect(isDemo).toHaveBeenCalledWith(7);
});
it('DEMOGATE-011: lets a write tool through for any other account', async () => {
expect(await trekDemoToolGate(async () => false)({ name: 'w' }, ctx)).toBeUndefined();
});
it('DEMOGATE-012: never asks about the caller for a read-only tool', async () => {
const isDemo = vi.fn(async () => true);
expect(await trekDemoToolGate(isDemo)({ name: 'r', annotations: { readOnlyHint: true } }, ctx)).toBeUndefined();
expect(isDemo).not.toHaveBeenCalled();
});
it('DEMOGATE-014: a listed write tool passes the gate without asking about the caller', async () => {
const isDemo = vi.fn(async () => true);
const gate = trekDemoToolGate(isDemo);
expect(await gate({ name: 'sync_trip_documents', annotations: { readOnlyHint: false } }, ctx)).toBeUndefined();
expect(isDemo).not.toHaveBeenCalled();
expect(isDemoGatedTool({ name: 'delete_manual_school_holiday_country' })).toBe(false);
});
it('DEMOGATE-013: a tool without annotations counts as a write', () => {
expect(isDemoGatedTool({ name: 'x' })).toBe(true);
expect(isDemoGatedTool({ name: 'x', annotations: { readOnlyHint: true } })).toBe(false);
});
});