journeyService (1324 LOC, 36 exports) and journeyShareService (203) become JourneyDomainService and JourneyShareService. Every SQL statement, error string and broadcast is the one that shipped. The two could never be split: the share service gates on isOwner, which lives in the domain service, so it injects it. JourneyDomainModule is a leaf carrying just the two services. Places, assignments and the plugin host each need a handful of journey functions; importing JourneyModule for that would drag MemoriesModule and both photo providers into their graphs. Same shape as TrekPhotosModule. The 23 tools in src/mcp/tools/journey.ts become JourneyMcp. src/mcp/tools/ is down to _shared.ts and registerTools() no longer fans out to anything. journey.bridge.ts keeps four exports, for the journey resources in src/mcp/resources.ts that register at import time. McpAccessMode is host-augmentable now. journey:share is neither read nor write, so the three share tools would have had to be opaque predicates - and predicates bypass the access policy entirely, invisible to the boot-time scope gate. A scope typo in one would ship as a silently ungated tool. McpAccessModeRegistry mirrors McpAccessGroupRegistry; unaugmented, mode stays 'read' | 'write'. Fixes a latent bug in tests/helpers/mcp-test-controllers.ts: it constructed PlacesService with five of eight arguments. tsconfig only includes src, so nothing typechecked it, and the missing dependencies were harmless until the journey skeleton hooks landed on the place write paths. journey.mcp.ts arrived from src/mcp/ at 14.5% branch coverage - the legacy registrar had no test file of its own. 40 tool tests take it to 93.4%, and 25 cases for the skeleton lifecycle, the photo gallery and the preference row take the domain service from 73.9% to 82.8%.
@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/).