Files
Maurice 517fa21e2f build: prettier leaves side-effect imports where they are
The sort-imports plugin moved bare imports along with the rest, which on
src/index.ts would have put reflect-metadata after the modules that need it
and the env validation ahead of dotenv. With importOrderSideEffects off, a
side-effect import stays in place and the imports between two of them are
sorted on their own. The three test files that were already formatted gain
the blank line the new grouping writes after reflect-metadata.
2026-10-10 20:30:16 +02:00

111 lines
3.7 KiB
TypeScript

import { McpController, McpModule, McpRegistryService, Tool, type McpAccessPolicy } from '../../../src/nest-mcp';
import { 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 } from 'vitest';
import { z } from 'zod';
@Injectable()
class CounterService {
private n = 0;
next(): number {
return ++this.n;
}
}
@McpController()
class CounterMcp {
constructor(private readonly counter: CounterService) {}
@Tool({ name: 'count', inputSchema: { by: z.number().optional() }, access: { group: 'trips', mode: 'read' } })
async count(_args: { by?: number }, ctx: TestCtx) {
return { content: [{ type: 'text', text: JSON.stringify({ value: this.counter.next(), userId: ctx.userId }) }] };
}
}
@Injectable()
class PlainProvider {
ignoredMethod() {}
}
@Module({ providers: [CounterService, CounterMcp], exports: [] })
class CounterModule {}
const policy: McpAccessPolicy = (_access, ctx) => (ctx as TestCtx).canRead === true;
describe('McpRegistryService discovery', () => {
let harness: AttachHarness | undefined;
afterEach(async () => {
await harness?.cleanup();
harness = undefined;
});
it('discovers only @McpController providers, across feature modules', async () => {
const moduleRef = await Test.createTestingModule({
imports: [McpModule.forRoot({ accessPolicy: policy }), CounterModule],
providers: [PlainProvider],
}).compile();
await moduleRef.init();
const registry = moduleRef.get(McpRegistryService);
expect(registry.list()).toEqual([
expect.objectContaining({ kind: 'tool', name: 'count', className: 'CounterMcp', methodName: 'count' }),
]);
await moduleRef.close();
});
it('registers boot-discovered entries with working constructor injection', async () => {
const moduleRef = await Test.createTestingModule({
imports: [McpModule.forRoot({ accessPolicy: policy }), CounterModule],
}).compile();
await moduleRef.init();
const registry = moduleRef.get(McpRegistryService);
harness = await createAttachHarness(registry, { userId: 5, canRead: true });
const result = await harness.client.callTool({ name: 'count', arguments: {} });
const text = (result.content as { type: string; text: string }[]).find((c) => c.type === 'text');
expect(JSON.parse(text?.text ?? '')).toEqual({ value: 1, userId: 5 });
await moduleRef.close();
});
it('fails app boot (onModuleInit) on duplicate tool names', async () => {
@McpController()
class CountClone {
@Tool({ name: 'count' })
async countAgain() {
return { content: [{ type: 'text', text: 'clone' }] };
}
}
const moduleRef = await Test.createTestingModule({
imports: [McpModule.forRoot({ accessPolicy: policy }), CounterModule],
providers: [CountClone],
}).compile();
await expect(moduleRef.init()).rejects.toThrow(/duplicate MCP registrations: tool "count"/);
});
it('boots without an accessPolicy and still attaches predicate/ungated entries', async () => {
@McpController()
class OpenMcp {
@Tool({ name: 'open' })
async open() {
return { content: [{ type: 'text', text: 'ok' }] };
}
}
const moduleRef = await Test.createTestingModule({
imports: [McpModule.forRoot()],
providers: [OpenMcp],
}).compile();
await moduleRef.init();
const registry = moduleRef.get(McpRegistryService);
harness = await createAttachHarness(registry, {});
expect((await harness.client.listTools()).tools.map((t) => t.name)).toEqual(['open']);
await moduleRef.close();
});
});