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
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json charts/trek/Chart.yaml
|
||||
git add package.json package-lock.json server/package.json client/package.json shared/package.json nest-mcp/package.json charts/trek/Chart.yaml
|
||||
git commit -m "chore: bump version to $NEW_VERSION [skip ci]"
|
||||
git tag -a "v$NEW_VERSION" -m "v$NEW_VERSION"
|
||||
git push origin main --follow-tags
|
||||
|
||||
@@ -10,6 +10,7 @@ on:
|
||||
- 'server/**'
|
||||
- 'client/**'
|
||||
- 'shared/**'
|
||||
- 'nest-mcp/**'
|
||||
- '.github/workflows/test.yml'
|
||||
|
||||
jobs:
|
||||
@@ -49,6 +50,41 @@ jobs:
|
||||
- name: Run tests
|
||||
run: cd shared && npm test
|
||||
|
||||
nest-mcp-package:
|
||||
name: nest-mcp Package
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: package-lock.json
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci --workspace nest-mcp
|
||||
|
||||
- name: Ensure @swc/core's Linux binary for unplugin-swc
|
||||
# Same lockfile quirk as server-tests: the Linux native binary is
|
||||
# omitted, and nest-mcp's vitest config uses the SWC transform too.
|
||||
run: |
|
||||
SWC_VERSION=$(node -p "require('@swc/core/package.json').version")
|
||||
npm install --no-save --legacy-peer-deps "@swc/core-linux-x64-gnu@$SWC_VERSION"
|
||||
|
||||
- name: Build
|
||||
run: npm run build --workspace=nest-mcp
|
||||
|
||||
- name: Typecheck
|
||||
run: cd nest-mcp && npm run typecheck
|
||||
|
||||
- name: Lint
|
||||
run: cd nest-mcp && npm run lint:check
|
||||
|
||||
- name: Run tests
|
||||
run: cd nest-mcp && npm test
|
||||
|
||||
server-tests:
|
||||
name: Server Tests
|
||||
runs-on: ubuntu-latest
|
||||
@@ -77,9 +113,20 @@ jobs:
|
||||
- name: Build shared
|
||||
run: npm run build --workspace=shared
|
||||
|
||||
- name: Build nest-mcp
|
||||
# Server typecheck/build resolve @trek/nest-mcp's types from its dist
|
||||
# (tests alias the package source, but tsc does not).
|
||||
run: npm run build --workspace=nest-mcp
|
||||
|
||||
- name: Build server (tsc -> dist)
|
||||
run: cd server && npm run build
|
||||
|
||||
- name: Smoke production require chain
|
||||
# Vitest aliases @trek/nest-mcp to its source, so only this exercises
|
||||
# what production runs: nest-mcp's built dist resolving the MCP SDK's
|
||||
# subpath exports through the tsconfig-paths/register runtime hook.
|
||||
run: cd server && node --require tsconfig-paths/register -e "require('@trek/nest-mcp')"
|
||||
|
||||
- name: Typecheck
|
||||
run: cd server && npm run typecheck
|
||||
|
||||
|
||||
@@ -31,9 +31,12 @@ FROM node:24-alpine AS server-builder
|
||||
WORKDIR /app
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY nest-mcp/package.json ./nest-mcp/
|
||||
COPY server/package.json ./server/
|
||||
RUN npm ci --workspace=server --ignore-scripts
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
COPY nest-mcp/ ./nest-mcp/
|
||||
RUN npm run build --workspace=nest-mcp
|
||||
COPY server/ ./server/
|
||||
RUN npm run build --workspace=server
|
||||
|
||||
@@ -44,6 +47,7 @@ WORKDIR /app
|
||||
# Workspace manifests only — source never enters this stage.
|
||||
COPY package.json package-lock.json ./
|
||||
COPY shared/package.json ./shared/
|
||||
COPY nest-mcp/package.json ./nest-mcp/
|
||||
COPY server/package.json ./server/
|
||||
|
||||
RUN apt-get update && \
|
||||
@@ -84,6 +88,10 @@ COPY server/scripts/migrate-encryption.ts ./server/scripts/migrate-encryption.ts
|
||||
# Admin recovery script (node server/reset-admin.js) for locked-out installs.
|
||||
COPY server/reset-admin.js ./server/reset-admin.js
|
||||
COPY --from=shared-builder /app/shared/dist ./shared/dist
|
||||
# server dist requires @trek/nest-mcp at runtime through the workspace symlink;
|
||||
# its dist's MCP SDK subpath requires ride the same tsconfig-paths/register
|
||||
# hook the server already boots with.
|
||||
COPY --from=server-builder /app/nest-mcp/dist ./nest-mcp/dist
|
||||
COPY --from=client-builder /app/client/dist ./server/public
|
||||
COPY --from=client-builder /app/client/public/fonts ./server/public/fonts
|
||||
|
||||
|
||||
@@ -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",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"nest-mcp",
|
||||
"server",
|
||||
"shared"
|
||||
],
|
||||
@@ -186,6 +187,55 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"nest-mcp": {
|
||||
"name": "@trek/nest-mcp",
|
||||
"version": "3.4.1",
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@nestjs/common": "^11.1.24",
|
||||
"@nestjs/core": "^11.1.24",
|
||||
"@nestjs/testing": "^11.1.24",
|
||||
"@swc/core": "^1.15.40",
|
||||
"@trivago/prettier-plugin-sort-imports": "^6.0.2",
|
||||
"@vitest/coverage-istanbul": "^4.1.9",
|
||||
"eslint": "^10.3.0",
|
||||
"eslint-config-flat-gitignore": "^2.3.0",
|
||||
"eslint-config-prettier": "^10.1.8",
|
||||
"eslint-plugin-prettier": "^5.5.5",
|
||||
"prettier": "3.8.3",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1",
|
||||
"typescript": "^6.0.2",
|
||||
"typescript-eslint": "^8.58.2",
|
||||
"unplugin-swc": "^1.5.9",
|
||||
"vitest": "^4.1.9",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.29.0",
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"zod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"nest-mcp/node_modules/prettier": {
|
||||
"version": "3.8.3",
|
||||
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz",
|
||||
"integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/prettier/prettier?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/@adobe/css-tools": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz",
|
||||
@@ -7123,6 +7173,10 @@
|
||||
"resolved": "client",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@trek/nest-mcp": {
|
||||
"resolved": "nest-mcp",
|
||||
"link": true
|
||||
},
|
||||
"node_modules/@trek/server": {
|
||||
"resolved": "server",
|
||||
"link": true
|
||||
@@ -21328,6 +21382,7 @@
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@nestjs/swagger": "^11.4.5",
|
||||
"@simplewebauthn/server": "^13.1.2",
|
||||
"@trek/nest-mcp": "*",
|
||||
"@trek/shared": "*",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
+7
-6
@@ -4,6 +4,7 @@
|
||||
"version": "3.4.1",
|
||||
"workspaces": [
|
||||
"client",
|
||||
"nest-mcp",
|
||||
"server",
|
||||
"shared"
|
||||
],
|
||||
@@ -15,14 +16,14 @@
|
||||
"version:preminor": "npm version preminor --preid=beta --workspaces --include-workspace-root --no-git-tag-version",
|
||||
"version:prepatch": "npm version prepatch --preid=alpha --workspaces --include-workspace-root --no-git-tag-version",
|
||||
"version:prerelease": "npm version prerelease --preid=pre --workspaces --include-workspace-root --no-git-tag-version",
|
||||
"dev": "npm run build --workspace=shared && concurrently --names shared,server,client \"npm run build:watch --workspace=shared\" \"npm run dev --workspace=server\" \"npm run dev --workspace=client\"",
|
||||
"build": "npm run build --workspace=shared && npm run build --workspace=server && npm run build --workspace=client",
|
||||
"test": "npm run test --workspace=shared && npm run test --workspace=server && npm run test --workspace=client",
|
||||
"dev": "npm run build --workspace=shared && npm run build --workspace=nest-mcp && concurrently --names shared,server,client \"npm run build:watch --workspace=shared\" \"npm run dev --workspace=server\" \"npm run dev --workspace=client\"",
|
||||
"build": "npm run build --workspace=shared && npm run build --workspace=nest-mcp && npm run build --workspace=server && npm run build --workspace=client",
|
||||
"test": "npm run test --workspace=shared && npm run test --workspace=nest-mcp && npm run test --workspace=server && npm run test --workspace=client",
|
||||
"test:cov": "npm run test:coverage --workspace=server && npm run test:coverage --workspace=client",
|
||||
"test:e2e": "npm run test:e2e --workspace=server",
|
||||
"lint": "npm run lint --workspace=shared && npm run lint --workspace=server && npm run lint --workspace=client",
|
||||
"format": "npm run format --workspace=shared && npm run format --workspace=server && npm run format --workspace=client",
|
||||
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=server && npm run format:check --workspace=client"
|
||||
"lint": "npm run lint --workspace=shared && npm run lint --workspace=nest-mcp && npm run lint --workspace=server && npm run lint --workspace=client",
|
||||
"format": "npm run format --workspace=shared && npm run format --workspace=nest-mcp && npm run format --workspace=server && npm run format --workspace=client",
|
||||
"format:check": "npm run format:check --workspace=shared && npm run format:check --workspace=nest-mcp && npm run format:check --workspace=server && npm run format:check --workspace=client"
|
||||
},
|
||||
"devDependencies": {
|
||||
"concurrently": "^10.0.4",
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@nestjs/platform-express": "^11.1.24",
|
||||
"@nestjs/swagger": "^11.4.5",
|
||||
"@simplewebauthn/server": "^13.1.2",
|
||||
"@trek/nest-mcp": "*",
|
||||
"@trek/shared": "*",
|
||||
"archiver": "^6.0.1",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
||||
@@ -8,6 +8,8 @@ import { applyGlobalMiddleware } from './middleware/globalMiddleware';
|
||||
import { applyPlatformUploads, applyPlatformTransport, applyPlatformStatic } from './nest/platform/platform.routes';
|
||||
import { apiDocsEnabled } from './nest/common/api-docs.kill-switch';
|
||||
import { setupApiDocs } from './nest/platform/api-docs';
|
||||
import { McpRegistryService } from '@trek/nest-mcp';
|
||||
import { setMcpRegistry } from './mcp/registry-handoff';
|
||||
|
||||
/**
|
||||
* Builds the unified TREK NestJS application that serves the ENTIRE surface — the
|
||||
@@ -56,5 +58,8 @@ export async function buildApp(): Promise<INestApplication> {
|
||||
applyPlatformStatic(instance);
|
||||
if (apiDocsEnabled()) setupApiDocs(app);
|
||||
await app.init();
|
||||
// The /mcp handler is mounted pre-init (step 3) and has no DI access — hand
|
||||
// it the boot-discovered registry now that the container is fully built.
|
||||
setMcpRegistry(app.get(McpRegistryService));
|
||||
return app;
|
||||
}
|
||||
|
||||
@@ -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 { registerJourneyTools } from './tools/journey';
|
||||
import { registerReservationTools } from './tools/reservations';
|
||||
import { registerTagTools } from './tools/tags';
|
||||
import { registerMapsWeatherTools } from './tools/mapsWeather';
|
||||
import { registerNotificationTools } from './tools/notifications';
|
||||
import { registerAtlasTools } from './tools/atlas';
|
||||
@@ -18,6 +17,7 @@ import { registerTransportTools } from './tools/transports';
|
||||
import { registerTransitTools } from './tools/transit';
|
||||
import { registerVacayTools } from './tools/vacay';
|
||||
import { registerMcpPrompts } from './tools/prompts';
|
||||
import { getMcpRegistry } from './registry-handoff';
|
||||
|
||||
export function registerTools(server: McpServer, userId: number, scopes: string[] | null, isStaticToken = false, getDeprecationNotice: () => string | null = () => null): void {
|
||||
registerTripTools(server, userId, scopes, getDeprecationNotice);
|
||||
@@ -36,8 +36,6 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
|
||||
registerAssignmentTools(server, userId, scopes);
|
||||
|
||||
registerTagTools(server, userId, scopes);
|
||||
|
||||
registerMapsWeatherTools(server, userId, scopes);
|
||||
|
||||
registerNotificationTools(server, userId, scopes);
|
||||
@@ -57,4 +55,11 @@ export function registerTools(server: McpServer, userId: number, scopes: string[
|
||||
registerTodoTools(server, userId, scopes);
|
||||
|
||||
registerMcpPrompts(server, userId, isStaticToken);
|
||||
|
||||
// Decorator-registered domains (@trek/nest-mcp) — migrating off the legacy
|
||||
// registrar fan-out above, one domain at a time. Unset registry (direct
|
||||
// callers without a Nest app, e.g. unit tests) ⇒ skip; the test harness
|
||||
// attaches its own via createTestRegistry + setMcpRegistry.
|
||||
const registry = getMcpRegistry();
|
||||
if (registry) registry.attach(server, { userId, scopes, isStaticToken });
|
||||
}
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import { errorResult } from '@trek/nest-mcp';
|
||||
import { db } from '../../db/database';
|
||||
import { checkPermission } from '../../services/permissions';
|
||||
import { broadcast } from '../../websocket';
|
||||
|
||||
// Generic MCP result helpers and annotation presets live in @trek/nest-mcp
|
||||
// (single source for the legacy registrars below and the decorator-based
|
||||
// domains). Re-exported so registrar files keep importing from here.
|
||||
export {
|
||||
demoDenied,
|
||||
errorResult,
|
||||
ok,
|
||||
TOOL_ANNOTATIONS_DELETE,
|
||||
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT,
|
||||
TOOL_ANNOTATIONS_OPEN_WORLD_READONLY,
|
||||
TOOL_ANNOTATIONS_READONLY,
|
||||
TOOL_ANNOTATIONS_WRITE,
|
||||
} from '@trek/nest-mcp';
|
||||
|
||||
export function safeBroadcast(tripId: number, event: string, payload: Record<string, unknown>): void {
|
||||
try {
|
||||
broadcast(tripId, event, { ...payload, _source: 'mcp' });
|
||||
@@ -12,57 +28,12 @@ export function safeBroadcast(tripId: number, event: string, payload: Record<str
|
||||
|
||||
export const MAX_MCP_TRIP_DAYS = 90;
|
||||
|
||||
export const TOOL_ANNOTATIONS_READONLY = {
|
||||
readOnlyHint: true,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
export const TOOL_ANNOTATIONS_OPEN_WORLD_READONLY = {
|
||||
...TOOL_ANNOTATIONS_READONLY,
|
||||
openWorldHint: true,
|
||||
} as const;
|
||||
|
||||
export const TOOL_ANNOTATIONS_WRITE = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
export const TOOL_ANNOTATIONS_DELETE = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: true,
|
||||
idempotentHint: true,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
export const TOOL_ANNOTATIONS_NON_IDEMPOTENT = {
|
||||
readOnlyHint: false,
|
||||
destructiveHint: false,
|
||||
idempotentHint: false,
|
||||
openWorldHint: false,
|
||||
} as const;
|
||||
|
||||
export const TOOL_ANNOTATIONS_OPEN_WORLD_NON_IDEMPOTENT = {
|
||||
...TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
||||
openWorldHint: true,
|
||||
} as const;
|
||||
|
||||
export function demoDenied() {
|
||||
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
|
||||
}
|
||||
|
||||
export function noAccess() {
|
||||
return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true };
|
||||
return errorResult('Trip not found or access denied.');
|
||||
}
|
||||
|
||||
export function permissionDenied() {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: 'You do not have permission to perform this action on this trip.' }],
|
||||
isError: true,
|
||||
};
|
||||
return errorResult('You do not have permission to perform this action on this trip.');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -87,9 +58,5 @@ export function isAdminUser(userId: number): boolean {
|
||||
|
||||
/** Error response for admin-only tools, reproducing the REST `{ error: 'Admin access required' }` string. */
|
||||
export function adminRequired() {
|
||||
return { content: [{ type: 'text' as const, text: 'Admin access required' }], isError: true };
|
||||
}
|
||||
|
||||
export function ok(data: unknown) {
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] };
|
||||
return errorResult('Admin access required');
|
||||
}
|
||||
|
||||
@@ -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 { AddonsModule } from './addons/addons.module';
|
||||
import { PluginsModule } from './plugins/plugins.module';
|
||||
import { McpModule } from '@trek/nest-mcp';
|
||||
import { trekMcpAccessPolicy } from '../mcp/nest-mcp-policy';
|
||||
import { TrekExceptionFilter } from './common/trek-exception.filter';
|
||||
import { SpaFallbackFilter } from './platform/spa-fallback.filter';
|
||||
import { IdempotencyInterceptor } from './common/idempotency.interceptor';
|
||||
@@ -53,7 +55,7 @@ import { IdempotencyInterceptor } from './common/idempotency.interceptor';
|
||||
* migrated.
|
||||
*/
|
||||
@Module({
|
||||
imports: [AppConfigModule, DatabaseModule, WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, PluginsModule, BookingImportModule],
|
||||
imports: [AppConfigModule, DatabaseModule, McpModule.forRoot({ accessPolicy: trekMcpAccessPolicy }), WeatherModule, HelpModule, AirportsModule, ConfigModule, SystemNoticesModule, MapsModule, CategoriesModule, TagsModule, NotificationsModule, AtlasModule, VacayModule, PackingModule, TodoModule, BudgetModule, ReservationsModule, DaysModule, AssignmentsModule, PlacesModule, TripsModule, CollabModule, FilesModule, PhotosModule, MemoriesModule, AirtrailModule, JourneyModule, CollectionsModule, ShareModule, TripInviteModule, TransitModule, FeedsModule, SettingsModule, BackupModule, AuthModule, OidcModule, OauthModule, AdminModule, AddonsModule, PluginsModule, BookingImportModule],
|
||||
controllers: [HealthController],
|
||||
providers: [
|
||||
HealthService,
|
||||
|
||||
@@ -5,9 +5,10 @@ import { TagsService } from './tags.service';
|
||||
|
||||
/**
|
||||
* Non-Nest entry point for the tags domain — for code running OUTSIDE the Nest
|
||||
* container (MCP tools, plugin RPC host). Exports the legacy
|
||||
* services/tagService function names 1:1 so repointing a consumer is an
|
||||
* import-path-only diff. Inside the container, inject TagsService instead.
|
||||
* container (currently only the plugin RPC host; the MCP tools moved to the
|
||||
* DI-discovered tags.mcp.ts). Exports the legacy services/tagService function
|
||||
* names 1:1 so repointing a consumer is an import-path-only diff. Inside the
|
||||
* container, inject TagsService instead.
|
||||
*
|
||||
* Module-level construction is safe: `db` is the reinitialize-proof Proxy onto
|
||||
* the shared better-sqlite3 singleton (same pattern as
|
||||
|
||||
@@ -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 { TagsController } from './tags.controller';
|
||||
import { TagsMcp } from './tags.mcp';
|
||||
import { TagsService } from './tags.service';
|
||||
|
||||
/** Tags domain (L5 leaf module). Registered in AppModule. */
|
||||
@Module({
|
||||
controllers: [TagsController],
|
||||
providers: [TagsService],
|
||||
providers: [TagsService, TagsMcp],
|
||||
})
|
||||
export class TagsModule {}
|
||||
|
||||
@@ -15,6 +15,8 @@ import { Client } from '@modelcontextprotocol/sdk/client/index';
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory';
|
||||
import { registerResources } from '../../src/mcp/resources';
|
||||
import { registerTools } from '../../src/mcp/tools';
|
||||
import { setMcpRegistry } from '../../src/mcp/registry-handoff';
|
||||
import { createMcpTestRegistry } from './mcp-test-controllers';
|
||||
|
||||
export interface McpHarness {
|
||||
client: Client;
|
||||
@@ -40,7 +42,14 @@ export async function createMcpHarness(options: McpHarnessOptions): Promise<McpH
|
||||
const server = new McpServer({ name: 'trek-test', version: '1.0.0' });
|
||||
|
||||
if (withResources) registerResources(server, userId);
|
||||
if (withTools) registerTools(server, userId, scopes ?? null, isStaticToken);
|
||||
if (withTools) {
|
||||
// In production bootstrap.ts hands the Nest-discovered registry to
|
||||
// registerTools after app.init(); the harness has no Nest app, so it
|
||||
// builds the same registry by hand (see mcp-test-controllers.ts).
|
||||
// registerTools' own ctx construction stays exercised.
|
||||
setMcpRegistry(createMcpTestRegistry());
|
||||
registerTools(server, userId, scopes ?? null, isStaticToken);
|
||||
}
|
||||
|
||||
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { createTestRegistry, type McpRegistry } from '@trek/nest-mcp';
|
||||
import { db } from '../../src/db/database';
|
||||
import { trekMcpAccessPolicy } from '../../src/mcp/nest-mcp-policy';
|
||||
import { DatabaseService } from '../../src/nest/database/database.service';
|
||||
import { TagsMcp } from '../../src/nest/tags/tags.mcp';
|
||||
import { TagsService } from '../../src/nest/tags/tags.service';
|
||||
|
||||
/**
|
||||
* Hand-wired counterpart of the boot-time discovery in McpRegistryService,
|
||||
* for the no-Nest MCP harness. One line per migrated domain — add the new
|
||||
* @McpController instance here when a domain moves off the legacy registrar
|
||||
* fan-out. Constructing against the `db` Proxy keeps per-file vi.mock's of
|
||||
* src/db/database flowing through (same pattern as tags.bridge.ts).
|
||||
*/
|
||||
export function createMcpTestRegistry(): McpRegistry {
|
||||
const dbService = new DatabaseService(db);
|
||||
return createTestRegistry(
|
||||
[
|
||||
new TagsMcp(new TagsService(dbService)),
|
||||
],
|
||||
{ accessPolicy: trekMcpAccessPolicy },
|
||||
);
|
||||
}
|
||||
@@ -178,6 +178,21 @@ describe('Tool: update_tag', () => {
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Demo Tag', '#aaaaaa');
|
||||
const tagId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'update_tag',
|
||||
arguments: { tagId, name: 'Blocked' },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT name FROM tags WHERE id = ?').get(tagId)).toEqual({ name: 'Demo Tag' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -199,6 +214,76 @@ describe('Tool: delete_tag', () => {
|
||||
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(tagId)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('returns isError for non-existent tagId', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_tag',
|
||||
arguments: { tagId: 99999 },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks demo user', async () => {
|
||||
process.env.DEMO_MODE = 'true';
|
||||
const { user } = createUser(testDb, { email: 'demo@nomad.app' });
|
||||
const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Demo Tag', '#aaaaaa');
|
||||
const tagId = r.lastInsertRowid as number;
|
||||
await withHarness(user.id, async (h) => {
|
||||
const result = await h.client.callTool({
|
||||
name: 'delete_tag',
|
||||
arguments: { tagId },
|
||||
});
|
||||
expect(result.isError).toBe(true);
|
||||
expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(tagId)).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// tags scope gating (places:read / places:write, registration-time)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('Tags tools: scope gating', () => {
|
||||
const TAG_TOOLS = ['list_tags', 'create_tag', 'update_tag', 'delete_tag'];
|
||||
|
||||
async function listToolNames(userId: number, scopes: string[] | null): Promise<string[]> {
|
||||
const h = await createMcpHarness({ userId, withResources: false, scopes });
|
||||
try {
|
||||
return (await h.client.listTools()).tools.map((t) => t.name);
|
||||
} finally {
|
||||
await h.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
it('registers all four tag tools with null scopes (full access)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, null);
|
||||
for (const tool of TAG_TOOLS) expect(names).toContain(tool);
|
||||
});
|
||||
|
||||
it('registers only list_tags with places:read', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, ['places:read']);
|
||||
expect(names).toContain('list_tags');
|
||||
expect(names).not.toContain('create_tag');
|
||||
expect(names).not.toContain('update_tag');
|
||||
expect(names).not.toContain('delete_tag');
|
||||
});
|
||||
|
||||
it('registers all four tag tools with places:write (write implies read)', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, ['places:write']);
|
||||
for (const tool of TAG_TOOLS) expect(names).toContain(tool);
|
||||
});
|
||||
|
||||
it('registers no tag tools for an unrelated scope', async () => {
|
||||
const { user } = createUser(testDb);
|
||||
const names = await listToolNames(user.id, ['budget:read']);
|
||||
for (const tool of TAG_TOOLS) expect(names).not.toContain(tool);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -47,6 +47,13 @@ export default defineConfig({
|
||||
},
|
||||
resolve: {
|
||||
alias: {
|
||||
// Use the package SOURCE in tests: the built CJS dist would be
|
||||
// externalized by vite and its require('@modelcontextprotocol/sdk/...')
|
||||
// would hit the SDK's unresolvable exports map natively (production
|
||||
// resolves it via the tsconfig-paths/register runtime hook instead).
|
||||
// Inlining the source also applies the SWC decorator transform and the
|
||||
// SDK aliases below, and drops the build-freshness dependency.
|
||||
'@trek/nest-mcp': new URL('../nest-mcp/src/index.ts', import.meta.url).pathname,
|
||||
// MCP SDK's exports map uses extension-less wildcard targets that neither
|
||||
// Node nor Vite can resolve. Point directly at the CJS dist files.
|
||||
// Paths are relative to the monorepo root (packages are hoisted there).
|
||||
|
||||
Reference in New Issue
Block a user