Prompt arguments cross the wire as strings, but the three trip prompts declared tripId as z.number(), so every prompts/get failed with -32602 "expected number, received string", MCP Inspector included. The id is now parsed from the string through a shared tripIdPromptArg (still an integer in the safe range), PromptOptions only admits string-input schemas so the mistake cannot come back unnoticed, and a prompt declared with an empty argsSchema no longer demands an empty arguments object from the client. The prompt tests run through a real in-memory MCP client instead of calling the callbacks directly.
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 the access policy — onto each per-session McpServer.
Formerly the standalone @trek/nest-mcp workspace package; folded into the server 2026-08-22. Two invariants survive the fold:
- Self-contained: this directory imports nothing from the rest of
server/except the type-only../mcp/scopesimport intypes.ts(which typesaccess.group/modeagainst the real scope unions). Keep it that way — new TREK semantics belong in the host layer, not here. - No scope semantics of its own: access meaning comes entirely from the
accessPolicy/validateAccessgiven atMcpModule.forRoot(...)— TREK's live insrc/mcp/nest-mcp-policy.ts.
Tests live in server/tests/unit/nest-mcp/ and run with the normal server suite; coverage is gated at ≥80% in server/vitest.config.ts.
API
Decorators
import { McpController, Tool, Resource, ResourceTemplate, Prompt } from '../../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 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). Prompt arguments arrive as strings, so everyargsSchemaentry has to accept a string (PromptArgsShape): parse numbers withz.string().regex(...).transform(Number), neverz.number(). The handler receives the transformed values.
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(...).groupandmodeare typed against the scope-derived unions intypes.ts, so a typo'd group is a compile error in.mcp.tsfiles. - Predicate —
access: (ctx) => boolean, bypasses the policy. - Availability gate —
when: (ctx, self) => boolean, evaluated beforeaccess(both must pass). Use it for feature/addon toggles so scope markers stay declarative:when: (_ctx, self: PackingMcp) => self.addons.isAddonEnabled(...),access: { group: 'packing', mode: 'read' }.selfis the@McpController()instance, handed in at attach time — the options object is built when the class is defined, so without it a gate can only reach a module-level singleton, and hosts end up constructing a second copy of a service outside their own container to answer a toggle. - Omitted
access— the entry is always registered (subject towhen).
Result helpers & annotation presets
Handler-side conveniences, exported from the barrel so decorated domains need nothing from the legacy MCP layer:
import { ok, errorResult, demoDenied, TOOL_ANNOTATIONS_READONLY } from '../../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). TREK-specific canned errors (permission wording, RBAC lookups) belong in the host layer, built on errorResult — see 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 optionalvalidateAccesshook.
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 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 and access typing
McpContext (the per-session context handed to every handler, predicate and the policy) and the McpAccessGroupRegistry/McpAccessModeRegistry interfaces that type access.group/mode are defined directly in types.ts. Before the fold they were empty interfaces the host augmented via declare module '@trek/nest-mcp'; the TREK shapes are now baked in, with the lockstep asserts in src/mcp/nest-mcp-policy.ts pinning the registries to scopes.ts at compile time.
Wiring
// AppModule
McpModule.forRoot({ accessPolicy: (access, ctx) => /* scope 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 '../../../src/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: (_ctx, self: XMcp) => self.addons.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.
Notes
The MCP SDK's exports map uses extension-less wildcards that TypeScript cannot resolve; server/tsconfig.json paths and server/vitest.config.ts aliases point at the CJS dist files. registry.ts is the only file here importing the SDK.