Files
TREK/server/tests/unit/nest-mcp/tool-gate.test.ts
T
Maurice 1a9855cf70 refactor(mcp): the demo-mode write block runs once, in the registry's tool gate
Every write tool used to open with its own demo check, 196 copies across 30
tool files, most of them injecting AuthService for nothing else. The
registry now takes a toolGate (built from the container) that runs before
each registered tool handler, and TREK's gate refuses a demo account on
every tool not annotated read-only with the same canned result. The
per-tool checks, the AuthService injections, AuthService.isDemoUser and the
AuthModule imports that only fed them are gone; ESLint refuses a demo check
in a tool file, and a registry-wide test calls every write tool as the
demo account.
2026-10-10 20:15:57 +02:00

166 lines
6.3 KiB
TypeScript

import {
createTestRegistry,
McpController,
McpModule,
McpRegistryService,
Resource,
Tool,
type McpDynamicTool,
type McpToolGate,
} from '../../../src/nest-mcp';
import { asCtx, createAttachHarness, type AttachHarness, type TestCtx } from './harness';
import { Injectable, Module } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import 'reflect-metadata';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { z } from 'zod';
const REFUSAL = { content: [{ type: 'text', text: 'refused' }], isError: true };
@McpController()
class GatedMcp {
calls = 0;
@Tool({ name: 'write_it', inputSchema: { n: z.number() }, annotations: { readOnlyHint: false } })
async writeIt({ n }: { n: number }, ctx: TestCtx) {
this.calls++;
return { content: [{ type: 'text', text: JSON.stringify({ n, userId: ctx.userId }) }] };
}
@Tool({ name: 'no_schema', annotations: { readOnlyHint: true } })
async noSchema(args: Record<string, never>) {
return { content: [{ type: 'text', text: JSON.stringify(args) }] };
}
@Resource({ name: 'res', uri: 'gate://res' })
async res(uri: URL) {
return { contents: [{ uri: uri.href, text: 'resource' }] };
}
}
const textOf = (result: unknown): string => (result as { content: { text: string }[] }).content[0].text;
describe('tool gate', () => {
let harness: AttachHarness | undefined;
afterEach(async () => {
await harness?.cleanup();
harness = undefined;
});
it('answers with the gate result and never runs the handler', async () => {
const mcp = new GatedMcp();
const gate = vi.fn<McpToolGate>(() => REFUSAL);
harness = await createAttachHarness(createTestRegistry([mcp], { toolGate: gate }), { userId: 3 });
const result = await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } });
expect(result).toEqual(REFUSAL);
expect(mcp.calls).toBe(0);
expect(gate).toHaveBeenCalledWith(expect.objectContaining({ name: 'write_it', annotations: { readOnlyHint: false } }), asCtx({ userId: 3 }));
});
it('runs the handler with its arguments when the gate returns undefined', async () => {
const mcp = new GatedMcp();
harness = await createAttachHarness(createTestRegistry([mcp], { toolGate: async () => undefined }), { userId: 4 });
const result = await harness.client.callTool({ name: 'write_it', arguments: { n: 2 } });
expect(JSON.parse(textOf(result))).toEqual({ n: 2, userId: 4 });
expect(mcp.calls).toBe(1);
});
it('gates a tool without an input schema too', async () => {
harness = await createAttachHarness(createTestRegistry([new GatedMcp()], { toolGate: () => REFUSAL }), {});
expect(await harness.client.callTool({ name: 'no_schema', arguments: {} })).toEqual(REFUSAL);
});
it('leaves resources alone', async () => {
const gate = vi.fn<McpToolGate>(() => REFUSAL);
harness = await createAttachHarness(createTestRegistry([new GatedMcp()], { toolGate: gate }), {});
const read = await harness.client.readResource({ uri: 'gate://res' });
expect(read.contents[0]).toMatchObject({ text: 'resource' });
expect(gate).not.toHaveBeenCalled();
});
it('leaves dynamic tools to their own source', async () => {
const gate = vi.fn<McpToolGate>(() => REFUSAL);
const dynamic: McpDynamicTool = {
options: { name: 'dyn', inputSchema: {}, access: () => true },
handler: () => ({ content: [{ type: 'text', text: 'dynamic ran' }] }),
};
harness = await createAttachHarness(createTestRegistry([], { toolGate: gate }), {}, { dynamicTools: () => [dynamic] });
expect(textOf(await harness.client.callTool({ name: 'dyn', arguments: {} }))).toBe('dynamic ran');
expect(gate).not.toHaveBeenCalled();
});
it('runs after onInvoke and inside around, so the audit and the trace still see a refused call', async () => {
const order: string[] = [];
const registry = createTestRegistry([new GatedMcp()], {
toolGate: () => {
order.push('gate');
return REFUSAL;
},
});
harness = await createAttachHarness(registry, {}, {
onInvoke: () => order.push('onInvoke'),
around: async (_info, call) => {
order.push('around:in');
const out = await call();
order.push('around:out');
return out;
},
});
await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } });
expect(order).toEqual(['onInvoke', 'around:in', 'gate', 'around:out']);
});
it('is skipped entirely without a configured gate', async () => {
const mcp = new GatedMcp();
harness = await createAttachHarness(createTestRegistry([mcp]), { userId: 1 });
await harness.client.callTool({ name: 'write_it', arguments: { n: 5 } });
expect(mcp.calls).toBe(1);
});
});
@Injectable()
class Verdicts {
refuse = true;
}
@Module({ providers: [Verdicts, GatedMcp], exports: [Verdicts] })
class GatedModule {}
describe('McpModule.forRoot toolGate', () => {
let harness: AttachHarness | undefined;
afterEach(async () => {
await harness?.cleanup();
harness = undefined;
});
it('builds the gate from the container with its injected providers', async () => {
const moduleRef = await Test.createTestingModule({
imports: [
GatedModule,
McpModule.forRoot({
toolGate: {
imports: [GatedModule],
inject: [Verdicts],
useFactory: (verdicts: Verdicts) => () => (verdicts.refuse ? REFUSAL : undefined),
},
}),
],
}).compile();
await moduleRef.init();
harness = await createAttachHarness(moduleRef.get(McpRegistryService), { userId: 1 });
expect(await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } })).toEqual(REFUSAL);
moduleRef.get(Verdicts).refuse = false;
expect(JSON.parse(textOf(await harness.client.callTool({ name: 'write_it', arguments: { n: 1 } })))).toEqual({ n: 1, userId: 1 });
await moduleRef.close();
});
it('boots without a gate and runs every handler', async () => {
const moduleRef = await Test.createTestingModule({ imports: [GatedModule, McpModule.forRoot()] }).compile();
await moduleRef.init();
harness = await createAttachHarness(moduleRef.get(McpRegistryService), { userId: 2 });
expect(JSON.parse(textOf(await harness.client.callTool({ name: 'write_it', arguments: { n: 3 } })))).toEqual({ n: 3, userId: 2 });
await moduleRef.close();
});
});