Files
TREK/server/src/nest-mcp
Maurice 8632f93c37 feat(server): correlate log lines with a request id per entry point
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.
2026-10-08 18:40:15 +02:00
..
2026-08-22 12:55:24 +02:00
2026-08-22 12:55:24 +02:00
2026-08-22 12:55:24 +02:00
2026-08-22 12:55:24 +02:00
2026-08-22 12:55:24 +02:00
2026-08-22 12:55:24 +02:00

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/scopes import in types.ts (which types access.group/mode against 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/validateAccess given at McpModule.forRoot(...) — TREK's live in src/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) — mirrors server.registerTool (name, title, description, inputSchema, outputSchema, annotations, _meta). Handler: (args, ctx); args is {} when no inputSchema was declared. A shape is registered as z.strictObject, so an argument the tool never declared is refused (Unrecognized key) and tools/list advertises additionalProperties: 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). (No list/complete template callbacks yet — extend when a domain needs them.)
  • @Prompt(options) — mirrors registerPrompt (argsSchema entries must be string-valued Zod schemas). Handler: (args, ctx). Prompt arguments arrive as strings, so every argsSchema entry has to accept a string (PromptArgsShape): parse numbers with z.string().regex(...).transform(Number), never z.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 the accessPolicy given once at McpModule.forRoot(...). group and mode are typed against the scope-derived unions in types.ts, so a typo'd group is a compile error in .mcp.ts files.
  • Predicate — access: (ctx) => boolean, bypasses the policy.
  • Availability gate — when: (ctx, self) => boolean, evaluated before access (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' }. self is 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 to when).

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 access without a configured accessPolicy;
  • declarative access rejected by the optional validateAccess hook.

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)

  1. Create server/src/nest/<domain>/<domain>.mcp.ts: an @McpController() class injecting the domain's Nest service. Port each server.registerTool(...) from server/src/mcp/tools/<domain>.ts to a @Tool() method byte-identically (names, descriptions, schemas, annotations, error strings, ok() payloads).
  2. Replace the registrar's canRead/canWrite registration-time gates with access: { group, mode } (TREK's policy in server/src/mcp/nest-mcp-policy.ts implements the scopes.ts semantics). Addon gates become when: (_ctx, self: XMcp) => self.addons.isAddonEnabled(ADDON_IDS.X) alongside the declarative access.
  3. Add the class to the domain module's providers: [].
  4. Delete the legacy registrar file and its call in server/src/mcp/tools.ts.
  5. Add one line constructing the instance in server/tests/helpers/mcp-test-controllers.ts and 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.