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