mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
dist
|
||||
node_modules
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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'],
|
||||
},
|
||||
);
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
// Required by @nestjs/testing (Test.createTestingModule) — the package's own
|
||||
// decorators/registry deliberately do NOT need it.
|
||||
import 'reflect-metadata';
|
||||
@@ -1,11 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": false,
|
||||
"rootDir": "./src",
|
||||
"outDir": "./dist",
|
||||
"sourceMap": false
|
||||
},
|
||||
"include": ["src"],
|
||||
"exclude": ["node_modules", "dist", "tests"]
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
Generated
+23
-101
@@ -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",
|
||||
|
||||
+6
-7
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<ScopeGroup, true> {}
|
||||
// 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<ScopeMode, true> {}
|
||||
}
|
||||
|
||||
type AssertExact<A, B> = [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<McpAccessGroup, ScopeGroup> = true;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<string, never>, 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: <pretty JSON> }] }
|
||||
errorResult('Tag not found.') // { content: [...], isError: true } — message verbatim
|
||||
demoDenied() // canned demo-mode write refusal
|
||||
```
|
||||
|
||||
Six `TOOL_ANNOTATIONS_*` presets cover the read/write/delete/idempotency matrix (plus `OPEN_WORLD` variants). Host-specific canned errors (permission wording, RBAC lookups) belong in the host, built on `errorResult` — see TREK's `server/src/mcp/tools/_shared.ts`, which re-exports the generic helpers from here.
|
||||
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<MyGroupUnion, true> {}
|
||||
}
|
||||
```
|
||||
`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.
|
||||
@@ -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<MyGroupUnion, true> {}
|
||||
* }
|
||||
*
|
||||
* 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<ScopeGroup, true> {}
|
||||
|
||||
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<MyModeUnion, true> {}
|
||||
* }
|
||||
*
|
||||
* 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<ScopeMode, true> {}
|
||||
|
||||
export type McpAccessMode = keyof McpAccessModeRegistry extends never
|
||||
? 'read' | 'write'
|
||||
@@ -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.
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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) {}
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
/**
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
|
||||
`<domain>.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 `<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-<domain>.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 |
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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<string, never>, 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() {
|
||||
+4
-4
@@ -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<McpRegistry['attach']>[0], { userId: 1 } as Parameters<McpRegistry['attach']>[1]);
|
||||
expect(calls).toEqual(['t:write:1']);
|
||||
expect(calls).toEqual(['trips:write:1']);
|
||||
});
|
||||
});
|
||||
@@ -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' },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 }) }] };
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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';
|
||||
|
||||
@@ -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/,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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=\
|
||||
|
||||
Reference in New Issue
Block a user