Files
TREK/server/tests/unit/nest-mcp/decorators.test.ts
T
jubnl 76c8c349b1 Fold nest mcp (#2017)
* refactor(server): fold @trek/nest-mcp into server/src/nest-mcp

* docs(server): point nest-mcp references at server/src/nest-mcp

* remove tracked md file
2026-08-22 12:55:24 +02:00

107 lines
3.6 KiB
TypeScript

import { McpController, Prompt, Resource, ResourceTemplate, Tool } from '../../../src/nest-mcp';
import { getEntry, isMcpController, type ClassRef } from '../../../src/nest-mcp/metadata';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
@McpController()
class Decorated {
@Tool({
name: 'do_thing',
description: 'Does a thing.',
inputSchema: { what: z.string() },
annotations: { readOnlyHint: true },
access: { group: 'places', mode: 'read' },
})
doThing() {}
@Resource({ name: 'thing_doc', uri: 'trek://thing', mimeType: 'application/json' })
readThing() {}
@ResourceTemplate({ name: 'thing_by_id', uriTemplate: 'trek://thing/{id}' })
readThingById() {}
@Prompt({ name: 'thing_prompt', description: 'Prompt.', argsSchema: { topic: z.string() } })
promptThing() {}
undecorated() {}
}
class Plain {
@Tool({ name: 'orphan_tool' })
orphan() {}
}
@McpController()
class Child extends Decorated {
@Tool({ name: 'child_tool' })
childThing() {}
}
const ctor = (c: unknown): ClassRef => c as ClassRef;
describe('nest-mcp decorators', () => {
it('@McpController marks the class', () => {
expect(isMcpController(Decorated)).toBe(true);
expect(isMcpController(Plain)).toBe(false);
expect(isMcpController(42)).toBe(false);
});
it('@McpController applies @Injectable (Nest watermark present)', () => {
expect(Reflect.getMetadata('__injectable__', Decorated)).toBe(true);
expect(Reflect.getMetadata('__injectable__', Plain)).toBeUndefined();
});
it('records one entry per decorated method with kind, name and options', () => {
const tool = getEntry(ctor(Decorated), 'doThing');
expect(tool).toMatchObject({
kind: 'tool',
methodName: 'doThing',
options: {
name: 'do_thing',
description: 'Does a thing.',
annotations: { readOnlyHint: true },
access: { group: 'places', mode: 'read' },
},
});
expect(getEntry(ctor(Decorated), 'readThing')).toMatchObject({
kind: 'resource',
options: { name: 'thing_doc', uri: 'trek://thing', mimeType: 'application/json' },
});
expect(getEntry(ctor(Decorated), 'readThingById')).toMatchObject({
kind: 'resourceTemplate',
options: { name: 'thing_by_id', uriTemplate: 'trek://thing/{id}' },
});
expect(getEntry(ctor(Decorated), 'promptThing')).toMatchObject({
kind: 'prompt',
options: { name: 'thing_prompt' },
});
});
it('passes the Zod raw shape through untouched', () => {
const tool = getEntry(ctor(Decorated), 'doThing');
expect(tool?.kind).toBe('tool');
if (tool?.kind === 'tool') {
expect(tool.options.inputSchema?.what).toBeInstanceOf(z.ZodString);
}
});
it('returns nothing for undecorated methods', () => {
expect(getEntry(ctor(Decorated), 'undecorated')).toBeUndefined();
expect(getEntry(ctor(Decorated), 'nope')).toBeUndefined();
});
it('entries decorated on a class without @McpController are still recorded per-method', () => {
// The controller marker gates discovery/registration, not metadata capture.
expect(getEntry(ctor(Plain), 'orphan')).toMatchObject({ kind: 'tool', options: { name: 'orphan_tool' } });
});
it('subclasses see inherited entries through the constructor prototype chain', () => {
expect(getEntry(ctor(Child), 'doThing')).toMatchObject({ options: { name: 'do_thing' } });
expect(getEntry(ctor(Child), 'childThing')).toMatchObject({ options: { name: 'child_tool' } });
// parent does not see the child's entries
expect(getEntry(ctor(Decorated), 'childThing')).toBeUndefined();
});
});