From 76c8c349b17366dab1cf3b6c76649511c4777e5e Mon Sep 17 00:00:00 2001 From: jubnl <66769052+jubnl@users.noreply.github.com> Date: Sat, 22 Aug 2026 12:55:24 +0200 Subject: [PATCH] Fold nest mcp (#2017) * refactor(server): fold @trek/nest-mcp into server/src/nest-mcp * docs(server): point nest-mcp references at server/src/nest-mcp * remove tracked md file --- .github/workflows/docker.yml | 2 +- .github/workflows/test.yml | 49 +-- Dockerfile | 8 - nest-mcp/.gitignore | 2 - nest-mcp/.prettierrc | 18 - nest-mcp/CLAUDE.md | 61 ---- nest-mcp/eslint.config.mjs | 26 -- nest-mcp/package.json | 51 --- nest-mcp/tests/setup.ts | 3 - nest-mcp/tsconfig.build.json | 11 - nest-mcp/tsconfig.json | 30 -- nest-mcp/vitest.config.ts | 62 ---- package-lock.json | 124 ++----- package.json | 13 +- server/package.json | 1 - server/src/mcp/nest-mcp-policy.ts | 38 +-- server/src/mcp/tools.ts | 4 +- server/src/mcp/tools/_shared.ts | 6 +- {nest-mcp => server/src/nest-mcp}/README.md | 66 ++-- .../src => server/src/nest-mcp}/decorators.ts | 0 .../src => server/src/nest-mcp}/helpers.ts | 0 .../src => server/src/nest-mcp}/index.ts | 0 .../src/nest-mcp}/mcp-registry.service.ts | 0 .../src => server/src/nest-mcp}/mcp.module.ts | 0 .../src => server/src/nest-mcp}/metadata.ts | 0 .../src => server/src/nest-mcp}/registry.ts | 0 .../src => server/src/nest-mcp}/testing.ts | 0 .../src => server/src/nest-mcp}/types.ts | 72 ++-- server/src/nest/README.md | 8 +- .../nest/accommodations/accommodations.mcp.ts | 2 +- server/src/nest/addons/addon-gate.ts | 4 +- server/src/nest/airports/airports.mcp.ts | 2 +- server/src/nest/app.module.ts | 2 +- .../src/nest/assignments/assignments.mcp.ts | 2 +- server/src/nest/atlas/atlas.mcp.ts | 2 +- server/src/nest/auth/auth.mcp.ts | 2 +- server/src/nest/budget/budget.mcp.ts | 2 +- server/src/nest/categories/categories.mcp.ts | 2 +- server/src/nest/collab/collab.mcp.ts | 2 +- .../src/nest/collections/collections.mcp.ts | 2 +- server/src/nest/day-notes/day-notes.mcp.ts | 2 +- server/src/nest/days/days.mcp.ts | 2 +- server/src/nest/journey/journey.mcp.ts | 2 +- server/src/nest/maps/maps.mcp.ts | 2 +- .../mcp-shared/mcp-tool-guards.service.ts | 2 +- .../mcp-transport/mcp-transport.service.ts | 2 +- .../nest/notifications/notifications.mcp.ts | 2 +- server/src/nest/packing/packing.mcp.ts | 2 +- server/src/nest/places/places.mcp.ts | 2 +- server/src/nest/plugins/DI-MIGRATION.md | 317 ------------------ .../src/nest/plugins/host/rpc-kit/README.md | 7 +- .../src/nest/reservations/reservations.mcp.ts | 2 +- server/src/nest/share/share.mcp.ts | 2 +- server/src/nest/tags/tags.mcp.ts | 2 +- server/src/nest/todo/todo.mcp.ts | 2 +- server/src/nest/transit/transit.mcp.ts | 4 +- server/src/nest/trips/trip-prompts.mcp.ts | 2 +- server/src/nest/trips/trips.mcp.ts | 2 +- server/src/nest/vacay/vacay.mcp.ts | 2 +- server/src/nest/weather/weather.mcp.ts | 2 +- server/tests/helpers/mcp-test-controllers.ts | 2 +- server/tests/unit/mcp/nest-mcp-policy.test.ts | 2 +- server/tests/unit/mcp/tools-prompts.test.ts | 2 +- .../tests/unit/nest-mcp}/attach.test.ts | 17 +- .../nest-mcp}/create-test-registry.test.ts | 8 +- .../tests/unit/nest-mcp}/decorators.test.ts | 8 +- .../tests/unit/nest-mcp}/discovery.test.ts | 5 +- .../tests/unit/nest-mcp}/harness.ts | 2 +- .../tests/unit/nest-mcp}/helpers.test.ts | 2 +- .../tests/unit/nest-mcp}/registry.test.ts | 16 +- .../unit/nest/mcp-transport.service.test.ts | 2 +- server/tsconfig.json | 4 +- server/vitest.config.ts | 10 +- sonar-project.properties | 8 +- 74 files changed, 190 insertions(+), 939 deletions(-) delete mode 100644 nest-mcp/.gitignore delete mode 100644 nest-mcp/.prettierrc delete mode 100644 nest-mcp/CLAUDE.md delete mode 100644 nest-mcp/eslint.config.mjs delete mode 100644 nest-mcp/package.json delete mode 100644 nest-mcp/tests/setup.ts delete mode 100644 nest-mcp/tsconfig.build.json delete mode 100644 nest-mcp/tsconfig.json delete mode 100644 nest-mcp/vitest.config.ts rename {nest-mcp => server/src/nest-mcp}/README.md (67%) rename {nest-mcp/src => server/src/nest-mcp}/decorators.ts (100%) rename {nest-mcp/src => server/src/nest-mcp}/helpers.ts (100%) rename {nest-mcp/src => server/src/nest-mcp}/index.ts (100%) rename {nest-mcp/src => server/src/nest-mcp}/mcp-registry.service.ts (100%) rename {nest-mcp/src => server/src/nest-mcp}/mcp.module.ts (100%) rename {nest-mcp/src => server/src/nest-mcp}/metadata.ts (100%) rename {nest-mcp/src => server/src/nest-mcp}/registry.ts (100%) rename {nest-mcp/src => server/src/nest-mcp}/testing.ts (100%) rename {nest-mcp/src => server/src/nest-mcp}/types.ts (73%) delete mode 100644 server/src/nest/plugins/DI-MIGRATION.md rename {nest-mcp/tests => server/tests/unit/nest-mcp}/attach.test.ts (97%) rename {nest-mcp/tests => server/tests/unit/nest-mcp}/create-test-registry.test.ts (88%) rename {nest-mcp/tests => server/tests/unit/nest-mcp}/decorators.test.ts (93%) rename {nest-mcp/tests => server/tests/unit/nest-mcp}/discovery.test.ts (96%) rename {nest-mcp/tests => server/tests/unit/nest-mcp}/harness.ts (98%) rename {nest-mcp/tests => server/tests/unit/nest-mcp}/helpers.test.ts (98%) rename {nest-mcp/tests => server/tests/unit/nest-mcp}/registry.test.ts (90%) diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 89a8e3c17..50d8751e3 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 nest-mcp/package.json charts/trek/Chart.yaml + git add package.json package-lock.json server/package.json client/package.json shared/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 61b7539a3..46c76f682 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -10,7 +10,6 @@ on: - 'server/**' - 'client/**' - 'shared/**' - - 'nest-mcp/**' - 'plugin-sdk/**' - '.github/workflows/test.yml' @@ -74,41 +73,6 @@ 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 @@ -137,19 +101,14 @@ 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')" + # Vitest aliases the MCP SDK deep imports, so only this exercises what + # production runs: the 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('./dist/nest-mcp')" - name: Typecheck run: cd server && npm run typecheck diff --git a/Dockerfile b/Dockerfile index cc20b9f32..aa603f0d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,12 +31,9 @@ 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 @@ -47,7 +44,6 @@ 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/ # The trailing chown runs in this layer on purpose: it covers the manifests and @@ -94,10 +90,6 @@ COPY --chown=node:node server/scripts/migrate-encryption.ts ./server/scripts/mig # Admin recovery script (node server/reset-admin.js) for locked-out installs. COPY --chown=node:node server/reset-admin.js ./server/reset-admin.js COPY --chown=node:node --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 --chown=node:node --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist COPY --chown=node:node --from=client-builder /app/client/dist ./server/public COPY --chown=node:node --from=client-builder /app/client/public/fonts ./server/public/fonts diff --git a/nest-mcp/.gitignore b/nest-mcp/.gitignore deleted file mode 100644 index db4c6d9b6..000000000 --- a/nest-mcp/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -dist -node_modules \ No newline at end of file diff --git a/nest-mcp/.prettierrc b/nest-mcp/.prettierrc deleted file mode 100644 index 7a664f6b6..000000000 --- a/nest-mcp/.prettierrc +++ /dev/null @@ -1,18 +0,0 @@ -{ - "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/CLAUDE.md b/nest-mcp/CLAUDE.md deleted file mode 100644 index 27e87f567..000000000 --- a/nest-mcp/CLAUDE.md +++ /dev/null @@ -1,61 +0,0 @@ -# CLAUDE.md - -This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. - -## What this package is - -`@trek/nest-mcp` — decorator-driven MCP tool/resource/prompt registration for NestJS, a workspace package of the TREK monorepo (see the repo-root `CLAUDE.md` for monorepo-wide context). Domains declare MCP entries 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`. - -Two invariants shape everything here: - -- **Extraction-clean**: imports nothing from `@trek/server` or `@trek/shared`; peer-depends only on `@nestjs/common`, `@nestjs/core`, `@modelcontextprotocol/sdk`, and `zod`. Do not add TREK-specific imports or semantics. -- **No scope semantics of its own**: `access: { group, mode }` markers are opaque here; their meaning comes entirely from the host's `accessPolicy` passed to `McpModule.forRoot(...)`. Host-specific behavior (TREK's scope rules, canned permission errors) belongs in `server/`, not here. - -## Commands - -From the repo root (or drop `--workspace=nest-mcp` when running inside this directory): - -```bash -npm run build --workspace=nest-mcp # tsc -p tsconfig.build.json → dist/ (CJS + d.ts) -npm run typecheck --workspace=nest-mcp # tsc --noEmit (covers src + tests) -npm run test --workspace=nest-mcp # vitest run -npm run test:coverage --workspace=nest-mcp # istanbul coverage, 80% thresholds on src/** -npm run lint:check --workspace=nest-mcp # eslint (lint runs with --fix) -``` - -Single test file / single test (from `nest-mcp/`): - -```bash -npx vitest run tests/registry.test.ts -npx vitest run -t "name of the test" -``` - -Build order matters at the monorepo level: root `build`/`dev` build `shared` → `nest-mcp` → `server` → `client`, because `server` consumes this package's `dist/`. After changing this package, rebuild it before running server tests that exercise the Nest MCP layer. - -## Architecture - -Source is small and flat (`src/`, one concern per file): - -- **`metadata.ts`** — package-private metadata store: a `WeakSet`/`WeakMap` keyed by class constructor, deliberately **not** reflect-metadata (no polyfill import-order hazards; works in bare vitest workers and `createTestRegistry` without a Nest app). `getEntry` walks the constructor prototype chain so inherited decorated methods resolve on subclasses. -- **`decorators.ts`** — `@McpController()` (implies `@Injectable()`; discovery scans only marked classes) plus `@Tool` / `@Resource` / `@ResourceTemplate` / `@Prompt`, which just record entries into the metadata store. -- **`registry.ts`** — `McpRegistry`, a plain no-DI class holding decorated entries bound to provider instances. `attach(server, ctx, opts?)` registers each entry passing its access check onto a per-session `McpServer`, normalizing SDK callback shapes so handlers always receive `ctx` as the last argument (the SDK `extra` slot) and tools always receive `(args, ctx)` even with no `inputSchema`. `opts.onInvoke` (see `McpAttachOptions`) fires with `{ kind, name }` immediately before every attached handler runs — the host's observability seam (TREK's tool-call audit trail); it must not throw, and nest-mcp does not catch. `validate()` fails fast on duplicate names (fixed resources: duplicate URIs) and on declarative `access` without a configured policy. -- **`mcp-registry.service.ts`** — `McpRegistryService`, the DI subclass: on `onModuleInit` it scans providers via Nest's `DiscoveryService`/`MetadataScanner`, dedupes by instance (a provider can be wrapped once per module listing it), registers them, then runs `validate()` so misconfiguration breaks app boot rather than MCP session creation. -- **`mcp.module.ts`** — `McpModule.forRoot({ accessPolicy })`, a global dynamic module exporting the registry service. -- **`testing.ts`** — `createTestRegistry(instances, options)`: builds and validates a registry from hand-constructed controller instances, no Nest app. -- **`helpers.ts`** — handler-side result helpers (`ok`, `errorResult`, `demoDenied`) and the six `TOOL_ANNOTATIONS_*` presets. -- **`types.ts`** — options types and the empty `McpContext` interface, which hosts augment via `declare module '@trek/nest-mcp'`. - -Access evaluation order in `allowed()`: `when` gate first, then `access` (predicate bypasses the policy; declarative goes through it; omitted `access` = always registered). Preserve this order — hosts rely on `when` for addon toggles so scope markers stay declarative. - -Everything public is re-exported through `src/index.ts`; new exports go there. - -## Testing setup quirks - -- Vitest uses the **SWC transform** (`unplugin-swc` in `vitest.config.ts`) because esbuild doesn't emit decorator metadata — without it, type-based Nest DI in tests breaks silently. -- Coverage uses the **istanbul** provider (v8 under-reports on decorator output), thresholds 80% across the board on `src/**`. -- `tests/setup.ts` imports `reflect-metadata` for `@nestjs/testing` only — the package's own decorators/registry deliberately don't need it. Keep it that way. -- `tests/harness.ts` provides `createAttachHarness` (real `McpServer` + `Client` over an `InMemoryTransport`) and `asCtx` for the test context shape. Prefer exercising behavior through a real client round-trip rather than poking registry internals. - -## MCP SDK exports-map workaround - -The SDK's `exports` map uses extension-less wildcard targets that TypeScript and Vite cannot resolve. Both `tsconfig.json` (`paths`) and `vitest.config.ts` (`resolve.alias`) redirect deep imports (`server/mcp`, `client/index`, `inMemory`, `types`) to the CJS dist files under the **repo-root** `node_modules` (packages are hoisted). If you add a new deep SDK import, add it to **both** files or typecheck/tests will fail with unresolvable-module errors. diff --git a/nest-mcp/eslint.config.mjs b/nest-mcp/eslint.config.mjs deleted file mode 100644 index b5e17998b..000000000 --- a/nest-mcp/eslint.config.mjs +++ /dev/null @@ -1,26 +0,0 @@ -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 deleted file mode 100644 index f48edad86..000000000 --- a/nest-mcp/package.json +++ /dev/null @@ -1,51 +0,0 @@ -{ - "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.30.0", - "@nestjs/common": "^11.0.0", - "@nestjs/core": "^11.0.0", - "zod": "^4.0.0" - }, - "devDependencies": { - "@eslint/js": "^10.0.1", - "@modelcontextprotocol/sdk": "^1.30.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/tests/setup.ts b/nest-mcp/tests/setup.ts deleted file mode 100644 index f8d91e05e..000000000 --- a/nest-mcp/tests/setup.ts +++ /dev/null @@ -1,3 +0,0 @@ -// 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 deleted file mode 100644 index d39200fb5..000000000 --- a/nest-mcp/tsconfig.build.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "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 deleted file mode 100644 index 1e6cd0298..000000000 --- a/nest-mcp/tsconfig.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "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 deleted file mode 100644 index 46f998c05..000000000 --- a/nest-mcp/vitest.config.ts +++ /dev/null @@ -1,62 +0,0 @@ -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 867b0d6e9..af2d2c247 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,6 @@ "version": "3.4.1", "workspaces": [ "client", - "nest-mcp", "server", "shared" ], @@ -261,55 +260,6 @@ } } }, - "nest-mcp": { - "name": "@trek/nest-mcp", - "version": "3.4.1", - "devDependencies": { - "@eslint/js": "^10.0.1", - "@modelcontextprotocol/sdk": "^1.30.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.30.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", @@ -6781,8 +6731,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-android-arm64": { "version": "4.62.0", @@ -6796,8 +6745,7 @@ "optional": true, "os": [ "android" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-arm64": { "version": "4.62.0", @@ -6811,8 +6759,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-darwin-x64": { "version": "4.62.0", @@ -6826,8 +6773,7 @@ "optional": true, "os": [ "darwin" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-arm64": { "version": "4.62.0", @@ -6841,8 +6787,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-freebsd-x64": { "version": "4.62.0", @@ -6856,8 +6801,7 @@ "optional": true, "os": [ "freebsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-gnueabihf": { "version": "4.62.0", @@ -6871,8 +6815,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm-musleabihf": { "version": "4.62.0", @@ -6886,8 +6829,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-gnu": { "version": "4.62.0", @@ -6901,8 +6843,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-arm64-musl": { "version": "4.62.0", @@ -6929,8 +6870,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-loong64-musl": { "version": "4.62.0", @@ -6944,8 +6884,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-gnu": { "version": "4.62.0", @@ -6959,8 +6898,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-ppc64-musl": { "version": "4.62.0", @@ -6974,8 +6912,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-gnu": { "version": "4.62.0", @@ -6989,8 +6926,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-riscv64-musl": { "version": "4.62.0", @@ -7004,8 +6940,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-s390x-gnu": { "version": "4.62.0", @@ -7019,8 +6954,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-gnu": { "version": "4.62.0", @@ -7034,8 +6968,7 @@ "optional": true, "os": [ "linux" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-linux-x64-musl": { "version": "4.62.0", @@ -7062,8 +6995,7 @@ "optional": true, "os": [ "openbsd" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-openharmony-arm64": { "version": "4.62.0", @@ -7077,8 +7009,7 @@ "optional": true, "os": [ "openharmony" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-arm64-msvc": { "version": "4.62.0", @@ -7092,8 +7023,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-ia32-msvc": { "version": "4.62.0", @@ -7107,8 +7037,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-gnu": { "version": "4.62.0", @@ -7122,8 +7051,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@rollup/rollup-win32-x64-msvc": { "version": "4.62.0", @@ -7137,8 +7065,7 @@ "optional": true, "os": [ "win32" - ], - "peer": true + ] }, "node_modules/@scarf/scarf": { "version": "1.4.0", @@ -7645,10 +7572,6 @@ "resolved": "client", "link": true }, - "node_modules/@trek/nest-mcp": { - "resolved": "nest-mcp", - "link": true - }, "node_modules/@trek/server": { "resolved": "server", "link": true @@ -21799,7 +21722,6 @@ "@nestjs/swagger": "^11.4.5", "@nestjs/websockets": "^11.1.29", "@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 6c5415907..2ec6b820e 100644 --- a/package.json +++ b/package.json @@ -4,7 +4,6 @@ "version": "3.4.1", "workspaces": [ "client", - "nest-mcp", "server", "shared" ], @@ -16,14 +15,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 && 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", + "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", "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=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" + "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" }, "devDependencies": { "concurrently": "^10.0.4", diff --git a/server/package.json b/server/package.json index 24125788d..982b790e8 100644 --- a/server/package.json +++ b/server/package.json @@ -37,7 +37,6 @@ "@nestjs/swagger": "^11.4.5", "@nestjs/websockets": "^11.1.29", "@simplewebauthn/server": "^13.1.2", - "@trek/nest-mcp": "*", "@trek/shared": "*", "archiver": "^6.0.1", "bcryptjs": "^2.4.3", diff --git a/server/src/mcp/nest-mcp-policy.ts b/server/src/mcp/nest-mcp-policy.ts index c52094251..8c0e837aa 100644 --- a/server/src/mcp/nest-mcp-policy.ts +++ b/server/src/mcp/nest-mcp-policy.ts @@ -1,45 +1,15 @@ -import type { McpAccessGroup, McpAccessMode, McpAccessPolicy, McpAccessValidator } from '@trek/nest-mcp'; +import type { McpAccessGroup, McpAccessMode, McpAccessPolicy, McpAccessValidator } from '../nest-mcp'; import { ALL_SCOPES, canRead, canWrite, type Scope, type ScopeGroup } from './scopes'; /** The mode half of every scope: 'read' | 'write' | 'delete' | 'share'. */ export type ScopeMode = Scope extends `${string}:${infer M}` ? M : never; -/** - * TREK's context shape for @trek/nest-mcp handlers, predicates and the - * access policy — mirrors what registerTools() receives per session. - * The group registry types every decorator's `access.group` against the - * scope-derived union, so a typo'd group is a compile error in `.mcp.ts`. - */ -declare module '@trek/nest-mcp' { - interface McpContext { - userId: number; - scopes: string[] | null; - isStaticToken: boolean; - /** - * Fire-once static-token deprecation notice closure (built per session in - * src/mcp/index.ts and threaded through registerTools → registry.attach). - * Optional so direct createTestRegistry ctxs without it keep working — - * consumers (list_trips / get_trip_summary) treat absence as "no notice". - */ - getDeprecationNotice?: () => string | null; - } - // The augmentation pattern requires an empty single-extends interface — - // same false positive (and same disable) as McpContext in @trek/nest-mcp. - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - interface McpAccessGroupRegistry extends Record {} - // Same augmentation, for the mode half. Without it `mode` is 'read' | - // 'write' and journey's share tools could only be expressed as opaque - // predicates, which the boot gate below cannot check. - // eslint-disable-next-line @typescript-eslint/no-empty-object-type - interface McpAccessModeRegistry extends Record {} -} - type AssertExact = [A] extends [B] ? ([B] extends [A] ? true : never) : never; /** - * Compile-time lockstep: the augmented `access.group` union must be exactly - * `ScopeGroup` — fails `npm run typecheck` if the augmentation above drifts - * (which is also the proof that unknown groups like 'budgets' are rejected). + * Compile-time lockstep: `access.group` (from the registry interfaces in + * src/nest-mcp/types.ts) must be exactly `ScopeGroup` — fails + * `npm run typecheck` if the registries drift from scopes.ts. * Exported so the policy unit test has a runtime touchpoint. */ export const MCP_ACCESS_GROUPS_MATCH_SCOPE_GROUPS: AssertExact = true; diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index e2d05f453..62eb38673 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -1,5 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; -import type { McpAttachOptions, McpRegistry } from '@trek/nest-mcp'; +import type { McpAttachOptions, McpRegistry } from '../nest-mcp'; export function registerTools(registry: McpRegistry | null, server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null, onInvoke?: McpAttachOptions['onInvoke']): void { // The trip tools moved to the DI-discovered src/nest/trips/trips.mcp.ts and @@ -61,7 +61,7 @@ export function registerTools(registry: McpRegistry | null, server: McpServer, u // static-token notice to auth.mcp.ts (its `if (isStaticToken)` became a // `when` gate — the registry hands `when` the session context). - // Decorator-registered domains (@trek/nest-mcp). Production passes the + // Decorator-registered domains (src/nest-mcp). Production passes the // container-discovered McpRegistryService (injected into the transport // service); the no-Nest harness hands in createTestRegistry()'s build. // A null registry (direct callers without either) ⇒ skip. diff --git a/server/src/mcp/tools/_shared.ts b/server/src/mcp/tools/_shared.ts index b6bc618bf..d0a2b4b9a 100644 --- a/server/src/mcp/tools/_shared.ts +++ b/server/src/mcp/tools/_shared.ts @@ -1,6 +1,6 @@ -import { errorResult } from '@trek/nest-mcp'; +import { errorResult } from '../../nest-mcp'; -// Generic MCP result helpers and annotation presets live in @trek/nest-mcp +// Generic MCP result helpers and annotation presets live in src/nest-mcp // (single source for the decorator-based domains). Re-exported so the // @McpController files keep importing from here. Everything below is PURE — // the impure guards (hasTripPermission/isAdminUser/safeBroadcast) moved onto @@ -15,7 +15,7 @@ export { TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; export const MAX_MCP_TRIP_DAYS = 90; diff --git a/nest-mcp/README.md b/server/src/nest-mcp/README.md similarity index 67% rename from nest-mcp/README.md rename to server/src/nest-mcp/README.md index d8df04a3c..f6319dec7 100644 --- a/nest-mcp/README.md +++ b/server/src/nest-mcp/README.md @@ -1,15 +1,20 @@ -# @trek/nest-mcp +# 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`. +Decorator-driven MCP registration for NestJS. Domains declare MCP tools, resources and prompts as decorated methods on ordinary Nest providers; a discovery-backed registry collects them at boot and attaches them — filtered by the access policy — onto each per-session `McpServer`. -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`. +Formerly the standalone `@trek/nest-mcp` workspace package; folded into the server 2026-08-22. Two invariants survive the fold: + +- **Self-contained**: this directory imports nothing from the rest of `server/` except the type-only `../mcp/scopes` import in `types.ts` (which types `access.group`/`mode` against the real scope unions). Keep it that way — new TREK semantics belong in the host layer, not here. +- **No scope semantics of its own**: access *meaning* comes entirely from the `accessPolicy`/`validateAccess` given at `McpModule.forRoot(...)` — TREK's live in `src/mcp/nest-mcp-policy.ts`. + +Tests live in `server/tests/unit/nest-mcp/` and run with the normal server suite; coverage is gated at ≥80% in `server/vitest.config.ts`. ## API ### Decorators ```ts -import { McpController, Tool, Resource, ResourceTemplate, Prompt } from '@trek/nest-mcp'; +import { McpController, Tool, Resource, ResourceTemplate, Prompt } from '../../nest-mcp'; import { z } from 'zod'; @McpController() // implies @Injectable(); register the class in a module's providers: [] @@ -21,7 +26,7 @@ export class ThingsMcp { description: 'List all things.', inputSchema: {}, // ZodRawShape, passed straight to the SDK annotations: { readOnlyHint: true }, - access: { group: 'things', mode: 'read' }, // resolved by the host accessPolicy + access: { group: 'things', mode: 'read' }, // resolved by the accessPolicy }) async listThings(_args: Record, ctx: McpContext) { return { content: [{ type: 'text', text: JSON.stringify(this.things.list(ctx.userId)) }] }; @@ -40,24 +45,24 @@ export class ThingsMcp { Every decorator accepts `access` and `when`: -- **Declarative** — `access: { group: 'things', mode: 'read' | 'write' }`, resolved by the `accessPolicy` given once at `McpModule.forRoot(...)`. `group` is typed as `McpAccessGroup` — plain `string` until the host augments `McpAccessGroupRegistry` (see below), after which only the host's registered groups compile. +- **Declarative** — `access: { group: 'things', mode: 'read' | 'write' }`, resolved by the `accessPolicy` given once at `McpModule.forRoot(...)`. `group` and `mode` are typed against the scope-derived unions in `types.ts`, so a typo'd group is a compile error in `.mcp.ts` files. - **Predicate** — `access: (ctx) => boolean`, bypasses the policy. - **Availability gate** — `when: (ctx, self) => boolean`, evaluated *before* `access` (both must pass). Use it for feature/addon toggles so scope markers stay declarative: `when: (_ctx, self: PackingMcp) => self.addons.isAddonEnabled(...)`, `access: { group: 'packing', mode: 'read' }`. `self` is the `@McpController()` instance, handed in at attach time — the options object is built when the class is defined, so without it a gate can only reach a module-level singleton, and hosts end up constructing a second copy of a service outside their own container to answer a toggle. - **Omitted `access`** — the entry is always registered (subject to `when`). ### Result helpers & annotation presets -Handler-side conveniences, exported from the package root so decorated domains need nothing from the legacy MCP layer: +Handler-side conveniences, exported from the barrel so decorated domains need nothing from the legacy MCP layer: ```ts -import { ok, errorResult, demoDenied, TOOL_ANNOTATIONS_READONLY } from '@trek/nest-mcp'; +import { ok, errorResult, demoDenied, TOOL_ANNOTATIONS_READONLY } from '../../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. +Six `TOOL_ANNOTATIONS_*` presets cover the read/write/delete/idempotency matrix (plus `OPEN_WORLD` variants). TREK-specific canned errors (permission wording, RBAC lookups) belong in the host layer, built on `errorResult` — see `server/src/mcp/tools/_shared.ts`, which re-exports the generic helpers from here. ### Fail-fast validation @@ -65,11 +70,11 @@ Six `TOOL_ANNOTATIONS_*` presets cover the read/write/delete/idempotency matrix - duplicate names per kind (fixed resources: duplicate URIs); - declarative `access` without a configured `accessPolicy`; -- declarative `access` rejected by the optional host-supplied `validateAccess` hook. +- declarative `access` rejected by the optional `validateAccess` hook. All problems are aggregated into one `Invalid MCP registry: ...` error, so a single boot failure reports every misconfiguration. -`validateAccess?: (access, entry) => string | null | undefined` (on both `McpModule.forRoot(...)` and `createTestRegistry(...)` options) is called once per entry with declarative access — predicates are opaque to the host and skipped. Return a problem description to fail boot, null/undefined to accept: +`validateAccess?: (access, entry) => string | null | undefined` (on both `McpModule.forRoot(...)` and `createTestRegistry(...)` options) is called once per entry with declarative access — predicates are opaque and skipped. Return a problem description to fail boot, null/undefined to accept: ```ts McpModule.forRoot({ @@ -79,35 +84,15 @@ McpModule.forRoot({ }) ``` -### Context typing +### Context and access 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; - } -} -``` - -### Access-group typing - -Same trick for `access.group`: the package exports an empty `McpAccessGroupRegistry` interface, and `McpAccessGroup` (the type of `group`) is `string` until the host augments it — after which every decorator's `group` is checked against the host's union at compile time: - -```ts -declare module '@trek/nest-mcp' { - interface McpAccessGroupRegistry extends Record {} -} -``` +`McpContext` (the per-session context handed to every handler, predicate and the policy) and the `McpAccessGroupRegistry`/`McpAccessModeRegistry` interfaces that type `access.group`/`mode` are defined directly in `types.ts`. Before the fold they were empty interfaces the host augmented via `declare module '@trek/nest-mcp'`; the TREK shapes are now baked in, with the lockstep asserts in `src/mcp/nest-mcp-policy.ts` pinning the registries to `scopes.ts` at compile time. ### Wiring ```ts // AppModule -McpModule.forRoot({ accessPolicy: (access, ctx) => /* host semantics */ }) +McpModule.forRoot({ accessPolicy: (access, ctx) => /* scope semantics */ }) // wherever per-session servers are built (may be outside Nest — hand the // registry over after app.init()): @@ -119,7 +104,7 @@ registry.list(); // introspection ### Testing without Nest ```ts -import { createTestRegistry } from '@trek/nest-mcp'; +import { createTestRegistry } from '../../../src/nest-mcp'; const registry = createTestRegistry([new ThingsMcp(new ThingsService(db))], { accessPolicy }); registry.attach(server, ctx); @@ -133,13 +118,6 @@ registry.attach(server, ctx); 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 +## Notes -```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/`). +The MCP SDK's exports map uses extension-less wildcards that TypeScript cannot resolve; `server/tsconfig.json` `paths` and `server/vitest.config.ts` aliases point at the CJS dist files. `registry.ts` is the only file here importing the SDK. diff --git a/nest-mcp/src/decorators.ts b/server/src/nest-mcp/decorators.ts similarity index 100% rename from nest-mcp/src/decorators.ts rename to server/src/nest-mcp/decorators.ts diff --git a/nest-mcp/src/helpers.ts b/server/src/nest-mcp/helpers.ts similarity index 100% rename from nest-mcp/src/helpers.ts rename to server/src/nest-mcp/helpers.ts diff --git a/nest-mcp/src/index.ts b/server/src/nest-mcp/index.ts similarity index 100% rename from nest-mcp/src/index.ts rename to server/src/nest-mcp/index.ts diff --git a/nest-mcp/src/mcp-registry.service.ts b/server/src/nest-mcp/mcp-registry.service.ts similarity index 100% rename from nest-mcp/src/mcp-registry.service.ts rename to server/src/nest-mcp/mcp-registry.service.ts diff --git a/nest-mcp/src/mcp.module.ts b/server/src/nest-mcp/mcp.module.ts similarity index 100% rename from nest-mcp/src/mcp.module.ts rename to server/src/nest-mcp/mcp.module.ts diff --git a/nest-mcp/src/metadata.ts b/server/src/nest-mcp/metadata.ts similarity index 100% rename from nest-mcp/src/metadata.ts rename to server/src/nest-mcp/metadata.ts diff --git a/nest-mcp/src/registry.ts b/server/src/nest-mcp/registry.ts similarity index 100% rename from nest-mcp/src/registry.ts rename to server/src/nest-mcp/registry.ts diff --git a/nest-mcp/src/testing.ts b/server/src/nest-mcp/testing.ts similarity index 100% rename from nest-mcp/src/testing.ts rename to server/src/nest-mcp/testing.ts diff --git a/nest-mcp/src/types.ts b/server/src/nest-mcp/types.ts similarity index 73% rename from nest-mcp/src/types.ts rename to server/src/nest-mcp/types.ts index bf16e05ff..7125bf81c 100644 --- a/nest-mcp/src/types.ts +++ b/server/src/nest-mcp/types.ts @@ -1,51 +1,61 @@ +import type { Scope, ScopeGroup } from '../mcp/scopes'; + 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 } - * } + * The mode half of every scope: 'read' | 'write' | 'delete' | 'share'. + * Mirrors `ScopeMode` in `src/mcp/nest-mcp-policy.ts`, which stays the + * exported one (its only consumer is that file's own lockstep assert). */ -// eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface McpContext {} +type ScopeMode = Scope extends `${string}:${infer M}` ? M : never; /** - * Host-augmentable registry of valid `access.group` values — the `McpContext` - * augmentation trick applied to groups. Augment once: + * The per-session context handed to `McpRegistry.attach(server, ctx)` and + * forwarded to every handler (last argument) and access predicate/policy — + * mirrors what registerTools() receives per session. * - * declare module '@trek/nest-mcp' { - * interface McpAccessGroupRegistry extends Record {} - * } - * - * Unaugmented, `McpAccessGroup` stays `string` — the package attaches no - * semantics to groups, same contract as `McpContext`. + * Historically this was an empty interface augmented by the host (nest-mcp + * was a separate extraction-clean workspace); since the fold into the server + * the TREK shape lives here directly. The type-only `../mcp/scopes` import + * above is the one deliberate coupling to the rest of the server. */ +export interface McpContext { + userId: number; + scopes: string[] | null; + isStaticToken: boolean; + /** + * Fire-once static-token deprecation notice closure (built per session in + * src/mcp/index.ts and threaded through registerTools → registry.attach). + * Optional so direct createTestRegistry ctxs without it keep working — + * consumers (list_trips / get_trip_summary) treat absence as "no notice". + */ + getDeprecationNotice?: () => string | null; +} + +/** + * Registry of valid `access.group` values, keyed by the scope-derived union + * so a typo'd group is a compile error in `.mcp.ts` files. The semantics of + * a group come from the access policy in `src/mcp/nest-mcp-policy.ts`, not + * from here. + */ +// The registry pattern requires an empty single-extends interface — the +// keys ARE the contract, so the lint's "no members" finding is a false +// positive. // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface McpAccessGroupRegistry {} +export interface McpAccessGroupRegistry extends Record {} export type McpAccessGroup = keyof McpAccessGroupRegistry extends never ? string : keyof McpAccessGroupRegistry & string; /** - * Host augmentation point for the access *mode*, mirroring - * `McpAccessGroupRegistry`: - * - * declare module '@trek/nest-mcp' { - * interface McpAccessModeRegistry extends Record {} - * } - * - * Unaugmented, `McpAccessMode` falls back to `'read' | 'write'` — the two - * modes every host had before this was augmentable, so leaving the registry - * empty keeps the previous contract exactly. + * Same registry pattern for the access *mode* half. Without the full mode + * union `mode` would be 'read' | 'write' only, and journey's share tools + * could only be expressed as opaque predicates, which the boot gate in + * `src/mcp/nest-mcp-policy.ts` cannot check. */ // eslint-disable-next-line @typescript-eslint/no-empty-object-type -export interface McpAccessModeRegistry {} +export interface McpAccessModeRegistry extends Record {} export type McpAccessMode = keyof McpAccessModeRegistry extends never ? 'read' | 'write' diff --git a/server/src/nest/README.md b/server/src/nest/README.md index 55ba83b31..c918268e1 100644 --- a/server/src/nest/README.md +++ b/server/src/nest/README.md @@ -275,7 +275,7 @@ graph**, which is a different problem from serving a genuine outside consumer. Nine `*.mcp.ts` files did that with `addons.bridge` for one boolean each, and not by preference: a decorator's options object is built while the class is being defined, so the `when:` closure had no `this` to reach an injected service -through. `@trek/nest-mcp` hands the gate its declaring instance now, and +through. `src/nest-mcp/` hands the gate its declaring instance now, and `addons/addon-gate.ts` turns that into `addonGate(ADDON_IDS.X)` over an injected `AddonsService`. `reservations.mcp.ts` (assignments) and `atlas.mcp.ts` / `journey.mcp.ts` (auth) went the same way. @@ -932,9 +932,9 @@ crons all inject now (`*.job.ts` providers on `scheduling/CronRegistrarService`) boot-validated by `trekMcpValidateAccess` (`src/mcp/nest-mcp-policy.ts`) — an unknown group, or `mode: 'write'` on a read-only group (`geo`, `weather`), fails app boot. **`mode` is typed the same way**: it is the mode - half of `Scope` (`read | write | delete | share`), augmented into - `@trek/nest-mcp` through `McpAccessModeRegistry` exactly like the group - registry. A scope that is neither read nor write — `journey:share` is the + half of `Scope` (`read | write | delete | share`), fed into + `src/nest-mcp/types.ts` through `McpAccessModeRegistry` exactly like the + group registry. A scope that is neither read nor write — `journey:share` is the one today — gets a real marker, *not* a `(ctx) => canX(ctx.scopes)` predicate: predicates bypass the policy entirely and are invisible to the boot gate, so a scope typo in one would ship as a silent full-access tool. diff --git a/server/src/nest/accommodations/accommodations.mcp.ts b/server/src/nest/accommodations/accommodations.mcp.ts index 2f9f54e6e..3f1b6dbd9 100644 --- a/server/src/nest/accommodations/accommodations.mcp.ts +++ b/server/src/nest/accommodations/accommodations.mcp.ts @@ -2,7 +2,7 @@ import { McpController, Tool, ResourceTemplate, type McpContext, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; diff --git a/server/src/nest/addons/addon-gate.ts b/server/src/nest/addons/addon-gate.ts index cbac391cf..6e1aebf35 100644 --- a/server/src/nest/addons/addon-gate.ts +++ b/server/src/nest/addons/addon-gate.ts @@ -1,4 +1,4 @@ -import type { McpContext } from '@trek/nest-mcp'; +import type { McpContext } from '../../nest-mcp'; import type { AddonsService } from './addons.service'; import { ADDON_IDS } from '../../addons'; @@ -11,7 +11,7 @@ import { ADDON_IDS } from '../../addons'; * `this` to reach an injected service through, and nine domains hid an edge to * the addons domain from the module graph to answer one boolean. * - * `@trek/nest-mcp` now hands the gate the declaring controller instance, so the + * `src/nest-mcp` now hands the gate the declaring controller instance, so the * only thing a domain has to do is expose the service it already injects: * * constructor(private readonly vacay: VacayService, readonly addons: AddonsService) {} diff --git a/server/src/nest/airports/airports.mcp.ts b/server/src/nest/airports/airports.mcp.ts index 0b25fe69e..aabc5fc1f 100644 --- a/server/src/nest/airports/airports.mcp.ts +++ b/server/src/nest/airports/airports.mcp.ts @@ -1,4 +1,4 @@ -import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '@trek/nest-mcp'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; import { z } from 'zod'; import { findByIata, searchAirports } from './airports.data'; diff --git a/server/src/nest/app.module.ts b/server/src/nest/app.module.ts index 4cda70887..04113f9d3 100644 --- a/server/src/nest/app.module.ts +++ b/server/src/nest/app.module.ts @@ -60,7 +60,7 @@ import { AuditModule } from './audit/audit.module'; import { PermissionsModule } from './permissions/permissions.module'; import { PluginsModule } from './plugins/plugins.module'; import { SchedulingModule } from './scheduling/scheduling.module'; -import { McpModule } from '@trek/nest-mcp'; +import { McpModule } from '../nest-mcp'; import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../mcp/nest-mcp-policy'; import { TrekExceptionFilter } from './common/trek-exception.filter'; import { SpaFallbackFilter } from './platform/spa-fallback.filter'; diff --git a/server/src/nest/assignments/assignments.mcp.ts b/server/src/nest/assignments/assignments.mcp.ts index f0eb426a3..d4ce2348f 100644 --- a/server/src/nest/assignments/assignments.mcp.ts +++ b/server/src/nest/assignments/assignments.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; diff --git a/server/src/nest/atlas/atlas.mcp.ts b/server/src/nest/atlas/atlas.mcp.ts index 40f2c9cec..de89f9620 100644 --- a/server/src/nest/atlas/atlas.mcp.ts +++ b/server/src/nest/atlas/atlas.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { z } from 'zod'; import { ADDON_IDS } from '../../addons'; import { AtlasService, BucketItemExistsError } from './atlas.service'; diff --git a/server/src/nest/auth/auth.mcp.ts b/server/src/nest/auth/auth.mcp.ts index 26bf61221..1a8459ca7 100644 --- a/server/src/nest/auth/auth.mcp.ts +++ b/server/src/nest/auth/auth.mcp.ts @@ -1,4 +1,4 @@ -import { McpController, Prompt, type McpContext } from '@trek/nest-mcp'; +import { McpController, Prompt, type McpContext } from '../../nest-mcp'; /** * Auth-related MCP surface. One prompt today, moved 1:1 from the legacy diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 5d2b74217..faf085db4 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { RuntimeEnvService } from '../app-config/runtime-env.service'; diff --git a/server/src/nest/categories/categories.mcp.ts b/server/src/nest/categories/categories.mcp.ts index 1f13b23f7..e9aa279b0 100644 --- a/server/src/nest/categories/categories.mcp.ts +++ b/server/src/nest/categories/categories.mcp.ts @@ -1,7 +1,7 @@ import { McpController, Tool, Resource, type McpContext, TOOL_ANNOTATIONS_READONLY, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { CategoriesService } from './categories.service'; /** diff --git a/server/src/nest/collab/collab.mcp.ts b/server/src/nest/collab/collab.mcp.ts index b6c4893c8..0c4778f2a 100644 --- a/server/src/nest/collab/collab.mcp.ts +++ b/server/src/nest/collab/collab.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; diff --git a/server/src/nest/collections/collections.mcp.ts b/server/src/nest/collections/collections.mcp.ts index 1d6e15597..fc90bb899 100644 --- a/server/src/nest/collections/collections.mcp.ts +++ b/server/src/nest/collections/collections.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; import { ADDON_IDS } from '../../addons'; diff --git a/server/src/nest/day-notes/day-notes.mcp.ts b/server/src/nest/day-notes/day-notes.mcp.ts index 5370cefab..71a2782ef 100644 --- a/server/src/nest/day-notes/day-notes.mcp.ts +++ b/server/src/nest/day-notes/day-notes.mcp.ts @@ -2,7 +2,7 @@ import { McpController, Tool, ResourceTemplate, type McpContext, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts index 430099d36..dcfd60007 100644 --- a/server/src/nest/days/days.mcp.ts +++ b/server/src/nest/days/days.mcp.ts @@ -2,7 +2,7 @@ import { McpController, Tool, ResourceTemplate, type McpContext, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; diff --git a/server/src/nest/journey/journey.mcp.ts b/server/src/nest/journey/journey.mcp.ts index 415e0dc5a..69e69eeec 100644 --- a/server/src/nest/journey/journey.mcp.ts +++ b/server/src/nest/journey/journey.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, demoDenied, ok, type McpContext, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { z } from 'zod'; import { ADDON_IDS } from '../../addons'; import { JourneyDomainService } from './journey-domain.service'; diff --git a/server/src/nest/maps/maps.mcp.ts b/server/src/nest/maps/maps.mcp.ts index 3afd752c7..5371e4157 100644 --- a/server/src/nest/maps/maps.mcp.ts +++ b/server/src/nest/maps/maps.mcp.ts @@ -1,4 +1,4 @@ -import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '@trek/nest-mcp'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; import { z } from 'zod'; import { MapsService } from './maps.service'; diff --git a/server/src/nest/mcp-shared/mcp-tool-guards.service.ts b/server/src/nest/mcp-shared/mcp-tool-guards.service.ts index 0a1c1a9f1..891056ce2 100644 --- a/server/src/nest/mcp-shared/mcp-tool-guards.service.ts +++ b/server/src/nest/mcp-shared/mcp-tool-guards.service.ts @@ -9,7 +9,7 @@ import { RealtimeService } from '../realtime/realtime.service'; * src/websocket stub as module globals. The @McpController domain classes are * ordinary Nest providers, so they inject this instead. The pure result * helpers (noAccess/permissionDenied/adminRequired/MAX_MCP_TRIP_DAYS and the - * @trek/nest-mcp re-exports) stay in _shared.ts — they carry no dependencies. + * src/nest-mcp re-exports) stay in _shared.ts — they carry no dependencies. */ @Injectable() export class McpToolGuardsService { diff --git a/server/src/nest/mcp-transport/mcp-transport.service.ts b/server/src/nest/mcp-transport/mcp-transport.service.ts index d919616b1..18d3e56ff 100644 --- a/server/src/nest/mcp-transport/mcp-transport.service.ts +++ b/server/src/nest/mcp-transport/mcp-transport.service.ts @@ -3,7 +3,7 @@ import type { Request, Response } from 'express'; import { randomUUID } from 'crypto'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp'; -import { McpRegistryService } from '@trek/nest-mcp'; +import { McpRegistryService } from '../../nest-mcp'; import type { User } from '../../types'; import { ADDON_IDS } from '../../addons'; import { getMcpSafeUrl } from '../../app-config'; diff --git a/server/src/nest/notifications/notifications.mcp.ts b/server/src/nest/notifications/notifications.mcp.ts index a546602c0..14b9667e0 100644 --- a/server/src/nest/notifications/notifications.mcp.ts +++ b/server/src/nest/notifications/notifications.mcp.ts @@ -2,7 +2,7 @@ import { McpController, Tool, Resource, type McpContext, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, demoDenied, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; import { NotificationsService } from './notifications.service'; diff --git a/server/src/nest/packing/packing.mcp.ts b/server/src/nest/packing/packing.mcp.ts index ade7fe3fd..408fcd065 100644 --- a/server/src/nest/packing/packing.mcp.ts +++ b/server/src/nest/packing/packing.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; diff --git a/server/src/nest/places/places.mcp.ts b/server/src/nest/places/places.mcp.ts index 7399b4294..dbdb881d7 100644 --- a/server/src/nest/places/places.mcp.ts +++ b/server/src/nest/places/places.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { placeWebsiteSchema } from '@trek/shared'; import { z } from 'zod'; diff --git a/server/src/nest/plugins/DI-MIGRATION.md b/server/src/nest/plugins/DI-MIGRATION.md deleted file mode 100644 index 55251dcc2..000000000 --- a/server/src/nest/plugins/DI-MIGRATION.md +++ /dev/null @@ -1,317 +0,0 @@ -# Plugin RPC boundary — migration to Nest DI - -Status: **Option A implemented (2026-07)** — the wiring layer is now -`host/plugin-host-deps.factory.ts` (`@Injectable() PluginHostDepsFactory`), -with process-wide host state split into `host/plugin-host-state.ts`. Option B -below remains the roadmap. -Goal: make the host side of the plugin boundary fully Nest-native, in step with -the ongoing legacy-service migration (`src/nest/README.md`, "Migrating a legacy -`src/services/*` service" recipe). The **process boundary itself does not -change** — only how the host sources and organizes its dependencies. - ---- - -## 1. Where we are today - -The plugin runtime has three layers. Only the third is "un-Nest". - -| Layer | File(s) | Role | Nest-native? | -|---|---|---|---| -| Sandbox / transport | `supervisor/plugin-supervisor.ts`, `runtime/plugin-host-entry.ts`, `protocol/envelope.ts` | Forked child per plugin, IPC envelopes, permission tables, actor resolution, rate limiting, RSS policing | Intentionally not — this is the security boundary | -| Enforcement | `host/rpc-host.ts` (~1330 lines) | `PluginRpcHost`: builds a per-plugin method map at spawn — a handler is registered **only if the plugin holds the unlocking permission** (registration = authorization). Depends only on the `HostDeps` interface | Neutral — decoupled by design | -| **Wiring** | `host/plugin-host-deps.factory.ts` (was `create-rpc-host.ts`) | Fills the ~130-member `HostDeps` object. Since Option A: an `@Injectable() PluginHostDepsFactory` with the DI-native domain services (`BudgetService`, `ReservationsService`, `TagsService`, `CategoriesService`, `TodoService`, `PluginOAuthService`) constructor-injected, sixteen domains as of 2026-07 (exchange-rates joined with the budget-domain fold, after the Wave-2 permissions pair); legacy `services/*` domains stay plain function imports until their own migration. Raw `db`/`broadcast` also stay until injectable equivalents exist | **Yes (Option A)** — drains further per §3 | -| Host state | `host/plugin-host-state.ts` | Deliberately module-level (NOT a provider): the per-plugin data-DB map (`getPluginDataDb`/`closePluginDataDb`) and daily broker budgets (`budgetFor`/`pluginBudgetUsage`). Must be one shared instance across host recreations, and is read by `PluginsService`, which the factory imports from — folding it into the injectable would create a provider cycle | Intentionally not | - -Key fact that made Option A cheap: the wiring is **called from inside a Nest -provider** — `PluginRuntimeService` binds `hostDeps.create(id, granted, this)` -in its supervisor field initializer (the arrow resolves `this.hostDeps` lazily -at spawn time, so field-initializer ordering is safe). Unlike the MCP handler -(mounted pre-`app.init()`, needs `registry-handoff.ts`), **no handoff seam is -needed here** — plain constructor injection is used. - -### The bridge pattern (mostly retired) - -`.bridge.ts` shims existed for one reason: a domain's SQL moved into a -DI-only service and the legacy function module was deleted, but the wiring -layer didn't inject — so a module-level shim re-exported the legacy names over -a hand-built instance. Option A deleted `tags.bridge.ts` and -`categories.bridge.ts` (the plugin host was their only consumer). -`todo.bridge.ts`, `share.bridge.ts`, `collab.bridge.ts` and `vacay.bridge.ts` -followed with the 2026-07 trip fold — their last consumers (the legacy -`src/mcp/tools/trips.ts` registrar and the legacy tripService itself) migrated -into the DI-discovered `trips.mcp.ts`/`share.mcp.ts` and the DI-native -`TripsService`. New bridges should only be added for genuinely -non-Nest consumers (legacy MCP registrars, scheduler, websocket) — the plugin -host no longer needs them, ever. - -### What never changes (the invariants) - -These are the sandbox. Every option below leaves them untouched: - -- One forked child process per plugin; IPC envelope protocol (`req`/`res`/`evt`). -- `protocol/envelope.ts` stays a **zero-runtime-import module** and stays the - **single source of truth** for `KNOWN_METHODS`, `METHOD_PERMISSION`, - `KNOWN_PERMISSIONS` (it is loaded by both host and child). -- Registration-is-authorization: an ungranted method is *absent*, not guarded. -- Host-side acting-user resolution (`_inv` map in the supervisor) — a plugin - can never name an arbitrary user. -- Scrubbed child env, Node `--permission` flags, RSS ceiling, heartbeat - reaping, crash backoff. -- The error taxonomy (`PERMISSION_DENIED`, `UNKNOWN_METHOD`, `BAD_PARAMS`, - `RESOURCE_FORBIDDEN`, …) and audit trail. - -We also explicitly **rejected** modelling the IPC boundary as a -`@nestjs/microservices` custom transporter ("Option C"): the supervisor's -security model is bespoke and load-bearing, and hiding it behind Nest's -microservice abstractions would obscure the boundary for near-zero payoff. - ---- - -## 2. Option A — inject the wiring layer (DONE, 2026-07) - -**What shipped:** `create-rpc-host.ts` became two files: - -- `host/plugin-host-deps.factory.ts` — `@Injectable() PluginHostDepsFactory` - with constructor deps `BudgetService`, `ReservationsService`, `TagsService`, - `CategoriesService`, `TodoService`, `PluginOAuthService` (the last was - previously `new`-ed **per call** inside the `getOAuthToken` closure). - `create(id, granted, router)` returns the `PluginRpcHost` with the same - ~130-member `HostDeps` object — closures over `this.*` for DI domains, still - plain imports for legacy `services/*`. `DatabaseService` is injected too - (since 2026-07): the factory's inline SQL, the `canEditTripAs` gate and the - `HostDeps.db`/audit connection all go through it instead of the raw `db` - proxy (behavior-identical — the injected connection IS the proxy). -- `host/plugin-host-state.ts` — the process-wide maps (`dataDbs`, `budgets`) - and their accessors, kept module-level on purpose (see §1 table). - -`PluginRuntimeService` takes the factory as a third, type-optional constructor -param (same convention as `registry?` — tests construct without it, Nest always -injects it) and binds `this.hostDeps.create(id, granted, this)` in the -supervisor field initializer. - -**Module wiring:** `TagsModule`/`CategoriesModule`/`BudgetModule`/ -`ReservationsModule`/`PackingModule` gained `exports: [XService]` (`TodoModule` -already exported), and `PluginsModule` imports all six. The dependency graph -moved from hidden (bridge files) to explicit (the container). `DatabaseModule` -is `@Global`, so no import needed for it. - -**Interaction with the service-migration track:** legacy `services/*` functions -(place, day, trip, …) stay as plain imports inside the -factory *until their own domain migrates*. Each future migration then swaps one -import for one injected service (+ module export/import) — **no more bridge -files for the plugin host, ever**. The factory's import list shrinks -monotonically as the recipe in `src/nest/README.md` proceeds (packing and -day-notes swapped in 2026-07; trip-invite migrated without touching the -factory — it was never imported here; assignments swapped in 2026-07; share -migrated 2026-07 without touching the factory — it was never imported here -either; settings swapped in 2026-07 indirectly — the factory never imported it, -but its `resolveLlmConfig` import became the injected `LlmConfigResolver`; -fileService swapped in 2026-07 — its ten imported symbols became the injected -`FilesService` plus the load-time constants from `files.constants.ts`; -collabService swapped in 2026-07 — its seven imported symbols became the -injected `CollabService`, completing Wave 3; vacayService swapped in 2026-07 — -its four imported symbols became the injected `VacayService`; dayService -swapped in 2026-07 — its eleven imported symbols (the heaviest single import) -became the injected `DaysService`; permissions swapped in 2026-07 with the -Wave-2 pair — its `checkPermission` import became the injected -`PermissionsService` (the auditLog half never touched the factory: the plugin -host's audit trail is the separate `plugin-audit.ts`); exchangeRateService -swapped in 2026-07 with the budget-domain fold — its `getRates` import became -the injected `ExchangeRatesService`; budgetService swapped in 2026-07 with the -budget migration — its `listBudgetItems` import became the already-injected -`BudgetService`, so no constructor change at all; tripService swapped in -2026-07 with the trip fold — its six imported symbols (`listTrips`, -`updateTrip`, `createTrip`, `removeMember`, `NotFoundError`, `ValidationError`) -became the injected `TripsService`, with the error classes now imported from -`nest/trips/trips.service`; placeService swapped in 2026-07 with the place -fold — its four imported symbols (`createPlace`, `updatePlace`, `deletePlace`, -`getPlace`) became the injected `PlacesService`, the factory's 21st constructor -dep; collectionsService swapped in 2026-08 with the collections fold — its -seven imported symbols (`listCollections`, `getCollection`, `createCollection`, -`updateCollection`, `savePlace`, `copyToTrip`, `deletePlace`) became the -injected `CollectionsService`, the factory's 22nd constructor dep, with -`mapCollectionError` unchanged on top; atlasService swapped in 2026-08 with -the atlas fold — its nine imported symbols (`listVisitedCountries`, -`listManuallyVisitedRegions`, `listBucketList`, `markCountryVisited`, -`unmarkCountryVisited`, `markRegionVisited`, `unmarkRegionVisited`, -`createBucketItem`, `deleteBucketItem`) became the injected `AtlasService`, -the factory's 23rd constructor dep; notificationService swapped in 2026-08 -with the notifications fold — its single imported symbol (`send as -sendNotification`, the `sendPluginNotification` closure) became the injected -`NotificationsService`, the factory's 24th constructor dep; the remaining -factory domain imports are `weatherService` (`getWeather`) and the Wave-5 -journeys domain). - -### Test impact (as landed) - -- `tests/unit/plugins/rpc-host.test.ts` — **unchanged** (constructs - `PluginRpcHost` directly with hand-built `HostDeps`). -- `tests/unit/plugins/plugin-host-deps.factory.test.ts` (was - `create-rpc-host.test.ts`) — the six DI-domain path mocks became constructor - stubs (sixteen stubs as of the 2026-07 exchange-rates fold, plus `tripsStub` - with the 2026-07 trip fold — the tripService path mock's sentinel behaviors - moved onto the stub, its throws now built from the real - `nest/trips/trips.service` error classes); the ~22 - legacy-service path mocks shrank by one with the 2026-07 budget migration - (the budgetService satisfy-the-import mock died with the legacy file — the - `budgetStub` gained `listBudgetItems` for the two swapped closures); the - collectionsService path mock became `collectionsStub` with the 2026-08 - collections fold (same sentinel behaviors — the status-tagged throws the - factory's `mapCollectionError` maps); the atlasService path mock became - `atlasStub` with the 2026-08 atlas fold (same sentinel behaviors, keyed by - the service method names — `bucketList`, `markCountry`, …); the - notificationService path mock became `notificationsStub` with the 2026-08 - notifications fold (the hoisted `notifySend` handle survived as the stub's - `send`); a - file-local shim keeps the - historical `createRealRpcHost(id, granted)` call sites and supplies a default - no-op router. -- Bridge delegation tests (TAG-SVC-016..020, CAT-SVC-016) — deleted with the - bridges (owner-scoping etc. already covered by the service-level cases). -- `tests/helpers/plugin-host.ts` — `createPluginRuntime()` builds the runtime - the way Nest would (real services over the test DB) for the suites that fork - real plugin children. - ---- - -## 3. Option B — decorator-driven RPC registry (the end state) - -**What:** mirror the `@trek/nest-mcp` pilot for the plugin RPC surface. -Per-domain `.rpc.ts` classes with declarative metadata, discovered at -boot, from which the supervisor builds each plugin's granted method map. - -```ts -// nest/tags/tags.rpc.ts (sketch) -@RpcController() -export class TagsRpc { - constructor(private readonly tags: TagsService) {} - - @RpcMethod({ method: 'tags.list', permission: 'db:read:tags' }) - list(_params: unknown, ctx: RpcCtx) { - return this.tags.list(requireActor(ctx)); - } -} -``` - -A registry service (same shape as `McpRegistryService`: `DiscoveryService` + -`MetadataScanner`, `OnModuleInit` scan, fail-fast `validate()`) replaces the -hand-built method map. `PluginRpcHost` shrinks to dispatch + shared resource -gates (`tripRead`, `requireTripEdit`, addon gates) offered as helpers the -domain classes call. - -**The one hard constraint:** `envelope.ts` remains the single source of truth. -Decorator metadata inevitably duplicates the method→permission mapping, so the -registry's boot-time `validate()` must assert, fail-fast at app start: - -1. every `@RpcMethod` names a method present in `KNOWN_METHODS`; -2. its declared permission **equals** `METHOD_PERMISSION[method]`; -3. every `KNOWN_METHOD` has exactly one handler (no orphans, no duplicates). - -That turns today's implicit convention into a checked invariant — a strict -improvement over the status quo, where a table/registration mismatch is only -found by tests (or not at all). - -**What does *not* map onto Nest:** the resource gates are not HTTP guards. -There is no request context; the acting user comes from the supervisor's -`_inv` map. Don't force `CanActivate` — keep the gates as plain helpers (or a -thin wrap around dispatch). Option B buys *structure and colocation*, not -Nest's HTTP middleware machinery. - -### Pros - -- **Kills the compounding cost.** One new plugin capability = one decorated - method on the domain class + one `envelope.ts` table entry. Today it is six - edits across two monoliths. Over months-to-years of API growth this is the - dominant term. -- **One mental model for the whole codebase.** A complete domain becomes - `x.controller.ts` + `x.service.ts` + `x.mcp.ts` + `x.rpc.ts` + `x.module.ts` - — same folder, same discovery pattern, same test style as the proven - nest-mcp pilot. New contributors learn one shape. -- **Dissolves the monoliths.** `plugin-host-deps.factory.ts` disappears; `rpc-host.ts` - shrinks to a small dispatch core. Per-domain diffs, per-domain tests, less - merge contention. -- **Boot-time validation** of the method/permission tables (see above). -- **Strangler-compatible.** The registry can coexist with the legacy method - map exactly like the nest-mcp registry coexists with the legacy tool - registrars: migrated domains register via decorators, the rest stay in the - old map until their turn. No big-bang cutover. - -### Cons - -- **Prerequisites.** Only worth starting once a domain's logic is a DI service; - doing it before the service migration just wraps legacy function imports in - decorator dressing. Today only tags and categories qualify. -- **Real up-front build:** the decorator/registry package (or a generalized - discovery core shared with `nest-mcp`), the validate() logic, a - `createTestRegistry`-style harness, and migration of the two big test suites - (`rpc-host.test.ts`'s `HostDeps`-shaped tests do **not** survive this one). -- **Duplication risk** between decorators and `envelope.ts` if validate() is - skipped or weakened — the mitigation is mandatory, not optional. -- The supervisor/registry seam needs care: method maps are per-plugin - (filtered by grants at spawn), so the registry must expose - "build map for grant-set", not a global attach. - -### Test impact - -- New per-domain RPC test files (mirroring `tools-.test.ts` on the MCP - side) + a registry unit suite. -- `rpc-host.test.ts` progressively shrinks to the dispatch core; - `plugin-host-deps.factory.test.ts` is deleted at the end of the drain. -- Integration suites (`tests/integration/plugins/*`, real fork/IPC) are the - parity net — they must pass unchanged throughout, same role - `tests/integration/categories.test.ts` played for the categories migration. - ---- - -## 4. Sequencing (the actual plan) - -A is not an alternative to B — **A is the first commit of B's journey**, and -none of it is throwaway. - -1. ~~**Now / opportunistically — Option A.**~~ **Done (2026-07).** Injectable - factory, tags/categories bridges deleted, wiring test rewritten against - stubs (see §2). -2. **Per domain, as the service migrations proceed** (`packingService`, - `dayNoteService` and `assignmentService` done 2026-07; `tripInviteService` - done 2026-07 with no factory impact — it was never imported here; - `reservationService` done 2026-07, draining the factory's last - plain-function reservation import — `notifyBookingChange` became a private - method over the already-injected `ReservationsService`; `dayService` done - 2026-07, the heaviest factory import — eleven symbols became the injected - `DaysService`; next per `src/nest/README.md`): migrate the - service to DI as usual; the factory swaps one legacy import for one - injected service. Optionally pilot `tags.rpc.ts` here — tags is small and - already fully DI on HTTP + MCP. -3. **Once ~half the domains are DI-native — commit to Option B.** Build the - registry package, validate() against `envelope.ts`, then drain the method - map domain by domain — the same strangler pattern that retired the Express - API and is retiring the legacy MCP registrars. -4. **Never — Option C** (Nest microservices transporter over the IPC channel). - -### Decision triggers - -- ~~**Start A** whenever there's a free session; it has no prerequisites.~~ Done. -- **Start B** when (a) the majority of plugin-facing domains have DI services, - or (b) the plugin API surface is about to grow significantly (new SDK - capability wave) — whichever comes first. -- **Stop at A** only if the plugin API is declared frozen: with no method - growth, B's compounding payoff never materializes. - ---- - -## 5. Key files (for whoever picks this up) - -| File | Why it matters | -|---|---| -| `src/nest/plugins/host/plugin-host-deps.factory.ts` | The wiring layer (Option A's product); Option B eventually deletes it | -| `src/nest/plugins/host/plugin-host-state.ts` | Process-wide host state (data-DB map, broker budgets) — module-level on purpose, see §1 | -| `src/nest/plugins/host/rpc-host.ts` | Enforcement core; `HostDeps` interface is the seam A preserved and B drains | -| `src/nest/plugins/protocol/envelope.ts` | Single source of truth for methods/permissions — must stay zero-import; B validates against it | -| `src/nest/plugins/plugin-runtime.service.ts` | The provider that owns the factory call site (supervisor field initializer) | -| `src/nest/plugins/supervisor/plugin-supervisor.ts` | Sandbox lifecycle — untouched by both options | -| `src/nest/todo/todo.bridge.ts` | Surviving bridge — kept only for the legacy `get_trip_summary` MCP registrar (`src/nest/files/files.bridge.ts` also survives, but for module-scope multer configs, not the plugin host) | -| `src/mcp/registry-handoff.ts` | The MCP precedent for non-container code — **not needed here**; documented to explain why | -| `nest-mcp/` (`@trek/nest-mcp`) | The proven decorator/registry/discovery blueprint Option B mirrors | -| `src/nest/README.md` | The per-service DI migration recipe this roadmap rides on | -| `tests/unit/plugins/plugin-host-deps.factory.test.ts` | The wiring suite (bore most of A's one-time test cost) | -| `tests/unit/plugins/rpc-host.test.ts` | Survived A untouched; shrinks under B | diff --git a/server/src/nest/plugins/host/rpc-kit/README.md b/server/src/nest/plugins/host/rpc-kit/README.md index 4ea5b6ba2..ebc3f2cbb 100644 --- a/server/src/nest/plugins/host/rpc-kit/README.md +++ b/server/src/nest/plugins/host/rpc-kit/README.md @@ -38,8 +38,9 @@ package as its data source, so it can read it directly. ## If this ever becomes its own package -`nest-mcp` is the worked example of the same layer as a separate workspace, and the -delta is instructive. Because `nest-mcp` must not import the host, it needs a +`nest-mcp` was the worked example of the same layer as a separate workspace (it has +since been folded into `server/src/nest-mcp/`, which retired the machinery below), and +the delta is instructive. Because `nest-mcp` could not import the host, it needed a `declare module` augmentation for its registries plus two `AssertExact` drift guards in `src/mcp/nest-mcp-policy.ts`, and a permissive `keyof X extends never ? string` fallback that silently disables all checking if someone forgets to augment. @@ -54,7 +55,7 @@ fallback that silently disables all checking if someone forgets to augment. 3. Root `package.json` `workspaces`, `server/package.json` deps, the root lockfile, the Dockerfile COPY stages and a source alias in `server/vitest.config.ts` all change. -Note that a workspace would *not* let `trek-plugin-sdk` consume it: `nest-mcp` is +Note that a workspace would *not* let `trek-plugin-sdk` consume it: `nest-mcp` was `"private": true`, and so would this be. The SDK gets its copy of the permission facts from the generated `plugin-sdk/src/generated/host-facts.ts` instead. diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 869528099..dacfc7904 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -2,7 +2,7 @@ import { McpController, Tool, ResourceTemplate, type McpContext, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; diff --git a/server/src/nest/share/share.mcp.ts b/server/src/nest/share/share.mcp.ts index 0ddefc3c9..525fc00ee 100644 --- a/server/src/nest/share/share.mcp.ts +++ b/server/src/nest/share/share.mcp.ts @@ -2,7 +2,7 @@ import { McpController, Tool, type McpContext, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, demoDenied, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; diff --git a/server/src/nest/tags/tags.mcp.ts b/server/src/nest/tags/tags.mcp.ts index a13ae6736..c898c30dc 100644 --- a/server/src/nest/tags/tags.mcp.ts +++ b/server/src/nest/tags/tags.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; import { TagsService } from './tags.service'; diff --git a/server/src/nest/todo/todo.mcp.ts b/server/src/nest/todo/todo.mcp.ts index be1cb5e20..278cd8ebe 100644 --- a/server/src/nest/todo/todo.mcp.ts +++ b/server/src/nest/todo/todo.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; diff --git a/server/src/nest/transit/transit.mcp.ts b/server/src/nest/transit/transit.mcp.ts index 918926248..3517b1c25 100644 --- a/server/src/nest/transit/transit.mcp.ts +++ b/server/src/nest/transit/transit.mcp.ts @@ -2,7 +2,7 @@ import { McpController, Tool, type McpContext, TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT, TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, demoDenied, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; @@ -29,7 +29,7 @@ const transitRateLimiter = new RateLimitService(); const transitModes = z.enum(['TRANSIT', ...SCHEDULED_TRANSIT_MODES]); -// Local 2-arg variant (the @trek/nest-mcp errorResult takes a string only): +// Local 2-arg variant (the src/nest-mcp errorResult takes a string only): // surface the provider/validation message when there is one, else the fallback. function errorResult(err: unknown, fallback: string) { return { diff --git a/server/src/nest/trips/trip-prompts.mcp.ts b/server/src/nest/trips/trip-prompts.mcp.ts index 8fab9e7e1..4d88b4608 100644 --- a/server/src/nest/trips/trip-prompts.mcp.ts +++ b/server/src/nest/trips/trip-prompts.mcp.ts @@ -1,4 +1,4 @@ -import { McpController, Prompt, type McpContext } from '@trek/nest-mcp'; +import { McpController, Prompt, type McpContext } from '../../nest-mcp'; import { z } from 'zod'; import { ADDON_IDS } from '../../addons'; import { addonGate } from '../addons/addon-gate'; diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index b4213f5ae..ee2057904 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -3,7 +3,7 @@ import { TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { McpToolGuardsService } from '../mcp-shared/mcp-tool-guards.service'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; diff --git a/server/src/nest/vacay/vacay.mcp.ts b/server/src/nest/vacay/vacay.mcp.ts index d02e5176b..99744e112 100644 --- a/server/src/nest/vacay/vacay.mcp.ts +++ b/server/src/nest/vacay/vacay.mcp.ts @@ -4,7 +4,7 @@ import { TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, -} from '@trek/nest-mcp'; +} from '../../nest-mcp'; import { z } from 'zod'; import { AuthService } from '../auth/auth.service'; import { ADDON_IDS } from '../../addons'; diff --git a/server/src/nest/weather/weather.mcp.ts b/server/src/nest/weather/weather.mcp.ts index e39f3f7c2..adb8fd56e 100644 --- a/server/src/nest/weather/weather.mcp.ts +++ b/server/src/nest/weather/weather.mcp.ts @@ -1,4 +1,4 @@ -import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '@trek/nest-mcp'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '../../nest-mcp'; import { z } from 'zod'; import { WeatherService } from './weather.service'; diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index a0bad3369..f0d6285a9 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -1,4 +1,4 @@ -import { createTestRegistry, type McpRegistry } from '@trek/nest-mcp'; +import { createTestRegistry, type McpRegistry } from '../../src/nest-mcp'; import { db } from '../../src/db/database'; import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../src/mcp/nest-mcp-policy'; import { AssignmentsMcp } from '../../src/nest/assignments/assignments.mcp'; diff --git a/server/tests/unit/mcp/nest-mcp-policy.test.ts b/server/tests/unit/mcp/nest-mcp-policy.test.ts index 32db5a8c9..97ed6dd84 100644 --- a/server/tests/unit/mcp/nest-mcp-policy.test.ts +++ b/server/tests/unit/mcp/nest-mcp-policy.test.ts @@ -8,7 +8,7 @@ * case below has to cast its way past the union on purpose. */ import { describe, it, expect } from 'vitest'; -import { createTestRegistry, McpController, Tool, type McpDeclarativeAccess } from '@trek/nest-mcp'; +import { createTestRegistry, McpController, Tool, type McpDeclarativeAccess } from '../../../src/nest-mcp'; import { MCP_ACCESS_GROUPS_MATCH_SCOPE_GROUPS, trekMcpAccessPolicy, diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index fef8a184e..61f11811d 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -61,7 +61,7 @@ import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember, createPackingItem, createBudgetItem } from '../../helpers/factories'; -import { createTestRegistry } from '@trek/nest-mcp'; +import { createTestRegistry } from '../../../src/nest-mcp'; import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy'; import { TripsMcp } from '../../../src/nest/trips/trips.mcp'; import { TripPromptsMcp } from '../../../src/nest/trips/trip-prompts.mcp'; diff --git a/nest-mcp/tests/attach.test.ts b/server/tests/unit/nest-mcp/attach.test.ts similarity index 97% rename from nest-mcp/tests/attach.test.ts rename to server/tests/unit/nest-mcp/attach.test.ts index 74d966030..d8324c538 100644 --- a/nest-mcp/tests/attach.test.ts +++ b/server/tests/unit/nest-mcp/attach.test.ts @@ -8,7 +8,7 @@ import { Tool, type McpAccessPolicy, type McpContext, -} from '../src'; +} from '../../../src/nest-mcp'; import { createAttachHarness, type AttachHarness, type TestCtx } from './harness'; import { afterEach, describe, expect, it } from 'vitest'; @@ -29,7 +29,7 @@ class FixtureMcp { description: 'Reads.', inputSchema: { name: z.string() }, annotations: { readOnlyHint: true }, - access: { group: 'g', mode: 'read' }, + access: { group: 'trips', mode: 'read' }, }) async readTool({ name }: { name: string }, ctx: TestCtx) { return { @@ -37,7 +37,7 @@ class FixtureMcp { }; } - @Tool({ name: 'write_tool', inputSchema: {}, access: { group: 'g', mode: 'write' } }) + @Tool({ name: 'write_tool', inputSchema: {}, access: { group: 'trips', mode: 'write' } }) async writeTool(_args: Record, ctx: TestCtx) { return { content: [{ type: 'text', text: `wrote as ${ctx.userId}` }] }; } @@ -52,7 +52,12 @@ class FixtureMcp { 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' } }) + @Resource({ + name: 'fixture_doc', + uri: 'test://doc', + mimeType: 'text/plain', + access: { group: 'trips', mode: 'read' }, + }) async readDoc(uri: URL, ctx: TestCtx) { return { contents: [{ uri: uri.href, text: `doc for ${ctx.userId}` }] }; } @@ -70,7 +75,7 @@ class FixtureMcp { @McpController() class DeclarativeOnly { - @Tool({ name: 'gated_tool', access: { group: 'g', mode: 'read' } }) + @Tool({ name: 'gated_tool', access: { group: 'trips', mode: 'read' } }) async gated() { return { content: [{ type: 'text', text: 'gated' }] }; } @@ -80,7 +85,7 @@ class DeclarativeOnly { class WhenMcp { @Tool({ name: 'when_scoped_tool', - access: { group: 'g', mode: 'read' }, + access: { group: 'trips', mode: 'read' }, when: (ctx: McpContext) => (ctx as TestCtx).allow === true, }) async whenScoped() { diff --git a/nest-mcp/tests/create-test-registry.test.ts b/server/tests/unit/nest-mcp/create-test-registry.test.ts similarity index 88% rename from nest-mcp/tests/create-test-registry.test.ts rename to server/tests/unit/nest-mcp/create-test-registry.test.ts index bb4e27de9..211a7b281 100644 --- a/nest-mcp/tests/create-test-registry.test.ts +++ b/server/tests/unit/nest-mcp/create-test-registry.test.ts @@ -1,4 +1,4 @@ -import { createTestRegistry, McpController, McpRegistry, Tool, type McpAccessPolicy } from '../src'; +import { createTestRegistry, McpController, McpRegistry, Tool, type McpAccessPolicy } from '../../../src/nest-mcp'; import type { TestCtx } from './harness'; import { describe, expect, it } from 'vitest'; @@ -11,7 +11,7 @@ class One { @McpController() class Two { - @Tool({ name: 'two_tool', access: { group: 't', mode: 'write' } }) + @Tool({ name: 'two_tool', access: { group: 'trips', mode: 'write' } }) two() {} } @@ -32,7 +32,7 @@ describe('createTestRegistry', () => { accessPolicy: () => true, validateAccess: ({ group, mode }) => `unknown group '${group}' (mode '${mode}')`, }), - ).toThrow(/invalid access declarations: tool "two_tool" \(Two\.two\): unknown group 't' \(mode 'write'\)/); + ).toThrow(/invalid access declarations: tool "two_tool" \(Two\.two\): unknown group 'trips' \(mode 'write'\)/); }); it('forwards the accessPolicy option', () => { @@ -44,6 +44,6 @@ describe('createTestRegistry', () => { 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']); + expect(calls).toEqual(['trips:write:1']); }); }); diff --git a/nest-mcp/tests/decorators.test.ts b/server/tests/unit/nest-mcp/decorators.test.ts similarity index 93% rename from nest-mcp/tests/decorators.test.ts rename to server/tests/unit/nest-mcp/decorators.test.ts index ed38b5e25..e9f9b1a16 100644 --- a/nest-mcp/tests/decorators.test.ts +++ b/server/tests/unit/nest-mcp/decorators.test.ts @@ -1,5 +1,5 @@ -import { McpController, Prompt, Resource, ResourceTemplate, Tool } from '../src'; -import { getEntry, isMcpController, type ClassRef } from '../src/metadata'; +import { McpController, Prompt, Resource, ResourceTemplate, Tool } from '../../../src/nest-mcp'; +import { getEntry, isMcpController, type ClassRef } from '../../../src/nest-mcp/metadata'; import { describe, expect, it } from 'vitest'; import { z } from 'zod'; @@ -11,7 +11,7 @@ class Decorated { description: 'Does a thing.', inputSchema: { what: z.string() }, annotations: { readOnlyHint: true }, - access: { group: 'things', mode: 'read' }, + access: { group: 'places', mode: 'read' }, }) doThing() {} @@ -61,7 +61,7 @@ describe('nest-mcp decorators', () => { name: 'do_thing', description: 'Does a thing.', annotations: { readOnlyHint: true }, - access: { group: 'things', mode: 'read' }, + access: { group: 'places', mode: 'read' }, }, }); diff --git a/nest-mcp/tests/discovery.test.ts b/server/tests/unit/nest-mcp/discovery.test.ts similarity index 96% rename from nest-mcp/tests/discovery.test.ts rename to server/tests/unit/nest-mcp/discovery.test.ts index 8fcdb5076..53b19b928 100644 --- a/nest-mcp/tests/discovery.test.ts +++ b/server/tests/unit/nest-mcp/discovery.test.ts @@ -1,8 +1,9 @@ -import { McpController, McpModule, McpRegistryService, Tool, type McpAccessPolicy } from '../src'; +import { McpController, McpModule, McpRegistryService, Tool, type McpAccessPolicy } from '../../../src/nest-mcp'; import { createAttachHarness, type AttachHarness, type TestCtx } from './harness'; import { Injectable, Module } from '@nestjs/common'; import { Test } from '@nestjs/testing'; +import 'reflect-metadata'; import { afterEach, describe, expect, it } from 'vitest'; import { z } from 'zod'; @@ -18,7 +19,7 @@ class CounterService { class CounterMcp { constructor(private readonly counter: CounterService) {} - @Tool({ name: 'count', inputSchema: { by: z.number().optional() }, access: { group: 'c', mode: 'read' } }) + @Tool({ name: 'count', inputSchema: { by: z.number().optional() }, access: { group: 'trips', mode: 'read' } }) async count(_args: { by?: number }, ctx: TestCtx) { return { content: [{ type: 'text', text: JSON.stringify({ value: this.counter.next(), userId: ctx.userId }) }] }; } diff --git a/nest-mcp/tests/harness.ts b/server/tests/unit/nest-mcp/harness.ts similarity index 98% rename from nest-mcp/tests/harness.ts rename to server/tests/unit/nest-mcp/harness.ts index 68aee898e..f54d13f1a 100644 --- a/nest-mcp/tests/harness.ts +++ b/server/tests/unit/nest-mcp/harness.ts @@ -1,4 +1,4 @@ -import type { McpAttachOptions, McpContext, McpRegistry } from '../src'; +import type { McpAttachOptions, McpContext, McpRegistry } from '../../../src/nest-mcp'; import { Client } from '@modelcontextprotocol/sdk/client/index'; import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; diff --git a/nest-mcp/tests/helpers.test.ts b/server/tests/unit/nest-mcp/helpers.test.ts similarity index 98% rename from nest-mcp/tests/helpers.test.ts rename to server/tests/unit/nest-mcp/helpers.test.ts index 421b0df84..31c17d4ef 100644 --- a/nest-mcp/tests/helpers.test.ts +++ b/server/tests/unit/nest-mcp/helpers.test.ts @@ -8,7 +8,7 @@ import { TOOL_ANNOTATIONS_OPEN_WORLD_READONLY, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, -} from '../src'; +} from '../../../src/nest-mcp'; import { describe, expect, it } from 'vitest'; diff --git a/nest-mcp/tests/registry.test.ts b/server/tests/unit/nest-mcp/registry.test.ts similarity index 90% rename from nest-mcp/tests/registry.test.ts rename to server/tests/unit/nest-mcp/registry.test.ts index 352221096..14ef433a5 100644 --- a/nest-mcp/tests/registry.test.ts +++ b/server/tests/unit/nest-mcp/registry.test.ts @@ -1,4 +1,4 @@ -import { McpController, McpRegistry, Resource, Tool, type McpAccessValidator } from '../src'; +import { McpController, McpRegistry, Resource, Tool, type McpAccessValidator } from '../../../src/nest-mcp'; import { describe, expect, it, vi } from 'vitest'; @@ -7,7 +7,7 @@ class Alpha { @Tool({ name: 'alpha_one' }) one() {} - @Tool({ name: 'alpha_two', access: { group: 'a', mode: 'write' } }) + @Tool({ name: 'alpha_two', access: { group: 'trips', mode: 'write' } }) two() {} helper() {} @@ -35,7 +35,7 @@ describe('McpRegistry.register / list', () => { name: 'alpha_two', className: 'Alpha', methodName: 'two', - access: { group: 'a', mode: 'write' }, + access: { group: 'trips', mode: 'write' }, }), ]); }); @@ -115,10 +115,10 @@ class MixedAccess { @Tool({ name: 'predicate_tool', access: () => true }) predicated() {} - @Tool({ name: 'declared_read', access: { group: 'a', mode: 'read' } }) + @Tool({ name: 'declared_read', access: { group: 'trips', mode: 'read' } }) declaredRead() {} - @Tool({ name: 'declared_write', access: { group: 'b', mode: 'write' } }) + @Tool({ name: 'declared_write', access: { group: 'places', mode: 'write' } }) declaredWrite() {} } @@ -130,7 +130,7 @@ describe('McpRegistry.validate — validateAccess hook', () => { expect(() => registry.validate()).not.toThrow(); expect(validateAccess).toHaveBeenCalledTimes(2); expect(validateAccess).toHaveBeenCalledWith( - { group: 'a', mode: 'read' }, + { group: 'trips', mode: 'read' }, expect.objectContaining({ kind: 'tool', name: 'declared_read', @@ -139,7 +139,7 @@ describe('McpRegistry.validate — validateAccess hook', () => { }), ); expect(validateAccess).toHaveBeenCalledWith( - { group: 'b', mode: 'write' }, + { group: 'places', mode: 'write' }, expect.objectContaining({ kind: 'tool', name: 'declared_write', @@ -163,7 +163,7 @@ describe('McpRegistry.validate — validateAccess hook', () => { }); registry.register(new MixedAccess()); expect(() => registry.validate()).toThrow( - /Invalid MCP registry: invalid access declarations: tool "declared_read" \(MixedAccess\.declaredRead\): no 'a:read' scope, tool "declared_write" \(MixedAccess\.declaredWrite\): no 'b:write' scope/, + /Invalid MCP registry: invalid access declarations: tool "declared_read" \(MixedAccess\.declaredRead\): no 'trips:read' scope, tool "declared_write" \(MixedAccess\.declaredWrite\): no 'places:write' scope/, ); }); diff --git a/server/tests/unit/nest/mcp-transport.service.test.ts b/server/tests/unit/nest/mcp-transport.service.test.ts index 58014a27c..f5c1de0c1 100644 --- a/server/tests/unit/nest/mcp-transport.service.test.ts +++ b/server/tests/unit/nest/mcp-transport.service.test.ts @@ -25,7 +25,7 @@ import type { TokenService } from '../../../src/nest/tokens/token.service'; import type { OauthService } from '../../../src/nest/oauth/oauth.service'; import type { AddonsService } from '../../../src/nest/addons/addons.service'; import type { AuditService } from '../../../src/nest/audit/audit.service'; -import type { McpRegistryService } from '@trek/nest-mcp'; +import type { McpRegistryService } from '../../../src/nest-mcp'; import { IS_PUBLIC } from '../../../src/nest/auth/public.decorator'; const user = { id: 7, username: 'u', email: 'u@example.com', role: 'user' as const }; diff --git a/server/tsconfig.json b/server/tsconfig.json index 6cff6213b..b968f0531 100644 --- a/server/tsconfig.json +++ b/server/tsconfig.json @@ -32,7 +32,9 @@ "@modelcontextprotocol/sdk/server/auth/clients": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/clients.js"], "@modelcontextprotocol/sdk/server/auth/errors": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/errors.js"], "@modelcontextprotocol/sdk/server/auth/types": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/server/auth/types.js"], - "@modelcontextprotocol/sdk/shared/auth": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth.js"] + "@modelcontextprotocol/sdk/shared/auth": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/shared/auth.js"], + "@modelcontextprotocol/sdk/client/index": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js"], + "@modelcontextprotocol/sdk/inMemory": ["../node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js"] } }, "include": ["src"], diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 40fc602ee..8a6bd6d85 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -141,6 +141,9 @@ export default defineConfig({ 'src/db/**/*.ts': { statements: 73, branches: 38, functions: 59, lines: 80 }, 'src/mcp/**/*.ts': { statements: 58, branches: 43, functions: 63, lines: 60 }, 'src/middleware/**/*.ts': { statements: 91, branches: 89, functions: 87, lines: 94 }, + // The folded-in nest-mcp decorator/registry layer keeps the 80% floor + // its own workspace gate enforced (tests/unit/nest-mcp/). + 'src/nest-mcp/**/*.ts': { statements: 80, branches: 80, functions: 80, lines: 80 }, 'src/systemNotices/**/*.ts': { statements: 84, branches: 67, functions: 93, lines: 85 }, 'src/utils/**/*.ts': { statements: 92, branches: 87, functions: 89, lines: 96 }, // index.ts, bootstrap.ts, scheduler.ts, config.ts, websocket.ts and the @@ -151,13 +154,6 @@ 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). diff --git a/sonar-project.properties b/sonar-project.properties index e7f3c58e4..25af293be 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -3,10 +3,10 @@ sonar.projectName=TREK sonar.sourceEncoding=UTF-8 # Sources — one entry per workspace package (plugin-sdk is standalone but lives in-repo) -sonar.sources=client/src,server/src,shared/src,nest-mcp/src,plugin-sdk/src +sonar.sources=client/src,server/src,shared/src,plugin-sdk/src # Tests — dedicated test dirs per package, plus co-located client tests in src -sonar.tests=server/tests,client/tests,client/e2e,client/src,nest-mcp/tests,plugin-sdk/test +sonar.tests=server/tests,client/tests,client/e2e,client/src,plugin-sdk/test sonar.test.inclusions=\ server/tests/**/*.test.ts,\ client/tests/**/*.test.ts,\ @@ -14,7 +14,6 @@ sonar.test.inclusions=\ client/e2e/**/*.spec.ts,\ client/src/**/*.test.ts,\ client/src/**/*.test.tsx,\ - nest-mcp/tests/**/*.test.ts,\ plugin-sdk/test/**/*.test.ts # Keep generated output and test files out of source analysis @@ -32,8 +31,7 @@ sonar.exclusions=\ # shared/ has no tests yet and plugin-sdk doesn't emit lcov; see coverage exclusions. sonar.javascript.lcov.reportPaths=\ server/coverage/lcov.info,\ - client/coverage/lcov.info,\ - nest-mcp/coverage/lcov.info + client/coverage/lcov.info # Infrastructure/bootstrap files and packages without coverage instrumentation sonar.coverage.exclusions=\