diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 50d8751e3..89a8e3c17 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -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 diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 20f1864b2..2b3711b28 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/Dockerfile b/Dockerfile index ba5a9c4c0..a7e293677 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/nest-mcp/.gitignore b/nest-mcp/.gitignore new file mode 100644 index 000000000..db4c6d9b6 --- /dev/null +++ b/nest-mcp/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules \ No newline at end of file diff --git a/nest-mcp/.prettierrc b/nest-mcp/.prettierrc new file mode 100644 index 000000000..7a664f6b6 --- /dev/null +++ b/nest-mcp/.prettierrc @@ -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" + ] +} diff --git a/nest-mcp/README.md b/nest-mcp/README.md new file mode 100644 index 000000000..86c0ce929 --- /dev/null +++ b/nest-mcp/README.md @@ -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, 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: }] } +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//.mcp.ts`: an `@McpController()` class injecting the domain's Nest service. Port each `server.registerTool(...)` from `server/src/mcp/tools/.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/`). diff --git a/nest-mcp/eslint.config.mjs b/nest-mcp/eslint.config.mjs new file mode 100644 index 000000000..b5e17998b --- /dev/null +++ b/nest-mcp/eslint.config.mjs @@ -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'], + }, +); diff --git a/nest-mcp/package.json b/nest-mcp/package.json new file mode 100644 index 000000000..6e7cb9185 --- /dev/null +++ b/nest-mcp/package.json @@ -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" + } +} diff --git a/nest-mcp/src/decorators.ts b/nest-mcp/src/decorators.ts new file mode 100644 index 000000000..958820a8b --- /dev/null +++ b/nest-mcp/src/decorators.ts @@ -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[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 })); +} diff --git a/nest-mcp/src/helpers.ts b/nest-mcp/src/helpers.ts new file mode 100644 index 000000000..5a5917125 --- /dev/null +++ b/nest-mcp/src/helpers.ts @@ -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; diff --git a/nest-mcp/src/index.ts b/nest-mcp/src/index.ts new file mode 100644 index 000000000..2f4bec360 --- /dev/null +++ b/nest-mcp/src/index.ts @@ -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'; diff --git a/nest-mcp/src/mcp-registry.service.ts b/nest-mcp/src/mcp-registry.service.ts new file mode 100644 index 000000000..1c5eefa05 --- /dev/null +++ b/nest-mcp/src/mcp-registry.service.ts @@ -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(); + 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(); + } +} diff --git a/nest-mcp/src/mcp.module.ts b/nest-mcp/src/mcp.module.ts new file mode 100644 index 000000000..c8ef19be6 --- /dev/null +++ b/nest-mcp/src/mcp.module.ts @@ -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], + }; + } +} diff --git a/nest-mcp/src/metadata.ts b/nest-mcp/src/metadata.ts new file mode 100644 index 000000000..bd4aa72b1 --- /dev/null +++ b/nest-mcp/src/metadata.ts @@ -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(); +const entries = new WeakMap>(); + +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; +} diff --git a/nest-mcp/src/registry.ts b/nest-mcp/src/registry.ts new file mode 100644 index 000000000..ff396d3bc --- /dev/null +++ b/nest-mcp/src/registry.ts @@ -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, cb: (...cbArgs: unknown[]) => unknown): unknown; + registerResource( + name: string, + uriOrTemplate: string | SdkResourceTemplate, + config: Record, + cb: (...cbArgs: unknown[]) => unknown, + ): unknown; + registerPrompt(name: string, config: Record, 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(); + 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)[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(); + 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 = { + 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 = { + 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 = { + 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 = { + 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); + } +} diff --git a/nest-mcp/src/testing.ts b/nest-mcp/src/testing.ts new file mode 100644 index 000000000..bf1e1cd61 --- /dev/null +++ b/nest-mcp/src/testing.ts @@ -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; +} diff --git a/nest-mcp/src/types.ts b/nest-mcp/src/types.ts new file mode 100644 index 000000000..3c051dbd1 --- /dev/null +++ b/nest-mcp/src/types.ts @@ -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; + _meta?: Record; +} + +/** Mirrors `registerResource(name, uri, metadata, cb)` for a fixed URI. */ +export interface ResourceOptions extends McpEntryOptionsBase { + uri: string; + mimeType?: string; + _meta?: Record; +} + +/** Mirrors `registerResource(name, new ResourceTemplate(uriTemplate), metadata, cb)`. */ +export interface ResourceTemplateOptions extends McpEntryOptionsBase { + uriTemplate: string; + mimeType?: string; + _meta?: Record; +} + +/** 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'); diff --git a/nest-mcp/tests/attach.test.ts b/nest-mcp/tests/attach.test.ts new file mode 100644 index 000000000..7209955a7 --- /dev/null +++ b/nest-mcp/tests/attach.test.ts @@ -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, 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, ctx: TestCtx) { + return { content: [{ type: 'text', text: JSON.stringify({ args, userId: ctx.userId }) }] }; + } + + @Tool({ name: 'open_tool' }) + async openTool(args: Record, 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[0], + { userId: 1 } as McpContext, + ), + ).toThrow(/declares declarative access but no accessPolicy was configured/); + }); +}); diff --git a/nest-mcp/tests/create-test-registry.test.ts b/nest-mcp/tests/create-test-registry.test.ts new file mode 100644 index 000000000..d4e483c11 --- /dev/null +++ b/nest-mcp/tests/create-test-registry.test.ts @@ -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[0], { userId: 1 } as Parameters[1]); + expect(calls).toEqual(['t:write:1']); + }); +}); diff --git a/nest-mcp/tests/decorators.test.ts b/nest-mcp/tests/decorators.test.ts new file mode 100644 index 000000000..ed38b5e25 --- /dev/null +++ b/nest-mcp/tests/decorators.test.ts @@ -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(); + }); +}); diff --git a/nest-mcp/tests/discovery.test.ts b/nest-mcp/tests/discovery.test.ts new file mode 100644 index 000000000..8fcdb5076 --- /dev/null +++ b/nest-mcp/tests/discovery.test.ts @@ -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(); + }); +}); diff --git a/nest-mcp/tests/harness.ts b/nest-mcp/tests/harness.ts new file mode 100644 index 000000000..a66b3ccbd --- /dev/null +++ b/nest-mcp/tests/harness.ts @@ -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; +} + +/** Bare McpServer + Client over an in-memory transport with the registry attached. */ +export async function createAttachHarness(registry: McpRegistry, ctx: TestCtx): Promise { + 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 }; +} diff --git a/nest-mcp/tests/helpers.test.ts b/nest-mcp/tests/helpers.test.ts new file mode 100644 index 000000000..421b0df84 --- /dev/null +++ b/nest-mcp/tests/helpers.test.ts @@ -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, + }); + }); +}); diff --git a/nest-mcp/tests/registry.test.ts b/nest-mcp/tests/registry.test.ts new file mode 100644 index 000000000..4935608cc --- /dev/null +++ b/nest-mcp/tests/registry.test.ts @@ -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\)/, + ); + }); +}); diff --git a/nest-mcp/tests/setup.ts b/nest-mcp/tests/setup.ts new file mode 100644 index 000000000..f8d91e05e --- /dev/null +++ b/nest-mcp/tests/setup.ts @@ -0,0 +1,3 @@ +// Required by @nestjs/testing (Test.createTestingModule) — the package's own +// decorators/registry deliberately do NOT need it. +import 'reflect-metadata'; diff --git a/nest-mcp/tsconfig.build.json b/nest-mcp/tsconfig.build.json new file mode 100644 index 000000000..d39200fb5 --- /dev/null +++ b/nest-mcp/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "rootDir": "./src", + "outDir": "./dist", + "sourceMap": false + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "tests"] +} diff --git a/nest-mcp/tsconfig.json b/nest-mcp/tsconfig.json new file mode 100644 index 000000000..1e6cd0298 --- /dev/null +++ b/nest-mcp/tsconfig.json @@ -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"] +} diff --git a/nest-mcp/vitest.config.ts b/nest-mcp/vitest.config.ts new file mode 100644 index 000000000..46f998c05 --- /dev/null +++ b/nest-mcp/vitest.config.ts @@ -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, + }, + }, +}); diff --git a/package-lock.json b/package-lock.json index 66dc8519a..106c5a3f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 2ec6b820e..6c5415907 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/server/package.json b/server/package.json index e13407584..2c32442bc 100644 --- a/server/package.json +++ b/server/package.json @@ -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", diff --git a/server/src/bootstrap.ts b/server/src/bootstrap.ts index 110c2de97..4274b25f8 100644 --- a/server/src/bootstrap.ts +++ b/server/src/bootstrap.ts @@ -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 { 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; } diff --git a/server/src/mcp/nest-mcp-policy.ts b/server/src/mcp/nest-mcp-policy.ts new file mode 100644 index 000000000..375d9d682 --- /dev/null +++ b/server/src/mcp/nest-mcp-policy.ts @@ -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); diff --git a/server/src/mcp/registry-handoff.ts b/server/src/mcp/registry-handoff.ts new file mode 100644 index 000000000..ae875a65f --- /dev/null +++ b/server/src/mcp/registry-handoff.ts @@ -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; +} diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 8a5cda142..a1effa6bc 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -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 }); } diff --git a/server/src/mcp/tools/_shared.ts b/server/src/mcp/tools/_shared.ts index 041f6a251..212c0366b 100644 --- a/server/src/mcp/tools/_shared.ts +++ b/server/src/mcp/tools/_shared.ts @@ -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): void { try { broadcast(tripId, event, { ...payload, _source: 'mcp' }); @@ -12,57 +28,12 @@ export function safeBroadcast(tripId: number, event: string, payload: Record { - 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 }); - } - ); -} diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index fb2d937f3..0fd0cb8b3 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -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, diff --git a/server/src/nest/tags/tags.bridge.ts b/server/src/nest/tags/tags.bridge.ts index 19425efe1..72fc9481b 100644 --- a/server/src/nest/tags/tags.bridge.ts +++ b/server/src/nest/tags/tags.bridge.ts @@ -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 diff --git a/server/src/nest/tags/tags.mcp.ts b/server/src/nest/tags/tags.mcp.ts new file mode 100644 index 000000000..de29779f2 --- /dev/null +++ b/server/src/nest/tags/tags.mcp.ts @@ -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, 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 }); + } +} diff --git a/server/src/nest/tags/tags.module.ts b/server/src/nest/tags/tags.module.ts index 4ffc93bc4..c9e54aef8 100644 --- a/server/src/nest/tags/tags.module.ts +++ b/server/src/nest/tags/tags.module.ts @@ -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 {} diff --git a/server/tests/helpers/mcp-harness.ts b/server/tests/helpers/mcp-harness.ts index c831525b8..373b3525c 100644 --- a/server/tests/helpers/mcp-harness.ts +++ b/server/tests/helpers/mcp-harness.ts @@ -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 { 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 { + 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); + }); }); // --------------------------------------------------------------------------- diff --git a/server/vitest.config.ts b/server/vitest.config.ts index e08ecc832..dfe5548ef 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -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).