feat(mcp): Create nest wrapper for MCP for proper DI (#1708)

This commit is contained in:
jubnl
2026-07-26 11:42:47 +02:00
committed by GitHub
parent 828c1db489
commit 8f3ee53841
45 changed files with 2072 additions and 152 deletions
+1 -1
View File
@@ -107,7 +107,7 @@ jobs:
# Commit and tag
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
git add package.json package-lock.json server/package.json client/package.json shared/package.json nest-mcp/package.json charts/trek/Chart.yaml
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
git push origin main --follow-tags
+47
View File
@@ -10,6 +10,7 @@ on:
- 'server/**'
- 'client/**'
- 'shared/**'
- 'nest-mcp/**'
- '.github/workflows/test.yml'
jobs:
@@ -49,6 +50,41 @@ jobs:
- name: Run tests
run: cd shared && npm test
nest-mcp-package:
name: nest-mcp Package
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-node@v6
with:
node-version: 24
cache: npm
cache-dependency-path: package-lock.json
- name: Install dependencies
run: npm ci --workspace nest-mcp
- name: Ensure @swc/core's Linux binary for unplugin-swc
# Same lockfile quirk as server-tests: the Linux native binary is
# omitted, and nest-mcp's vitest config uses the SWC transform too.
run: |
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
- name: Build
run: npm run build --workspace=nest-mcp
- name: Typecheck
run: cd nest-mcp && npm run typecheck
- name: Lint
run: cd nest-mcp && npm run lint:check
- name: Run tests
run: cd nest-mcp && npm test
server-tests:
name: Server Tests
runs-on: ubuntu-latest
@@ -77,9 +113,20 @@ jobs:
- name: Build shared
run: npm run build --workspace=shared
- name: Build nest-mcp
# Server typecheck/build resolve @trek/nest-mcp's types from its dist
# (tests alias the package source, but tsc does not).
run: npm run build --workspace=nest-mcp
- name: Build server (tsc -> dist)
run: cd server && npm run build
- name: Smoke production require chain
# Vitest aliases @trek/nest-mcp to its source, so only this exercises
# what production runs: nest-mcp's built dist resolving the MCP SDK's
# subpath exports through the tsconfig-paths/register runtime hook.
run: cd server && node --require tsconfig-paths/register -e "require('@trek/nest-mcp')"
- name: Typecheck
run: cd server && npm run typecheck
+8
View File
@@ -31,9 +31,12 @@ FROM node:24-alpine AS server-builder
WORKDIR /app
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY nest-mcp/package.json ./nest-mcp/
COPY server/package.json ./server/
RUN npm ci --workspace=server --ignore-scripts
COPY --from=shared-builder /app/shared/dist ./shared/dist
COPY nest-mcp/ ./nest-mcp/
RUN npm run build --workspace=nest-mcp
COPY server/ ./server/
RUN npm run build --workspace=server
@@ -44,6 +47,7 @@ WORKDIR /app
# Workspace manifests only — source never enters this stage.
COPY package.json package-lock.json ./
COPY shared/package.json ./shared/
COPY nest-mcp/package.json ./nest-mcp/
COPY server/package.json ./server/
RUN apt-get update && \
@@ -84,6 +88,10 @@ COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
COPY server/reset-admin.js ./server/reset-admin.js
COPY --from=shared-builder /app/shared/dist ./shared/dist
# server dist requires @trek/nest-mcp at runtime through the workspace symlink;
# its dist's MCP SDK subpath requires ride the same tsconfig-paths/register
# hook the server already boots with.
COPY --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist
COPY --from=client-builder /app/client/dist ./server/public
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
+2
View File
@@ -0,0 +1,2 @@
dist
node_modules
+18
View File
@@ -0,0 +1,18 @@
{
"printWidth": 120,
"singleQuote": true,
"trailingComma": "all",
"plugins": [
"prettier-plugin-organize-imports",
"@trivago/prettier-plugin-sort-imports"
],
"importOrder": [
"^[a-zA-Z]",
"^@/.*"
],
"importOrderSeparation": true,
"importOrderParserPlugins": [
"typescript",
"decorators-legacy"
]
}
+122
View File
@@ -0,0 +1,122 @@
# @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
```ts
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)` — mirrors `server.registerTool` (`name`, `title`, `description`, `inputSchema`, `outputSchema`, `annotations`, `_meta`). Handler: `(args, ctx)`; `args` is `{}` when no `inputSchema` was declared.
- `@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)`.
`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(...)`.
- **Predicate** — `access: (ctx) => boolean`, bypasses the policy.
- **Availability gate** — `when: (ctx) => boolean`, evaluated *before* `access` (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 to `when`).
### Result helpers & annotation presets
Handler-side conveniences, exported from the package root so decorated domains need nothing from the legacy MCP layer:
```ts
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 `access` without a configured `accessPolicy`.
### Context typing
The package exports an empty `McpContext` interface; the host augments it once:
```ts
declare module '@trek/nest-mcp' {
interface McpContext {
userId: number;
scopes: string[] | null;
isStaticToken: boolean;
}
}
```
### Wiring
```ts
// 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
```ts
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)
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: () => 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.
## Development
```bash
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/`).
+26
View File
@@ -0,0 +1,26 @@
import js from '@eslint/js';
import gitignore from 'eslint-config-flat-gitignore';
import eslintConfigPrettier from 'eslint-config-prettier';
import eslintPluginPrettier from 'eslint-plugin-prettier';
import tseslint from 'typescript-eslint';
export default tseslint.config(
gitignore({ strict: false }),
js.configs.recommended,
...tseslint.configs.recommended,
eslintConfigPrettier,
{
plugins: {
prettier: eslintPluginPrettier,
},
rules: {
'prettier/prettier': 'error',
'@typescript-eslint/no-unused-vars': ['error', { argsIgnorePattern: '^_', varsIgnorePattern: '^_' }],
'@typescript-eslint/no-explicit-any': 'error',
},
},
{
ignores: ['node_modules', 'dist', 'coverage'],
},
);
+51
View File
@@ -0,0 +1,51 @@
{
"name": "@trek/nest-mcp",
"version": "3.4.1",
"private": true,
"description": "Decorator-driven MCP tool/resource/prompt registration for NestJS — discovery-based registry with host-defined access control.",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage",
"lint": "eslint --fix \"src/**/*.ts\" \"tests/**/*.ts\"",
"lint:check": "eslint .",
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\""
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"zod": "^4.0.0"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs/common": "^11.1.24",
"@nestjs/core": "^11.1.24",
"@nestjs/testing": "^11.1.24",
"@swc/core": "^1.15.40",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@vitest/coverage-istanbul": "^4.1.9",
"eslint": "^10.3.0",
"eslint-config-flat-gitignore": "^2.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"prettier": "3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"unplugin-swc": "^1.5.9",
"vitest": "^4.1.9",
"zod": "^4.3.6"
}
}
+42
View File
@@ -0,0 +1,42 @@
import { addEntry, markController, type ClassRef } from './metadata';
import type { PromptOptions, ResourceOptions, ResourceTemplateOptions, ToolOptions } from './types';
import { Injectable } from '@nestjs/common';
/**
* Marks a class as an MCP controller. Implies `@Injectable()` — register the
* class in a module's `providers: []` and inject domain services through the
* constructor as usual. Discovery scans ONLY marked classes (explicit opt-in,
* deterministic registry).
*/
export function McpController(): ClassDecorator {
return (target) => {
Injectable()(target);
markController(target as unknown as ClassRef);
};
}
function mcpMethodDecorator(entry: (methodName: string) => Parameters<typeof addEntry>[1]): MethodDecorator {
return (target, propertyKey) => {
addEntry((target as { constructor: unknown }).constructor as ClassRef, entry(String(propertyKey)));
};
}
/** Registers the method as an MCP tool. Handler signature: `(args, ctx)`. */
export function Tool(options: ToolOptions): MethodDecorator {
return mcpMethodDecorator((methodName) => ({ kind: 'tool', methodName, options }));
}
/** Registers the method as a fixed-URI MCP resource. Handler signature: `(uri: URL, ctx)`. */
export function Resource(options: ResourceOptions): MethodDecorator {
return mcpMethodDecorator((methodName) => ({ kind: 'resource', methodName, options }));
}
/** Registers the method as an MCP resource template. Handler signature: `(uri: URL, variables, ctx)`. */
export function ResourceTemplate(options: ResourceTemplateOptions): MethodDecorator {
return mcpMethodDecorator((methodName) => ({ kind: 'resourceTemplate', methodName, options }));
}
/** Registers the method as an MCP prompt. Handler signature: `(args, ctx)`. */
export function Prompt(options: PromptOptions): MethodDecorator {
return mcpMethodDecorator((methodName) => ({ kind: 'prompt', methodName, options }));
}
+64
View File
@@ -0,0 +1,64 @@
/**
* Result-shape helpers and tool-annotation presets shared by MCP handlers.
* App-agnostic on purpose — host-specific canned errors (permission wording,
* RBAC lookups, broadcasts) belong in the host, built on `errorResult`.
*/
/** The text-content result shape MCP tool handlers return. */
export interface McpTextResult {
content: { type: 'text'; text: string }[];
isError?: boolean;
}
/** Success result: `data` pretty-printed as a single JSON text block. */
export function ok(data: unknown): McpTextResult {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
}
/** Error result with a verbatim message (no formatting, no prefix). */
export function errorResult(text: string): McpTextResult {
return { content: [{ type: 'text' as const, text }], isError: true };
}
/** Canned refusal for write tools when the acting user is a demo account. */
export function demoDenied(): McpTextResult {
return errorResult('Write operations are disabled in demo mode.');
}
export const TOOL_ANNOTATIONS_READONLY = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_OPEN_WORLD_READONLY = {
...TOOL_ANNOTATIONS_READONLY,
openWorldHint: true,
} as const;
export const TOOL_ANNOTATIONS_WRITE = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_DELETE = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_NON_IDEMPOTENT = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT = {
...TOOL_ANNOTATIONS_NON_IDEMPOTENT,
openWorldHint: true,
} as const;
+32
View File
@@ -0,0 +1,32 @@
export { McpController, Prompt, Resource, ResourceTemplate, Tool } from './decorators';
export {
demoDenied,
errorResult,
ok,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
type McpTextResult,
} from './helpers';
export { McpRegistryService } from './mcp-registry.service';
export { McpModule } from './mcp.module';
export { McpRegistry, type McpRegistryListing, type McpRegistryOptions } from './registry';
export { createTestRegistry } from './testing';
export {
MCP_MODULE_OPTIONS,
type McpAccess,
type McpAccessPolicy,
type McpAccessPredicate,
type McpContext,
type McpDeclarativeAccess,
type McpEntry,
type McpEntryKind,
type McpModuleOptions,
type PromptOptions,
type ResourceOptions,
type ResourceTemplateOptions,
type ToolOptions,
} from './types';
+37
View File
@@ -0,0 +1,37 @@
import { isMcpController } from './metadata';
import { McpRegistry } from './registry';
import { MCP_MODULE_OPTIONS, type McpModuleOptions } from './types';
import { Inject, Injectable, type OnModuleInit } from '@nestjs/common';
import { DiscoveryService, MetadataScanner } from '@nestjs/core';
/**
* DI-discovered registry: at boot, scans every provider marked with
* `@McpController()` and records its decorated methods. Attach it to a
* per-session `McpServer` with `attach(server, ctx)`.
*/
@Injectable()
export class McpRegistryService extends McpRegistry implements OnModuleInit {
constructor(
private readonly discovery: DiscoveryService,
private readonly scanner: MetadataScanner,
@Inject(MCP_MODULE_OPTIONS) options: McpModuleOptions,
) {
super(options);
}
onModuleInit(): void {
// A provider can be wrapped once per module that lists it — dedupe by
// instance so an entry is never recorded (and later registered) twice.
const seen = new Set<object>();
for (const wrapper of this.discovery.getProviders()) {
const { instance, metatype } = wrapper;
if (!instance || typeof instance !== 'object' || seen.has(instance)) continue;
if (!isMcpController(metatype)) continue;
seen.add(instance);
this.register(instance, this.scanner.getAllMethodNames(Object.getPrototypeOf(instance)));
}
// Fail app boot on duplicate names / policy-less declarative access
// instead of failing per-session at attach().
this.validate();
}
}
+22
View File
@@ -0,0 +1,22 @@
import { McpRegistryService } from './mcp-registry.service';
import { MCP_MODULE_OPTIONS, type McpModuleOptions } from './types';
import { Module, type DynamicModule } from '@nestjs/common';
import { DiscoveryModule } from '@nestjs/core';
@Module({})
export class McpModule {
/**
* Registers the discovery-backed `McpRegistryService` globally. The
* `accessPolicy` resolves every declarative `access: { group, mode }`
* marker — the package itself defines no scope semantics.
*/
static forRoot(options: McpModuleOptions = {}): DynamicModule {
return {
module: McpModule,
global: true,
imports: [DiscoveryModule],
providers: [{ provide: MCP_MODULE_OPTIONS, useValue: options }, McpRegistryService],
exports: [McpRegistryService],
};
}
}
+47
View File
@@ -0,0 +1,47 @@
import type { McpEntry } from './types';
/**
* Metadata store. A package-private WeakMap/WeakSet keyed by class
* constructor instead of reflect-metadata: it has zero runtime
* prerequisites (no polyfill import order to get right), works in bare
* vitest workers and in `createTestRegistry` without a Nest app, and is
* trivially extraction-clean.
*/
/** A class constructor, without using the banned bare `Function` type. */
export type ClassRef = abstract new (...args: never[]) => unknown;
const controllers = new WeakSet<ClassRef>();
const entries = new WeakMap<ClassRef, Map<string, McpEntry>>();
export function markController(ctor: ClassRef): void {
controllers.add(ctor);
}
export function isMcpController(ctor: unknown): ctor is ClassRef {
return typeof ctor === 'function' && controllers.has(ctor as ClassRef);
}
export function addEntry(ctor: ClassRef, entry: McpEntry): void {
let map = entries.get(ctor);
if (!map) {
map = new Map();
entries.set(ctor, map);
}
map.set(entry.methodName, entry);
}
/**
* Look up the entry recorded for `methodName`, walking the constructor
* prototype chain so inherited decorated methods are found on subclasses.
*/
export function getEntry(ctor: ClassRef, methodName: string): McpEntry | undefined {
let current: ClassRef | null = ctor;
while (current) {
const found = entries.get(current)?.get(methodName);
if (found) return found;
const parent: unknown = Object.getPrototypeOf(current);
current = typeof parent === 'function' ? (parent as ClassRef) : null;
}
return undefined;
}
+275
View File
@@ -0,0 +1,275 @@
import { getEntry, isMcpController, type ClassRef } from './metadata';
import type {
McpAccess,
McpAccessPolicy,
McpContext,
McpEntry,
McpEntryKind,
PromptOptions,
ResourceOptions,
ResourceTemplateOptions,
ToolOptions,
} from './types';
import { ResourceTemplate as SdkResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp';
import type { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import type { ZodRawShape } from 'zod';
interface BoundEntry {
entry: McpEntry;
instance: object;
}
export interface McpRegistryListing {
kind: McpEntryKind;
name: string;
className: string;
methodName: string;
access?: McpAccess;
}
export interface McpRegistryOptions {
accessPolicy?: McpAccessPolicy;
}
type AnyHandler = (this: unknown, ...handlerArgs: unknown[]) => unknown;
/**
* Structural view of the SDK registration surface. The SDK's real signatures
* are generic over the Zod shapes; the registry passes schemas straight
* through and binds `ctx` where the SDK would pass `extra`, so it needs (and
* exposes) none of that type-level machinery.
*/
interface LooseRegistrar {
registerTool(name: string, config: Record<string, unknown>, cb: (...cbArgs: unknown[]) => unknown): unknown;
registerResource(
name: string,
uriOrTemplate: string | SdkResourceTemplate,
config: Record<string, unknown>,
cb: (...cbArgs: unknown[]) => unknown,
): unknown;
registerPrompt(name: string, config: Record<string, unknown>, cb: (...cbArgs: unknown[]) => unknown): unknown;
}
function describeBound({ entry, instance }: BoundEntry): string {
return `${(instance as { constructor: { name: string } }).constructor.name}.${entry.methodName}`;
}
/** Own + inherited method names, excluding constructor and Object.prototype. */
function enumerateMethodNames(instance: object): string[] {
const names = new Set<string>();
let proto: object | null = Object.getPrototypeOf(instance);
while (proto && proto !== Object.prototype) {
for (const name of Object.getOwnPropertyNames(proto)) {
if (name === 'constructor') continue;
const descriptor = Object.getOwnPropertyDescriptor(proto, name);
if (typeof descriptor?.value === 'function') names.add(name);
}
proto = Object.getPrototypeOf(proto);
}
return [...names];
}
/**
* Holds every decorated MCP entry bound to its provider instance and attaches
* them, filtered by access, onto a per-session `McpServer`. Plain class with
* no DI so `createTestRegistry` can build one without a Nest app;
* `McpRegistryService` is the DI-discovered subclass.
*/
export class McpRegistry {
private readonly bound: BoundEntry[] = [];
private readonly accessPolicy?: McpAccessPolicy;
constructor(options: McpRegistryOptions = {}) {
this.accessPolicy = options.accessPolicy;
}
/**
* Records every decorated method of `instance`. `methodNames` (e.g. from
* Nest's MetadataScanner) narrows the scan; omitted, the instance's
* prototype chain is enumerated directly.
*/
register(instance: object, methodNames?: readonly string[]): void {
const ctor = (instance as { constructor: unknown }).constructor;
if (!isMcpController(ctor)) {
throw new Error(`${(ctor as ClassRef).name} is not decorated with @McpController()`);
}
const names = methodNames ?? enumerateMethodNames(instance);
for (const name of new Set(names)) {
const entry = getEntry(ctor, name);
if (entry) this.bound.push({ entry, instance });
}
}
/**
* Registers every entry passing its access check onto `server`, binding
* `ctx` as the handler's last argument (in the SDK `extra` slot).
*/
attach(server: McpServer, ctx: McpContext): void {
const registrar = server as unknown as LooseRegistrar;
for (const { entry, instance } of this.bound) {
if (!this.allowed(entry, ctx)) continue;
const handler = (instance as unknown as Record<string, AnyHandler>)[entry.methodName];
switch (entry.kind) {
case 'tool':
this.attachTool(registrar, entry.options, instance, handler, ctx);
break;
case 'resource':
this.attachResource(registrar, entry.options, instance, handler, ctx);
break;
case 'resourceTemplate':
this.attachResourceTemplate(registrar, entry.options, instance, handler, ctx);
break;
case 'prompt':
this.attachPrompt(registrar, entry.options, instance, handler, ctx);
break;
}
}
}
/**
* Fail-fast configuration check — call once after all instances are
* registered (McpRegistryService runs it at boot, createTestRegistry on
* construction) so misconfiguration breaks app startup instead of MCP
* session creation:
* - duplicate names per kind (fixed resources: duplicate URIs), which the
* SDK would otherwise reject per-session at attach();
* - declarative access without a configured accessPolicy.
*/
validate(): void {
const seen = new Map<string, BoundEntry>();
const duplicates: string[] = [];
const unresolvable: string[] = [];
for (const bound of this.bound) {
const { entry } = bound;
const key =
entry.kind === 'resource' ? `resource uri "${entry.options.uri}"` : `${entry.kind} "${entry.options.name}"`;
const prior = seen.get(key);
if (prior) duplicates.push(`${key} (${describeBound(prior)} and ${describeBound(bound)})`);
else seen.set(key, bound);
const access = entry.options.access;
if (!this.accessPolicy && access !== undefined && typeof access !== 'function') {
unresolvable.push(`${key} (${describeBound(bound)})`);
}
}
const problems: string[] = [];
if (duplicates.length) problems.push(`duplicate MCP registrations: ${duplicates.join(', ')}`);
if (unresolvable.length) {
problems.push(
`entries declare declarative access but no accessPolicy was configured ` +
`(McpModule.forRoot({ accessPolicy })): ${unresolvable.join(', ')}`,
);
}
if (problems.length) throw new Error(`Invalid MCP registry: ${problems.join('; ')}`);
}
/** Introspection: every recorded entry, regardless of access. */
list(): McpRegistryListing[] {
return this.bound.map(({ entry, instance }) => ({
kind: entry.kind,
name: entry.options.name,
className: (instance as { constructor: { name: string } }).constructor.name,
methodName: entry.methodName,
access: entry.options.access,
}));
}
private allowed(entry: McpEntry, ctx: McpContext): boolean {
if (entry.options.when && !entry.options.when(ctx)) return false;
const access = entry.options.access;
if (access === undefined) return true;
if (typeof access === 'function') return access(ctx);
if (!this.accessPolicy) {
// Fail loud: silently registering would leak a gated entry, silently
// skipping would be undebuggable.
throw new Error(
`MCP ${entry.kind} "${entry.options.name}" declares declarative access ` +
`but no accessPolicy was configured (McpModule.forRoot({ accessPolicy }))`,
);
}
return this.accessPolicy(access, ctx);
}
private attachTool(
registrar: LooseRegistrar,
options: ToolOptions,
instance: object,
handler: AnyHandler,
ctx: McpContext,
): void {
const config: Record<string, unknown> = {
title: options.title,
description: options.description,
inputSchema: options.inputSchema as ZodRawShape | undefined,
outputSchema: options.outputSchema,
annotations: options.annotations,
_meta: options._meta,
};
// The SDK invokes the callback as (args, extra) when inputSchema is
// present (even an empty shape) and as (extra) otherwise — normalize so
// the decorated method always receives (args, ctx).
const cb =
options.inputSchema !== undefined
? (args: unknown, _extra: unknown) => handler.call(instance, args, ctx)
: (_extra: unknown) => handler.call(instance, {}, ctx);
registrar.registerTool(options.name, config, cb);
}
private attachResource(
registrar: LooseRegistrar,
options: ResourceOptions,
instance: object,
handler: AnyHandler,
ctx: McpContext,
): void {
const metadata: Record<string, unknown> = {
title: options.title,
description: options.description,
mimeType: options.mimeType,
_meta: options._meta,
};
registrar.registerResource(options.name, options.uri, metadata, (uri: unknown, _extra: unknown) =>
handler.call(instance, uri, ctx),
);
}
private attachResourceTemplate(
registrar: LooseRegistrar,
options: ResourceTemplateOptions,
instance: object,
handler: AnyHandler,
ctx: McpContext,
): void {
const metadata: Record<string, unknown> = {
title: options.title,
description: options.description,
mimeType: options.mimeType,
_meta: options._meta,
};
registrar.registerResource(
options.name,
new SdkResourceTemplate(options.uriTemplate, { list: undefined }),
metadata,
(uri: unknown, variables: unknown, _extra: unknown) => handler.call(instance, uri, variables, ctx),
);
}
private attachPrompt(
registrar: LooseRegistrar,
options: PromptOptions,
instance: object,
handler: AnyHandler,
ctx: McpContext,
): void {
const config: Record<string, unknown> = {
title: options.title,
description: options.description,
argsSchema: options.argsSchema,
};
const cb =
options.argsSchema !== undefined
? (args: unknown, _extra: unknown) => handler.call(instance, args, ctx)
: (_extra: unknown) => handler.call(instance, {}, ctx);
registrar.registerPrompt(options.name, config, cb);
}
}
+14
View File
@@ -0,0 +1,14 @@
import { McpRegistry, type McpRegistryOptions } from './registry';
/**
* Builds a registry from hand-constructed `@McpController()` instances —
* no Nest app required. For test harnesses that assemble a bare `McpServer`
* (e.g. over an in-memory transport).
*/
export function createTestRegistry(instances: readonly object[], options: McpRegistryOptions = {}): McpRegistry {
const registry = new McpRegistry(options);
for (const instance of instances) registry.register(instance);
// Same fail-fast contract as McpRegistryService.onModuleInit.
registry.validate();
return registry;
}
+98
View File
@@ -0,0 +1,98 @@
import type { ZodRawShape } from 'zod';
/**
* The per-session context handed to `McpRegistry.attach(server, ctx)` and
* forwarded to every handler (last argument) and access predicate/policy.
*
* The package defines NO context fields of its own — the host application
* augments this interface once:
*
* declare module '@trek/nest-mcp' {
* interface McpContext { userId: number; scopes: string[] | null }
* }
*/
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface McpContext {}
/**
* Declarative access marker resolved by the host-supplied `accessPolicy`.
* The package attaches no semantics to `group`/`mode` — the policy does.
*/
export interface McpDeclarativeAccess {
group: string;
mode: 'read' | 'write';
}
/** Predicate escape hatch — bypasses the policy entirely. */
export type McpAccessPredicate = (ctx: McpContext) => boolean;
export type McpAccess = McpDeclarativeAccess | McpAccessPredicate;
/**
* Host-supplied resolver for declarative access, given once at
* `McpModule.forRoot({ accessPolicy })`. Return true to register the entry
* on the session's server.
*/
export type McpAccessPolicy = (access: McpDeclarativeAccess, ctx: McpContext) => boolean;
interface McpEntryOptionsBase {
name: string;
title?: string;
description?: string;
/**
* Availability gate evaluated BEFORE `access` (short-circuits it) — for
* "is this feature on at all" checks like addon toggles. Keeps `access`
* purely about permissions, so declarative scope markers still compose
* with runtime feature gates instead of being replaced by predicates.
*/
when?: (ctx: McpContext) => boolean;
/** Omitted ⇒ the entry is always registered (subject to `when`). */
access?: McpAccess;
}
/**
* Mirrors the SDK's `registerTool` config. `inputSchema` is a ZodRawShape
* passed straight through — the SDK does the parsing/validation.
* `annotations`/`_meta` are kept structural so the package's public d.ts
* never imports deep SDK subpaths.
*/
export interface ToolOptions extends McpEntryOptionsBase {
inputSchema?: ZodRawShape;
outputSchema?: ZodRawShape;
annotations?: Record<string, unknown>;
_meta?: Record<string, unknown>;
}
/** Mirrors `registerResource(name, uri, metadata, cb)` for a fixed URI. */
export interface ResourceOptions extends McpEntryOptionsBase {
uri: string;
mimeType?: string;
_meta?: Record<string, unknown>;
}
/** Mirrors `registerResource(name, new ResourceTemplate(uriTemplate), metadata, cb)`. */
export interface ResourceTemplateOptions extends McpEntryOptionsBase {
uriTemplate: string;
mimeType?: string;
_meta?: Record<string, unknown>;
}
/** Mirrors the SDK's `registerPrompt` config (argsSchema entries must be string-valued Zod schemas). */
export interface PromptOptions extends McpEntryOptionsBase {
argsSchema?: ZodRawShape;
}
export type McpEntryKind = 'tool' | 'resource' | 'resourceTemplate' | 'prompt';
export type McpEntry =
| { kind: 'tool'; methodName: string; options: ToolOptions }
| { kind: 'resource'; methodName: string; options: ResourceOptions }
| { kind: 'resourceTemplate'; methodName: string; options: ResourceTemplateOptions }
| { kind: 'prompt'; methodName: string; options: PromptOptions };
export interface McpModuleOptions {
accessPolicy?: McpAccessPolicy;
}
/** Injection token for the options object given to `McpModule.forRoot()`. */
export const MCP_MODULE_OPTIONS = Symbol('MCP_MODULE_OPTIONS');
+225
View File
@@ -0,0 +1,225 @@
import {
createTestRegistry,
McpController,
McpRegistry,
Prompt,
Resource,
ResourceTemplate,
Tool,
type McpAccessPolicy,
type McpContext,
} from '../src';
import { createAttachHarness, type AttachHarness, type TestCtx } from './harness';
import { afterEach, describe, expect, it } from 'vitest';
import { z } from 'zod';
class Greeter {
greet(name: string): string {
return `hello ${name}`;
}
}
@McpController()
class FixtureMcp {
constructor(private readonly greeter: Greeter) {}
@Tool({
name: 'read_tool',
description: 'Reads.',
inputSchema: { name: z.string() },
annotations: { readOnlyHint: true },
access: { group: 'g', mode: 'read' },
})
async readTool({ name }: { name: string }, ctx: TestCtx) {
return {
content: [{ type: 'text', text: JSON.stringify({ greeting: this.greeter.greet(name), userId: ctx.userId }) }],
};
}
@Tool({ name: 'write_tool', inputSchema: {}, access: { group: 'g', mode: 'write' } })
async writeTool(_args: Record<string, never>, ctx: TestCtx) {
return { content: [{ type: 'text', text: `wrote as ${ctx.userId}` }] };
}
@Tool({ name: 'predicate_tool', access: (ctx: McpContext) => (ctx as TestCtx).allow === true })
async predicateTool(args: Record<string, never>, ctx: TestCtx) {
return { content: [{ type: 'text', text: JSON.stringify({ args, userId: ctx.userId }) }] };
}
@Tool({ name: 'open_tool' })
async openTool(args: Record<string, never>, ctx: TestCtx) {
return { content: [{ type: 'text', text: JSON.stringify({ args, userId: ctx.userId }) }] };
}
@Resource({ name: 'fixture_doc', uri: 'test://doc', mimeType: 'text/plain', access: { group: 'g', mode: 'read' } })
async readDoc(uri: URL, ctx: TestCtx) {
return { contents: [{ uri: uri.href, text: `doc for ${ctx.userId}` }] };
}
@ResourceTemplate({ name: 'fixture_item', uriTemplate: 'test://item/{id}' })
async readItem(uri: URL, variables: { id: string }, ctx: TestCtx) {
return { contents: [{ uri: uri.href, text: `item ${variables.id} for ${ctx.userId}` }] };
}
@Prompt({ name: 'fixture_prompt', description: 'Prompts.', argsSchema: { topic: z.string() } })
async fixturePrompt({ topic }: { topic: string }, ctx: TestCtx) {
return { messages: [{ role: 'user', content: { type: 'text', text: `${topic} for ${ctx.userId}` } }] };
}
}
@McpController()
class DeclarativeOnly {
@Tool({ name: 'gated_tool', access: { group: 'g', mode: 'read' } })
async gated() {
return { content: [{ type: 'text', text: 'gated' }] };
}
}
@McpController()
class WhenMcp {
@Tool({
name: 'when_scoped_tool',
access: { group: 'g', mode: 'read' },
when: (ctx: McpContext) => (ctx as TestCtx).allow === true,
})
async whenScoped() {
return { content: [{ type: 'text', text: 'ok' }] };
}
@Tool({ name: 'when_only_tool', when: (ctx: McpContext) => (ctx as TestCtx).allow === true })
async whenOnly() {
return { content: [{ type: 'text', text: 'ok' }] };
}
// Ungated: keeps the tools capability advertised even when every gated
// entry is filtered (an SDK server with zero tools rejects tools/list).
@Tool({ name: 'probe_tool' })
async probe() {
return { content: [{ type: 'text', text: 'ok' }] };
}
}
const policy: McpAccessPolicy = ({ mode }, ctx) =>
mode === 'write' ? (ctx as TestCtx).canWrite === true : (ctx as TestCtx).canRead === true;
const buildRegistry = () => createTestRegistry([new FixtureMcp(new Greeter())], { accessPolicy: policy });
const textOf = (result: unknown): string => {
const content = (result as { content: { type: string; text: string }[] }).content;
const item = content.find((c) => c.type === 'text');
if (!item) throw new Error('no text content');
return item.text;
};
describe('McpRegistry.attach', () => {
let harness: AttachHarness | undefined;
afterEach(async () => {
await harness?.cleanup();
harness = undefined;
});
it('registers everything for a ctx passing all access checks', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: true, allow: true });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['open_tool', 'predicate_tool', 'read_tool', 'write_tool']);
const resources = (await harness.client.listResources()).resources.map((r) => r.name);
expect(resources).toEqual(['fixture_doc']);
const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name);
expect(templates).toEqual(['fixture_item']);
const prompts = (await harness.client.listPrompts()).prompts.map((p) => p.name);
expect(prompts).toEqual(['fixture_prompt']);
});
it('filters declarative access through the policy per mode', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: false, allow: false });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['open_tool', 'read_tool']);
});
it('filters everything gated when the ctx grants nothing, keeping ungated entries', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 7 });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['open_tool']);
expect((await harness.client.listResources()).resources).toEqual([]);
const templates = (await harness.client.listResourceTemplates()).resourceTemplates.map((t) => t.name);
expect(templates).toEqual(['fixture_item']);
});
it('exposes tool metadata (description, annotations, input schema) to the client', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 7, canRead: true, canWrite: true, allow: true });
const read = (await harness.client.listTools()).tools.find((t) => t.name === 'read_tool');
expect(read?.description).toBe('Reads.');
expect(read?.annotations).toMatchObject({ readOnlyHint: true });
expect(read?.inputSchema).toMatchObject({ type: 'object', properties: { name: { type: 'string' } } });
});
it('binds (args, ctx) and preserves `this` on tool handlers', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 42, canRead: true, canWrite: true, allow: true });
const result = await harness.client.callTool({ name: 'read_tool', arguments: { name: 'trek' } });
expect(JSON.parse(textOf(result))).toEqual({ greeting: 'hello trek', userId: 42 });
});
it('normalizes args to {} for tools declared without an inputSchema', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 42 });
const result = await harness.client.callTool({ name: 'open_tool', arguments: {} });
expect(JSON.parse(textOf(result))).toEqual({ args: {}, userId: 42 });
});
it('serves fixed resources with (uri, ctx)', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 9, canRead: true });
const result = await harness.client.readResource({ uri: 'test://doc' });
expect(result.contents[0]).toMatchObject({ uri: 'test://doc', text: 'doc for 9' });
});
it('serves resource templates with (uri, variables, ctx)', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 9 });
const result = await harness.client.readResource({ uri: 'test://item/42' });
expect(result.contents[0]).toMatchObject({ uri: 'test://item/42', text: 'item 42 for 9' });
});
it('serves prompts with (args, ctx)', async () => {
harness = await createAttachHarness(buildRegistry(), { userId: 3 });
const result = await harness.client.getPrompt({ name: 'fixture_prompt', arguments: { topic: 'packing' } });
expect(result.messages[0]?.content).toMatchObject({ type: 'text', text: 'packing for 3' });
});
it('ANDs `when` in front of declarative access (both must pass)', async () => {
const registry = createTestRegistry([new WhenMcp()], { accessPolicy: policy });
harness = await createAttachHarness(registry, { userId: 7, canRead: true, allow: true });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['probe_tool', 'when_only_tool', 'when_scoped_tool']);
});
it('skips entries whose `when` gate fails, even when access would pass', async () => {
const registry = createTestRegistry([new WhenMcp()], { accessPolicy: policy });
harness = await createAttachHarness(registry, { userId: 7, canRead: true, allow: false });
expect((await harness.client.listTools()).tools.map((t) => t.name)).toEqual(['probe_tool']);
});
it('skips when-gated entries whose access fails, keeping when-only ones', async () => {
const registry = createTestRegistry([new WhenMcp()], { accessPolicy: policy });
harness = await createAttachHarness(registry, { userId: 7, canRead: false, allow: true });
const tools = (await harness.client.listTools()).tools.map((t) => t.name).sort();
expect(tools).toEqual(['probe_tool', 'when_only_tool']);
});
it('createTestRegistry fails fast on declarative access without an accessPolicy', () => {
expect(() => createTestRegistry([new DeclarativeOnly()])).toThrow(
/declare declarative access but no accessPolicy was configured/,
);
});
it('attach itself still guards declarative access without a policy (defense in depth)', () => {
// Bypass createTestRegistry's validate() by assembling the registry by hand.
const registry = new McpRegistry();
registry.register(new DeclarativeOnly());
expect(() =>
registry.attach(
// attach never gets far enough to need a live server here
{} as Parameters<typeof registry.attach>[0],
{ userId: 1 } as McpContext,
),
).toThrow(/declares declarative access but no accessPolicy was configured/);
});
});
@@ -0,0 +1,40 @@
import { createTestRegistry, McpController, McpRegistry, Tool, type McpAccessPolicy } from '../src';
import type { TestCtx } from './harness';
import { describe, expect, it } from 'vitest';
@McpController()
class One {
@Tool({ name: 'one_tool' })
one() {}
}
@McpController()
class Two {
@Tool({ name: 'two_tool', access: { group: 't', mode: 'write' } })
two() {}
}
describe('createTestRegistry', () => {
it('builds a registry from hand-constructed instances without a Nest app', () => {
const registry = createTestRegistry([new One(), new Two()], { accessPolicy: () => true });
expect(registry).toBeInstanceOf(McpRegistry);
expect(registry.list().map((e) => e.name)).toEqual(['one_tool', 'two_tool']);
});
it('validates on construction (declarative access needs a policy)', () => {
expect(() => createTestRegistry([new One(), new Two()])).toThrow(/no accessPolicy was configured/);
});
it('forwards the accessPolicy option', () => {
const calls: string[] = [];
const policy: McpAccessPolicy = (access, ctx) => {
calls.push(`${access.group}:${access.mode}:${(ctx as TestCtx).userId}`);
return false;
};
const registry = createTestRegistry([new Two()], { accessPolicy: policy });
// A denied entry is filtered before any server interaction, so a stub suffices.
registry.attach({} as Parameters<McpRegistry['attach']>[0], { userId: 1 } as Parameters<McpRegistry['attach']>[1]);
expect(calls).toEqual(['t:write:1']);
});
});
+106
View File
@@ -0,0 +1,106 @@
import { McpController, Prompt, Resource, ResourceTemplate, Tool } from '../src';
import { getEntry, isMcpController, type ClassRef } from '../src/metadata';
import { describe, expect, it } from 'vitest';
import { z } from 'zod';
@McpController()
class Decorated {
@Tool({
name: 'do_thing',
description: 'Does a thing.',
inputSchema: { what: z.string() },
annotations: { readOnlyHint: true },
access: { group: 'things', mode: 'read' },
})
doThing() {}
@Resource({ name: 'thing_doc', uri: 'trek://thing', mimeType: 'application/json' })
readThing() {}
@ResourceTemplate({ name: 'thing_by_id', uriTemplate: 'trek://thing/{id}' })
readThingById() {}
@Prompt({ name: 'thing_prompt', description: 'Prompt.', argsSchema: { topic: z.string() } })
promptThing() {}
undecorated() {}
}
class Plain {
@Tool({ name: 'orphan_tool' })
orphan() {}
}
@McpController()
class Child extends Decorated {
@Tool({ name: 'child_tool' })
childThing() {}
}
const ctor = (c: unknown): ClassRef => c as ClassRef;
describe('nest-mcp decorators', () => {
it('@McpController marks the class', () => {
expect(isMcpController(Decorated)).toBe(true);
expect(isMcpController(Plain)).toBe(false);
expect(isMcpController(42)).toBe(false);
});
it('@McpController applies @Injectable (Nest watermark present)', () => {
expect(Reflect.getMetadata('__injectable__', Decorated)).toBe(true);
expect(Reflect.getMetadata('__injectable__', Plain)).toBeUndefined();
});
it('records one entry per decorated method with kind, name and options', () => {
const tool = getEntry(ctor(Decorated), 'doThing');
expect(tool).toMatchObject({
kind: 'tool',
methodName: 'doThing',
options: {
name: 'do_thing',
description: 'Does a thing.',
annotations: { readOnlyHint: true },
access: { group: 'things', mode: 'read' },
},
});
expect(getEntry(ctor(Decorated), 'readThing')).toMatchObject({
kind: 'resource',
options: { name: 'thing_doc', uri: 'trek://thing', mimeType: 'application/json' },
});
expect(getEntry(ctor(Decorated), 'readThingById')).toMatchObject({
kind: 'resourceTemplate',
options: { name: 'thing_by_id', uriTemplate: 'trek://thing/{id}' },
});
expect(getEntry(ctor(Decorated), 'promptThing')).toMatchObject({
kind: 'prompt',
options: { name: 'thing_prompt' },
});
});
it('passes the Zod raw shape through untouched', () => {
const tool = getEntry(ctor(Decorated), 'doThing');
expect(tool?.kind).toBe('tool');
if (tool?.kind === 'tool') {
expect(tool.options.inputSchema?.what).toBeInstanceOf(z.ZodString);
}
});
it('returns nothing for undecorated methods', () => {
expect(getEntry(ctor(Decorated), 'undecorated')).toBeUndefined();
expect(getEntry(ctor(Decorated), 'nope')).toBeUndefined();
});
it('entries decorated on a class without @McpController are still recorded per-method', () => {
// The controller marker gates discovery/registration, not metadata capture.
expect(getEntry(ctor(Plain), 'orphan')).toMatchObject({ kind: 'tool', options: { name: 'orphan_tool' } });
});
it('subclasses see inherited entries through the constructor prototype chain', () => {
expect(getEntry(ctor(Child), 'doThing')).toMatchObject({ options: { name: 'do_thing' } });
expect(getEntry(ctor(Child), 'childThing')).toMatchObject({ options: { name: 'child_tool' } });
// parent does not see the child's entries
expect(getEntry(ctor(Decorated), 'childThing')).toBeUndefined();
});
});
+108
View File
@@ -0,0 +1,108 @@
import { McpController, McpModule, McpRegistryService, Tool, type McpAccessPolicy } from '../src';
import { createAttachHarness, type AttachHarness, type TestCtx } from './harness';
import { Injectable, Module } from '@nestjs/common';
import { Test } from '@nestjs/testing';
import { afterEach, describe, expect, it } from 'vitest';
import { z } from 'zod';
@Injectable()
class CounterService {
private n = 0;
next(): number {
return ++this.n;
}
}
@McpController()
class CounterMcp {
constructor(private readonly counter: CounterService) {}
@Tool({ name: 'count', inputSchema: { by: z.number().optional() }, access: { group: 'c', mode: 'read' } })
async count(_args: { by?: number }, ctx: TestCtx) {
return { content: [{ type: 'text', text: JSON.stringify({ value: this.counter.next(), userId: ctx.userId }) }] };
}
}
@Injectable()
class PlainProvider {
ignoredMethod() {}
}
@Module({ providers: [CounterService, CounterMcp], exports: [] })
class CounterModule {}
const policy: McpAccessPolicy = (_access, ctx) => (ctx as TestCtx).canRead === true;
describe('McpRegistryService discovery', () => {
let harness: AttachHarness | undefined;
afterEach(async () => {
await harness?.cleanup();
harness = undefined;
});
it('discovers only @McpController providers, across feature modules', async () => {
const moduleRef = await Test.createTestingModule({
imports: [McpModule.forRoot({ accessPolicy: policy }), CounterModule],
providers: [PlainProvider],
}).compile();
await moduleRef.init();
const registry = moduleRef.get(McpRegistryService);
expect(registry.list()).toEqual([
expect.objectContaining({ kind: 'tool', name: 'count', className: 'CounterMcp', methodName: 'count' }),
]);
await moduleRef.close();
});
it('registers boot-discovered entries with working constructor injection', async () => {
const moduleRef = await Test.createTestingModule({
imports: [McpModule.forRoot({ accessPolicy: policy }), CounterModule],
}).compile();
await moduleRef.init();
const registry = moduleRef.get(McpRegistryService);
harness = await createAttachHarness(registry, { userId: 5, canRead: true });
const result = await harness.client.callTool({ name: 'count', arguments: {} });
const text = (result.content as { type: string; text: string }[]).find((c) => c.type === 'text');
expect(JSON.parse(text?.text ?? '')).toEqual({ value: 1, userId: 5 });
await moduleRef.close();
});
it('fails app boot (onModuleInit) on duplicate tool names', async () => {
@McpController()
class CountClone {
@Tool({ name: 'count' })
async countAgain() {
return { content: [{ type: 'text', text: 'clone' }] };
}
}
const moduleRef = await Test.createTestingModule({
imports: [McpModule.forRoot({ accessPolicy: policy }), CounterModule],
providers: [CountClone],
}).compile();
await expect(moduleRef.init()).rejects.toThrow(/duplicate MCP registrations: tool "count"/);
});
it('boots without an accessPolicy and still attaches predicate/ungated entries', async () => {
@McpController()
class OpenMcp {
@Tool({ name: 'open' })
async open() {
return { content: [{ type: 'text', text: 'ok' }] };
}
}
const moduleRef = await Test.createTestingModule({
imports: [McpModule.forRoot()],
providers: [OpenMcp],
}).compile();
await moduleRef.init();
const registry = moduleRef.get(McpRegistryService);
harness = await createAttachHarness(registry, {});
expect((await harness.client.listTools()).tools.map((t) => t.name)).toEqual(['open']);
await moduleRef.close();
});
});
+45
View File
@@ -0,0 +1,45 @@
import type { McpContext, McpRegistry } from '../src';
import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
/** Test context shape — stands in for whatever a host app augments McpContext with. */
export interface TestCtx {
userId?: number;
canRead?: boolean;
canWrite?: boolean;
allow?: boolean;
}
export const asCtx = (ctx: TestCtx): McpContext => ctx as McpContext;
export interface AttachHarness {
client: Client;
server: McpServer;
cleanup: () => Promise<void>;
}
/** Bare McpServer + Client over an in-memory transport with the registry attached. */
export async function createAttachHarness(registry: McpRegistry, ctx: TestCtx): Promise<AttachHarness> {
const server = new McpServer({ name: 'nest-mcp-test', version: '1.0.0' });
registry.attach(server, asCtx(ctx));
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
const client = new Client({ name: 'test-client', version: '1.0.0' });
await server.connect(serverTransport);
await client.connect(clientTransport);
const cleanup = async () => {
try {
await client.close();
} catch {
/* ignore */
}
try {
await server.close();
} catch {
/* ignore */
}
};
return { client, server, cleanup };
}
+72
View File
@@ -0,0 +1,72 @@
import {
demoDenied,
errorResult,
ok,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
} from '../src';
import { describe, expect, it } from 'vitest';
describe('result helpers', () => {
it('ok() wraps data as one pretty-printed JSON text block, without isError', () => {
const result = ok({ a: 1 });
expect(result).toEqual({ content: [{ type: 'text', text: JSON.stringify({ a: 1 }, null, 2) }] });
expect('isError' in result).toBe(false);
});
it('errorResult() carries the message verbatim with isError', () => {
expect(errorResult('Tag not found.')).toEqual({
content: [{ type: 'text', text: 'Tag not found.' }],
isError: true,
});
});
it('demoDenied() uses the canned demo-mode message', () => {
expect(demoDenied()).toEqual({
content: [{ type: 'text', text: 'Write operations are disabled in demo mode.' }],
isError: true,
});
});
});
describe('tool annotation presets', () => {
it('encode the read/write/delete/idempotency matrix', () => {
expect(TOOL_ANNOTATIONS_READONLY).toEqual({
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
});
expect(TOOL_ANNOTATIONS_WRITE).toEqual({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
});
expect(TOOL_ANNOTATIONS_DELETE).toEqual({
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
});
expect(TOOL_ANNOTATIONS_NON_IDEMPOTENT).toEqual({
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
});
});
it('open-world variants only flip openWorldHint', () => {
expect(TOOL_ANNOTATIONS_OPEN_WORLD_READONLY).toEqual({ ...TOOL_ANNOTATIONS_READONLY, openWorldHint: true });
expect(TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT).toEqual({
...TOOL_ANNOTATIONS_NON_IDEMPOTENT,
openWorldHint: true,
});
});
});
+108
View File
@@ -0,0 +1,108 @@
import { McpController, McpRegistry, Resource, Tool } from '../src';
import { describe, expect, it } from 'vitest';
@McpController()
class Alpha {
@Tool({ name: 'alpha_one' })
one() {}
@Tool({ name: 'alpha_two', access: { group: 'a', mode: 'write' } })
two() {}
helper() {}
}
@McpController()
class Beta extends Alpha {
@Tool({ name: 'beta_one' })
betaOne() {}
}
class NotAController {
@Tool({ name: 'nope' })
nope() {}
}
describe('McpRegistry.register / list', () => {
it('records every decorated method and lists it with class/method names', () => {
const registry = new McpRegistry();
registry.register(new Alpha());
expect(registry.list()).toEqual([
expect.objectContaining({ kind: 'tool', name: 'alpha_one', className: 'Alpha', methodName: 'one' }),
expect.objectContaining({
kind: 'tool',
name: 'alpha_two',
className: 'Alpha',
methodName: 'two',
access: { group: 'a', mode: 'write' },
}),
]);
});
it('includes inherited decorated methods', () => {
const registry = new McpRegistry();
registry.register(new Beta());
const names = registry.list().map((e) => e.name);
expect(names).toContain('beta_one');
expect(names).toContain('alpha_one');
expect(names).toContain('alpha_two');
});
it('respects an explicit methodNames narrowing and dedupes repeats', () => {
const registry = new McpRegistry();
registry.register(new Alpha(), ['one', 'one', 'helper']);
expect(registry.list().map((e) => e.name)).toEqual(['alpha_one']);
});
it('throws for instances whose class is not @McpController-decorated', () => {
const registry = new McpRegistry();
expect(() => registry.register(new NotAController())).toThrow(/not decorated with @McpController/);
});
});
@McpController()
class AlphaClone {
@Tool({ name: 'alpha_one' })
cloned() {}
}
@McpController()
class DuplicateResources {
@Resource({ name: 'doc_a', uri: 'test://same' })
a() {}
@Resource({ name: 'doc_b', uri: 'test://same' })
b() {}
}
describe('McpRegistry.validate', () => {
it('passes for a well-formed registry', () => {
const registry = new McpRegistry({ accessPolicy: () => true });
registry.register(new Alpha());
expect(() => registry.validate()).not.toThrow();
});
it('reports duplicate tool names across classes with their origins', () => {
const registry = new McpRegistry({ accessPolicy: () => true });
registry.register(new Alpha());
registry.register(new AlphaClone());
expect(() => registry.validate()).toThrow(
/duplicate MCP registrations: tool "alpha_one" \(Alpha\.one and AlphaClone\.cloned\)/,
);
});
it('reports duplicate fixed-resource URIs (names may differ)', () => {
const registry = new McpRegistry();
registry.register(new DuplicateResources());
expect(() => registry.validate()).toThrow(/duplicate MCP registrations: resource uri "test:\/\/same"/);
});
it('reports declarative access without a configured accessPolicy', () => {
const registry = new McpRegistry();
registry.register(new Alpha());
expect(() => registry.validate()).toThrow(
/declare declarative access but no accessPolicy was configured.*tool "alpha_two" \(Alpha\.two\)/,
);
});
});
+3
View File
@@ -0,0 +1,3 @@
// Required by @nestjs/testing (Test.createTestingModule) — the package's own
// decorators/registry deliberately do NOT need it.
import 'reflect-metadata';
+11
View File
@@ -0,0 +1,11 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"rootDir": "./src",
"outDir": "./dist",
"sourceMap": false
},
"include": ["src"],
"exclude": ["node_modules", "dist", "tests"]
}
+30
View File
@@ -0,0 +1,30 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"types": ["node"],
"experimentalDecorators": true,
"emitDecoratorMetadata": true,
"baseUrl": ".",
"strict": true,
"noEmit": true,
"declaration": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"ignoreDeprecations": "6.0",
// The MCP SDK's package.json uses a wildcard exports pattern with extension-less targets
// (e.g. "./*": "./dist/esm/*") which TypeScript cannot resolve — it only strips .js suffixes.
// These paths manually redirect to the CJS dist until the SDK fixes its exports map
// (same workaround as server/tsconfig.json; node_modules are hoisted to the repo root).
"paths": {
"@modelcontextprotocol/sdk/server/mcp": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.d.ts"],
"@modelcontextprotocol/sdk/client/index": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.d.ts"],
"@modelcontextprotocol/sdk/inMemory": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.d.ts"],
"@modelcontextprotocol/sdk/types": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/types.d.ts"]
}
},
"include": ["src", "tests"],
"exclude": ["node_modules", "dist"]
}
+62
View File
@@ -0,0 +1,62 @@
import swc from 'unplugin-swc';
import { defineConfig } from 'vitest/config';
export default defineConfig({
// SWC transform so NestJS decorator metadata is emitted in tests
// (vitest's default esbuild does not emit it -> type-based DI would break).
plugins: [
swc.vite({
jsc: {
parser: { syntax: 'typescript', decorators: true },
transform: { legacyDecorator: true, decoratorMetadata: true },
keepClassNames: true,
},
}),
],
test: {
root: '.',
include: ['tests/**/*.test.ts'],
globals: true,
setupFiles: ['tests/setup.ts'],
testTimeout: 15000,
hookTimeout: 15000,
pool: 'forks',
silent: false,
reporters: ['verbose'],
coverage: {
// istanbul instruments the source directly, independent of the SWC
// transform pipeline (the v8 provider under-reports on decorator output —
// same rationale as server/vitest.config.ts).
provider: 'istanbul',
reporter: ['lcov', 'text'],
reportsDirectory: './coverage',
include: ['src/**/*.ts'],
thresholds: {
'src/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 },
},
},
},
resolve: {
alias: {
// MCP SDK's exports map uses extension-less wildcard targets that neither
// Node nor Vite can resolve. Point directly at the CJS dist files.
// Paths are relative to the monorepo root (packages are hoisted there).
'@modelcontextprotocol/sdk/server/mcp': new URL(
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/mcp.js',
import.meta.url,
).pathname,
'@modelcontextprotocol/sdk/client/index': new URL(
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js',
import.meta.url,
).pathname,
'@modelcontextprotocol/sdk/inMemory': new URL(
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js',
import.meta.url,
).pathname,
'@modelcontextprotocol/sdk/types': new URL(
'../node_modules/@modelcontextprotocol/sdk/dist/cjs/types.js',
import.meta.url,
).pathname,
},
},
});
+55
View File
@@ -9,6 +9,7 @@
"version": "3.4.1",
"workspaces": [
"client",
"nest-mcp",
"server",
"shared"
],
@@ -186,6 +187,55 @@
}
}
},
"nest-mcp": {
"name": "@trek/nest-mcp",
"version": "3.4.1",
"devDependencies": {
"@eslint/js": "^10.0.1",
"@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs/common": "^11.1.24",
"@nestjs/core": "^11.1.24",
"@nestjs/testing": "^11.1.24",
"@swc/core": "^1.15.40",
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
"@vitest/coverage-istanbul": "^4.1.9",
"eslint": "^10.3.0",
"eslint-config-flat-gitignore": "^2.3.0",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-prettier": "^5.5.5",
"prettier": "3.8.3",
"prettier-plugin-organize-imports": "^4.3.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"typescript": "^6.0.2",
"typescript-eslint": "^8.58.2",
"unplugin-swc": "^1.5.9",
"vitest": "^4.1.9",
"zod": "^4.3.6"
},
"peerDependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"@nestjs/common": "^11.0.0",
"@nestjs/core": "^11.0.0",
"zod": "^4.0.0"
}
},
"nest-mcp/node_modules/prettier": {
"version": "3.8.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/@adobe/css-tools": {
"version": "4.5.0",
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
@@ -7123,6 +7173,10 @@
"resolved": "client",
"link": true
},
"node_modules/@trek/nest-mcp": {
"resolved": "nest-mcp",
"link": true
},
"node_modules/@trek/server": {
"resolved": "server",
"link": true
@@ -21328,6 +21382,7 @@
"@nestjs/platform-express": "^11.1.24",
"@nestjs/swagger": "^11.4.5",
"@simplewebauthn/server": "^13.1.2",
"@trek/nest-mcp": "*",
"@trek/shared": "*",
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
+7 -6
View File
@@ -4,6 +4,7 @@
"version": "3.4.1",
"workspaces": [
"client",
"nest-mcp",
"server",
"shared"
],
@@ -15,14 +16,14 @@
"version:preminor": "npm version preminor --preid=beta --workspaces --include-workspace-root --no-git-tag-version",
"version:prepatch": "npm version prepatch --preid=alpha --workspaces --include-workspace-root --no-git-tag-version",
"version:prerelease": "npm version prerelease --preid=pre --workspaces --include-workspace-root --no-git-tag-version",
"dev": "npm run build --workspace=shared && concurrently --names shared,server,client \"npm run build:watch --workspace=shared\" \"npm run dev --workspace=server\" \"npm run dev --workspace=client\"",
"build": "npm run build --workspace=shared && npm run build --workspace=server && npm run build --workspace=client",
"test": "npm run test --workspace=shared && npm run test --workspace=server && npm run test --workspace=client",
"dev": "npm run build --workspace=shared && npm run build --workspace=nest-mcp && concurrently --names shared,server,client \"npm run build:watch --workspace=shared\" \"npm run dev --workspace=server\" \"npm run dev --workspace=client\"",
"build": "npm run build --workspace=shared && npm run build --workspace=nest-mcp && npm run build --workspace=server && npm run build --workspace=client",
"test": "npm run test --workspace=shared && npm run test --workspace=nest-mcp && npm run test --workspace=server && npm run test --workspace=client",
"test:cov": "npm run test:coverage --workspace=server && npm run test:coverage --workspace=client",
"test:e2e": "npm run test:e2e --workspace=server",
"lint": "npm run lint --workspace=shared && npm run lint --workspace=server && npm run lint --workspace=client",
"format": "npm run format --workspace=shared && npm run format --workspace=server && npm run format --workspace=client",
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
"lint": "npm run lint --workspace=shared && npm run lint --workspace=nest-mcp && npm run lint --workspace=server && npm run lint --workspace=client",
"format": "npm run format --workspace=shared && npm run format --workspace=nest-mcp && npm run format --workspace=server && npm run format --workspace=client",
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=nest-mcp && npm run format:check --workspace=server && npm run format:check --workspace=client"
},
"devDependencies": {
"concurrently": "^10.0.4",
+1
View File
@@ -29,6 +29,7 @@
"@nestjs/platform-express": "^11.1.24",
"@nestjs/swagger": "^11.4.5",
"@simplewebauthn/server": "^13.1.2",
"@trek/nest-mcp": "*",
"@trek/shared": "*",
"archiver": "^6.0.1",
"bcryptjs": "^2.4.3",
+5
View File
@@ -8,6 +8,8 @@ import { applyGlobalMiddleware } from './middleware/globalMiddleware';
import { applyPlatformUploads, applyPlatformTransport, applyPlatformStatic } from './nest/platform/platform.routes';
import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
import { setupApiDocs } from './nest/platform/api-docs';
import { McpRegistryService } from '@trek/nest-mcp';
import { setMcpRegistry } from './mcp/registry-handoff';
/**
* Builds the unified TREK NestJS application that serves the ENTIRE surface — the
@@ -56,5 +58,8 @@ export async function buildApp(): Promise<INestApplication> {
applyPlatformStatic(instance);
if (apiDocsEnabled()) setupApiDocs(app);
await app.init();
// The /mcp handler is mounted pre-init (step 3) and has no DI access — hand
// it the boot-discovered registry now that the container is fully built.
setMcpRegistry(app.get(McpRegistryService));
return app;
}
+24
View File
@@ -0,0 +1,24 @@
import type { McpAccessPolicy } from '@trek/nest-mcp';
import { canRead, canWrite } from './scopes';
/**
* TREK's context shape for @trek/nest-mcp handlers, predicates and the
* access policy — mirrors what registerTools() receives per session.
*/
declare module '@trek/nest-mcp' {
interface McpContext {
userId: number;
scopes: string[] | null;
isStaticToken: boolean;
}
}
/**
* Resolves declarative `access: { group, mode }` markers with the exact
* scopes.ts semantics the legacy registrars used at registration time:
* null scopes ⇒ full access; read ⇒ `group:read` OR `group:write`;
* write ⇒ `group:write`. Single source for production (AppModule) and the
* MCP test harness.
*/
export const trekMcpAccessPolicy: McpAccessPolicy = ({ group, mode }, ctx) =>
mode === 'write' ? canWrite(ctx.scopes, group) : canRead(ctx.scopes, group);
+20
View File
@@ -0,0 +1,20 @@
import type { McpRegistry } from '@trek/nest-mcp';
/**
* Hands the Nest-discovered MCP registry to the non-Nest MCP handler.
*
* mcpHandler is mounted on the raw Express instance BEFORE app.init()
* (load-bearing composition order in bootstrap.ts), so it cannot reach the
* DI container itself — bootstrap.ts pushes the registry here right after
* init. registerTools() degrades gracefully while unset (direct callers
* like unit tests attach their own via createTestRegistry + setMcpRegistry).
*/
let registry: McpRegistry | null = null;
export function setMcpRegistry(value: McpRegistry): void {
registry = value;
}
export function getMcpRegistry(): McpRegistry | null {
return registry;
}
+8 -3
View File
@@ -3,7 +3,6 @@ import { registerTodoTools } from './tools/todos';
import { registerAssignmentTools } from './tools/assignments';
import { registerJourneyTools } from './tools/journey';
import { registerReservationTools } from './tools/reservations';
import { registerTagTools } from './tools/tags';
import { registerMapsWeatherTools } from './tools/mapsWeather';
import { registerNotificationTools } from './tools/notifications';
import { registerAtlasTools } from './tools/atlas';
@@ -18,6 +17,7 @@ import { registerTransportTools } from './tools/transports';
import { registerTransitTools } from './tools/transit';
import { registerVacayTools } from './tools/vacay';
import { registerMcpPrompts } from './tools/prompts';
import { getMcpRegistry } from './registry-handoff';
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
registerTripTools(server, userId, scopes, getDeprecationNotice);
@@ -36,8 +36,6 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
registerAssignmentTools(server, userId, scopes);
registerTagTools(server, userId, scopes);
registerMapsWeatherTools(server, userId, scopes);
registerNotificationTools(server, userId, scopes);
@@ -57,4 +55,11 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
registerTodoTools(server, userId, scopes);
registerMcpPrompts(server, userId, isStaticToken);
// Decorator-registered domains (@trek/nest-mcp) — migrating off the legacy
// registrar fan-out above, one domain at a time. Unset registry (direct
// callers without a Nest app, e.g. unit tests) ⇒ skip; the test harness
// attaches its own via createTestRegistry + setMcpRegistry.
const registry = getMcpRegistry();
if (registry) registry.attach(server, { userId, scopes, isStaticToken });
}
+19 -52
View File
@@ -1,7 +1,23 @@
import { errorResult } from '@trek/nest-mcp';
import { db } from '../../db/database';
import { checkPermission } from '../../services/permissions';
import { broadcast } from '../../websocket';
// Generic MCP result helpers and annotation presets live in @trek/nest-mcp
// (single source for the legacy registrars below and the decorator-based
// domains). Re-exported so registrar files keep importing from here.
export {
demoDenied,
errorResult,
ok,
TOOL_ANNOTATIONS_DELETE,
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
TOOL_ANNOTATIONS_READONLY,
TOOL_ANNOTATIONS_WRITE,
} from '@trek/nest-mcp';
export function safeBroadcast(tripId: number, event: string, payload: Record<string, unknown>): void {
try {
broadcast(tripId, event, { ...payload, _source: 'mcp' });
@@ -12,57 +28,12 @@ export function safeBroadcast(tripId: number, event: string, payload: Record<str
export const MAX_MCP_TRIP_DAYS = 90;
export const TOOL_ANNOTATIONS_READONLY = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_OPEN_WORLD_READONLY = {
...TOOL_ANNOTATIONS_READONLY,
openWorldHint: true,
} as const;
export const TOOL_ANNOTATIONS_WRITE = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_DELETE = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_NON_IDEMPOTENT = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
} as const;
export const TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT = {
...TOOL_ANNOTATIONS_NON_IDEMPOTENT,
openWorldHint: true,
} as const;
export function demoDenied() {
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
}
export function noAccess() {
return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true };
return errorResult('Trip not found or access denied.');
}
export function permissionDenied() {
return {
content: [{ type: 'text' as const, text: 'You do not have permission to perform this action on this trip.' }],
isError: true,
};
return errorResult('You do not have permission to perform this action on this trip.');
}
/**
@@ -87,9 +58,5 @@ export function isAdminUser(userId: number): boolean {
/** Error response for admin-only tools, reproducing the REST `{ error: 'Admin access required' }` string. */
export function adminRequired() {
return { content: [{ type: 'text' as const, text: 'Admin access required' }], isError: true };
}
export function ok(data: unknown) {
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
return errorResult('Admin access required');
}
-84
View File
@@ -1,84 +0,0 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import { listTags, createTag, getTagByIdAndUser, updateTag, deleteTag } from '../../nest/tags/tags.bridge';
import {
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, ok,
} from './_shared';
import { canRead, canWrite } from '../scopes';
export function registerTagTools(server: McpServer, userId: number, scopes: string[] | null): void {
const R = canRead(scopes, 'places');
const W = canWrite(scopes, 'places');
// --- TAGS ---
if (R) server.registerTool(
'list_tags',
{
description: 'List all tags belonging to the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const tags = listTags(userId);
return ok({ tags });
}
);
if (W) server.registerTool(
'create_tag',
{
description: 'Create a new tag (user-scoped label for places).',
inputSchema: {
name: z.string().min(1).max(100),
color: z.string().optional().describe('Hex color string e.g. #6366f1'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ name, color }) => {
if (isDemoUser(userId)) return demoDenied();
const tag = createTag(userId, name, color);
return ok({ tag });
}
);
if (W) server.registerTool(
'update_tag',
{
description: 'Update the name or color of an existing tag.',
inputSchema: {
tagId: z.number().int().positive(),
name: z.string().optional(),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tagId, name, color }) => {
if (isDemoUser(userId)) return demoDenied();
if (!getTagByIdAndUser(tagId, userId)) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
const tag = updateTag(tagId, name, color);
if (!tag) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
return ok({ tag });
}
);
if (W) server.registerTool(
'delete_tag',
{
description: 'Delete a tag (removes it from all places it was attached to).',
inputSchema: {
tagId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tagId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!getTagByIdAndUser(tagId, userId)) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true };
deleteTag(tagId);
return ok({ success: true });
}
);
}
+3 -1
View File
@@ -43,6 +43,8 @@ import { OauthModule } from './oauth/oauth.module';
import { AdminModule } from './admin/admin.module';
import { AddonsModule } from './addons/addons.module';
import { PluginsModule } from './plugins/plugins.module';
import { McpModule } from '@trek/nest-mcp';
import { trekMcpAccessPolicy } from '../mcp/nest-mcp-policy';
import { TrekExceptionFilter } from './common/trek-exception.filter';
import { SpaFallbackFilter } from './platform/spa-fallback.filter';
import { IdempotencyInterceptor } from './common/idempotency.interceptor';
@@ -53,7 +55,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
* migrated.
*/
@Module({
imports: [AppConfigModule, DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, PluginsModule, BookingImportModule],
imports: [AppConfigModule, DatabaseModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy }), WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, PluginsModule, BookingImportModule],
controllers: [HealthController],
providers: [
HealthService,
+4 -3
View File
@@ -5,9 +5,10 @@ import { TagsService } from './tags.service';
/**
* Non-Nest entry point for the tags domain — for code running OUTSIDE the Nest
* container (MCP tools, plugin RPC host). Exports the legacy
* services/tagService function names 1:1 so repointing a consumer is an
* import-path-only diff. Inside the container, inject TagsService instead.
* container (currently only the plugin RPC host; the MCP tools moved to the
* DI-discovered tags.mcp.ts). Exports the legacy services/tagService function
* names 1:1 so repointing a consumer is an import-path-only diff. Inside the
* container, inject TagsService instead.
*
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
* the shared better-sqlite3 singleton (same pattern as
+83
View File
@@ -0,0 +1,83 @@
import {
McpController, Tool, type McpContext,
TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE,
TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
demoDenied, errorResult, ok,
} from '@trek/nest-mcp';
import { z } from 'zod';
import { isDemoUser } from '../../services/authService';
import { TagsService } from './tags.service';
/**
* Tags MCP tools — pilot for the decorator-driven registry. Ported 1:1 from
* the legacy src/mcp/tools/tags.ts registrar: identical names, descriptions,
* schemas, annotations, scope gating (places read/write via the declarative
* access markers + trekMcpAccessPolicy) and error/payload shapes.
*/
@McpController()
export class TagsMcp {
constructor(private readonly tags: TagsService) {}
@Tool({
name: 'list_tags',
description: 'List all tags belonging to the current user.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'places', mode: 'read' },
})
async listTags(_args: Record<string, never>, ctx: McpContext) {
const tags = this.tags.list(ctx.userId);
return ok({ tags });
}
@Tool({
name: 'create_tag',
description: 'Create a new tag (user-scoped label for places).',
inputSchema: {
name: z.string().min(1).max(100),
color: z.string().optional().describe('Hex color string e.g. #6366f1'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
access: { group: 'places', mode: 'write' },
})
async createTag({ name, color }: { name: string; color?: string }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
const tag = this.tags.create(ctx.userId, name, color);
return ok({ tag });
}
@Tool({
name: 'update_tag',
description: 'Update the name or color of an existing tag.',
inputSchema: {
tagId: z.number().int().positive(),
name: z.string().optional(),
color: z.string().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
access: { group: 'places', mode: 'write' },
})
async updateTag({ tagId, name, color }: { tagId: number; name?: string; color?: string }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.tags.getByIdAndUser(tagId, ctx.userId)) return errorResult('Tag not found.');
const tag = this.tags.update(tagId, name, color);
if (!tag) return errorResult('Tag not found.');
return ok({ tag });
}
@Tool({
name: 'delete_tag',
description: 'Delete a tag (removes it from all places it was attached to).',
inputSchema: {
tagId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
access: { group: 'places', mode: 'write' },
})
async deleteTag({ tagId }: { tagId: number }, ctx: McpContext) {
if (isDemoUser(ctx.userId)) return demoDenied();
if (!this.tags.getByIdAndUser(tagId, ctx.userId)) return errorResult('Tag not found.');
this.tags.remove(tagId);
return ok({ success: true });
}
}
+2 -1
View File
@@ -1,10 +1,11 @@
import { Module } from '@nestjs/common';
import { TagsController } from './tags.controller';
import { TagsMcp } from './tags.mcp';
import { TagsService } from './tags.service';
/** Tags domain (L5 leaf module). Registered in AppModule. */
@Module({
controllers: [TagsController],
providers: [TagsService],
providers: [TagsService, TagsMcp],
})
export class TagsModule {}
+10 -1
View File
@@ -15,6 +15,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index';
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
import { registerResources } from '../../src/mcp/resources';
import { registerTools } from '../../src/mcp/tools';
import { setMcpRegistry } from '../../src/mcp/registry-handoff';
import { createMcpTestRegistry } from './mcp-test-controllers';
export interface McpHarness {
client: Client;
@@ -40,7 +42,14 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
if (withResources) registerResources(server, userId);
if (withTools) registerTools(server, userId, scopes ?? null, isStaticToken);
if (withTools) {
// In production bootstrap.ts hands the Nest-discovered registry to
// registerTools after app.init(); the harness has no Nest app, so it
// builds the same registry by hand (see mcp-test-controllers.ts).
// registerTools' own ctx construction stays exercised.
setMcpRegistry(createMcpTestRegistry());
registerTools(server, userId, scopes ?? null, isStaticToken);
}
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
@@ -0,0 +1,23 @@
import { createTestRegistry, type McpRegistry } from '@trek/nest-mcp';
import { db } from '../../src/db/database';
import { trekMcpAccessPolicy } from '../../src/mcp/nest-mcp-policy';
import { DatabaseService } from '../../src/nest/database/database.service';
import { TagsMcp } from '../../src/nest/tags/tags.mcp';
import { TagsService } from '../../src/nest/tags/tags.service';
/**
* Hand-wired counterpart of the boot-time discovery in McpRegistryService,
* for the no-Nest MCP harness. One line per migrated domain — add the new
* @McpController instance here when a domain moves off the legacy registrar
* fan-out. Constructing against the `db` Proxy keeps per-file vi.mock's of
* src/db/database flowing through (same pattern as tags.bridge.ts).
*/
export function createMcpTestRegistry(): McpRegistry {
const dbService = new DatabaseService(db);
return createTestRegistry(
[
new TagsMcp(new TagsService(dbService)),
],
{ accessPolicy: trekMcpAccessPolicy },
);
}
@@ -178,6 +178,21 @@ describe('Tool: update_tag', () => {
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Demo Tag', '#aaaaaa');
const tagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'update_tag',
arguments: { tagId, name: 'Blocked' },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT name FROM tags WHERE id = ?').get(tagId)).toEqual({ name: 'Demo Tag' });
});
});
});
// ---------------------------------------------------------------------------
@@ -199,6 +214,76 @@ describe('Tool: delete_tag', () => {
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(tagId)).toBeUndefined();
});
});
it('returns isError for non-existent tagId', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_tag',
arguments: { tagId: 99999 },
});
expect(result.isError).toBe(true);
});
});
it('blocks demo user', async () => {
process.env.DEMO_MODE = 'true';
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Demo Tag', '#aaaaaa');
const tagId = r.lastInsertRowid as number;
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'delete_tag',
arguments: { tagId },
});
expect(result.isError).toBe(true);
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(tagId)).toBeDefined();
});
});
});
// ---------------------------------------------------------------------------
// tags scope gating (places:read / places:write, registration-time)
// ---------------------------------------------------------------------------
describe('Tags tools: scope gating', () => {
const TAG_TOOLS = ['list_tags', 'create_tag', 'update_tag', 'delete_tag'];
async function listToolNames(userId: number, scopes: string[] | null): Promise<string[]> {
const h = await createMcpHarness({ userId, withResources: false, scopes });
try {
return (await h.client.listTools()).tools.map((t) => t.name);
} finally {
await h.cleanup();
}
}
it('registers all four tag tools with null scopes (full access)', async () => {
const { user } = createUser(testDb);
const names = await listToolNames(user.id, null);
for (const tool of TAG_TOOLS) expect(names).toContain(tool);
});
it('registers only list_tags with places:read', async () => {
const { user } = createUser(testDb);
const names = await listToolNames(user.id, ['places:read']);
expect(names).toContain('list_tags');
expect(names).not.toContain('create_tag');
expect(names).not.toContain('update_tag');
expect(names).not.toContain('delete_tag');
});
it('registers all four tag tools with places:write (write implies read)', async () => {
const { user } = createUser(testDb);
const names = await listToolNames(user.id, ['places:write']);
for (const tool of TAG_TOOLS) expect(names).toContain(tool);
});
it('registers no tag tools for an unrelated scope', async () => {
const { user } = createUser(testDb);
const names = await listToolNames(user.id, ['budget:read']);
for (const tool of TAG_TOOLS) expect(names).not.toContain(tool);
});
});
// ---------------------------------------------------------------------------
+7
View File
@@ -47,6 +47,13 @@ export default defineConfig({
},
resolve: {
alias: {
// Use the package SOURCE in tests: the built CJS dist would be
// externalized by vite and its require('@modelcontextprotocol/sdk/...')
// would hit the SDK's unresolvable exports map natively (production
// resolves it via the tsconfig-paths/register runtime hook instead).
// Inlining the source also applies the SWC decorator transform and the
// SDK aliases below, and drops the build-freshness dependency.
'@trek/nest-mcp': new URL('../nest-mcp/src/index.ts', import.meta.url).pathname,
// MCP SDK's exports map uses extension-less wildcard targets that neither
// Node nor Vite can resolve. Point directly at the CJS dist files.
// Paths are relative to the monorepo root (packages are hoisted there).