A 5xx stack and its access-log line were two unrelated lines, the exception filter printed the stack with console.error and no request context, and the MCP, WebSocket, plugin RPC and cron entry points logged nothing per call. A self-hosted bug report could not be traced from a failure back to the call that caused it. Every HTTP request now runs under an id held in AsyncLocalStorage: a well-formed incoming X-Request-Id is kept, anything else is replaced by a UUID, and the response carries it in X-Request-Id. The logger tags every line written inside a unit of work with `[http <id>]`. The exception filter leaves a server-side failure on res.locals and the access log writes the request line and the stack as one entry under that id; without an access log watching the response it still prints the error itself. MCP tool, resource and prompt calls (through a new `around` seam on the registry), WebSocket messages, plugin RPC dispatches and cron ticks each run through traceEntry(), which gives the call its own id (remembering the outer one as its parent) and logs one line for it: debug on success, like the HTTP access log, and warn or error with the reason on failure. The call's result and timing are unchanged, a failed cron tick is logged once rather than twice, and a WebSocket handler rejection no longer reaches rxjs's asynchronous rethrow.
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, registered as a strict object
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. A shape is registered asz.strictObject, so an argument the tool never declared is refused (Unrecognized key) andtools/listadvertisesadditionalProperties: false; the SDK's own wrapping would strip it and run the handler regardless. A whole Zod schema is passed through as written.@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.