feat(mcp): Create nest wrapper for MCP for proper DI (#1708)

This commit is contained in:
jubnl
2026-07-26 11:42:47 +02:00
committed by GitHub
parent 828c1db489
commit 8f3ee53841
45 changed files with 2072 additions and 152 deletions
+5
View File
@@ -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;
}
+24
View File
@@ -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);
+20
View File
@@ -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;
}
+8 -3
View File
@@ -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 });
}
+19 -52
View File
@@ -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');
}
-84
View File
@@ -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 });
}
);
}
+3 -1
View File
@@ -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,
+4 -3
View File
@@ -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
+83
View File
@@ -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 });
}
}
+2 -1
View File
@@ -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 {}