7.3 KiB
@trek/nest-mcp
Decorator-driven MCP registration for NestJS. Domains declare MCP tools, resources and prompts as decorated methods on ordinary Nest providers; a discovery-backed registry collects them at boot and attaches them — filtered by a host-defined access policy — onto each per-session McpServer.
The package is extraction-clean: it imports nothing from @trek/server or @trek/shared and only peer-depends on @nestjs/common, @nestjs/core, @modelcontextprotocol/sdk and zod. It defines no scope semantics of its own — access meaning comes entirely from the host's accessPolicy.
API
Decorators
import { McpController, Tool, Resource, ResourceTemplate, Prompt } from '@trek/nest-mcp';
import { z } from 'zod';
@McpController() // implies @Injectable(); register the class in a module's providers: []
export class ThingsMcp {
constructor(private readonly things: ThingsService) {}
@Tool({
name: 'list_things',
description: 'List all things.',
inputSchema: {}, // ZodRawShape, passed straight to the SDK
annotations: { readOnlyHint: true },
access: { group: 'things', mode: 'read' }, // resolved by the host accessPolicy
})
async listThings(_args: Record<string, never>, ctx: McpContext) {
return { content: [{ type: 'text', text: JSON.stringify(this.things.list(ctx.userId)) }] };
}
}
@Tool(options)— mirrorsserver.registerTool(name,title,description,inputSchema,outputSchema,annotations,_meta). Handler:(args, ctx);argsis{}when noinputSchemawas declared.@Resource(options)— fixed URI (uri,mimeType, …). Handler:(uri: URL, ctx).@ResourceTemplate(options)—uriTemplate(RFC 6570). Handler:(uri: URL, variables, ctx). (Nolist/completetemplate callbacks yet — extend when a domain needs them.)@Prompt(options)— mirrorsregisterPrompt(argsSchemaentries must be string-valued Zod schemas). Handler:(args, ctx).
ctx always takes the SDK extra slot — the last handler argument.
Access control
Every decorator accepts access and when:
- Declarative —
access: { group: 'things', mode: 'read' | 'write' }, resolved by theaccessPolicygiven once atMcpModule.forRoot(...).groupis typed asMcpAccessGroup— plainstringuntil the host augmentsMcpAccessGroupRegistry(see below), after which only the host's registered groups compile. - Predicate —
access: (ctx) => boolean, bypasses the policy. - Availability gate —
when: (ctx) => boolean, evaluated beforeaccess(both must pass). Use it for feature/addon toggles so scope markers stay declarative:when: () => isAddonEnabled(...),access: { group: 'packing', mode: 'read' }. - Omitted
access— the entry is always registered (subject towhen).
Result helpers & annotation presets
Handler-side conveniences, exported from the package root so decorated domains need nothing from the legacy MCP layer:
import { ok, errorResult, demoDenied, TOOL_ANNOTATIONS_READONLY } from '@trek/nest-mcp';
ok({ tags }) // { content: [{ type: 'text', text: <pretty JSON> }] }
errorResult('Tag not found.') // { content: [...], isError: true } — message verbatim
demoDenied() // canned demo-mode write refusal
Six TOOL_ANNOTATIONS_* presets cover the read/write/delete/idempotency matrix (plus OPEN_WORLD variants). Host-specific canned errors (permission wording, RBAC lookups) belong in the host, built on errorResult — see TREK's server/src/mcp/tools/_shared.ts, which re-exports the generic helpers from here.
Fail-fast validation
McpRegistryService runs registry.validate() at boot (onModuleInit), and createTestRegistry runs it on construction, so misconfiguration breaks startup instead of MCP session creation:
- duplicate names per kind (fixed resources: duplicate URIs);
- declarative
accesswithout a configuredaccessPolicy; - declarative
accessrejected by the optional host-suppliedvalidateAccesshook.
All problems are aggregated into one Invalid MCP registry: ... error, so a single boot failure reports every misconfiguration.
validateAccess?: (access, entry) => string | null | undefined (on both McpModule.forRoot(...) and createTestRegistry(...) options) is called once per entry with declarative access — predicates are opaque to the host and skipped. Return a problem description to fail boot, null/undefined to accept:
McpModule.forRoot({
accessPolicy,
validateAccess: ({ group, mode }) =>
hostKnowsGroupMode(group, mode) ? null : `no '${group}:${mode}' scope`,
})
Context typing
The package exports an empty McpContext interface; the host augments it once:
declare module '@trek/nest-mcp' {
interface McpContext {
userId: number;
scopes: string[] | null;
isStaticToken: boolean;
}
}
Access-group typing
Same trick for access.group: the package exports an empty McpAccessGroupRegistry interface, and McpAccessGroup (the type of group) is string until the host augments it — after which every decorator's group is checked against the host's union at compile time:
declare module '@trek/nest-mcp' {
interface McpAccessGroupRegistry extends Record<MyGroupUnion, true> {}
}
Wiring
// AppModule
McpModule.forRoot({ accessPolicy: (access, ctx) => /* host semantics */ })
// wherever per-session servers are built (may be outside Nest — hand the
// registry over after app.init()):
const registry = app.get(McpRegistryService);
registry.attach(server, { userId, scopes, isStaticToken });
registry.list(); // introspection
Testing without Nest
import { createTestRegistry } from '@trek/nest-mcp';
const registry = createTestRegistry([new ThingsMcp(new ThingsService(db))], { accessPolicy });
registry.attach(server, ctx);
Migrating a TREK MCP domain (recipe)
- Create
server/src/nest/<domain>/<domain>.mcp.ts: an@McpController()class injecting the domain's Nest service. Port eachserver.registerTool(...)fromserver/src/mcp/tools/<domain>.tsto a@Tool()method byte-identically (names, descriptions, schemas, annotations, error strings,ok()payloads). - Replace the registrar's
canRead/canWriteregistration-time gates withaccess: { group, mode }(TREK's policy inserver/src/mcp/nest-mcp-policy.tsimplements thescopes.tssemantics). Addon gates becomewhen: () => isAddonEnabled(ADDON_IDS.X)alongside the declarativeaccess. - Add the class to the domain module's
providers: []. - Delete the legacy registrar file and its call in
server/src/mcp/tools.ts. - Add one line constructing the instance in
server/tests/helpers/mcp-test-controllers.tsand keep the domain's existing unit tests green — behavior must be indistinguishable to a client.
Development
npm run build --workspace=nest-mcp # tsc → dist/ (CJS + d.ts)
npm run test --workspace=nest-mcp # vitest (SWC transform for decorator metadata)
npm run typecheck --workspace=nest-mcp
npm run lint:check --workspace=nest-mcp
Note: the MCP SDK's exports map uses extension-less wildcards that TypeScript cannot resolve; tsconfig.json paths and vitest.config.ts aliases point at the CJS dist files (same workaround as server/).