mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-20 14:06:27 +00:00
107 lines
3.5 KiB
TypeScript
107 lines
3.5 KiB
TypeScript
import { McpController, Prompt, Resource, ResourceTemplate, Tool } from '../src';
|
|
import { getEntry, isMcpController, type ClassRef } from '../src/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: 'things', 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: 'things', 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();
|
|
});
|
|
});
|