mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
feat(mcp): Create nest wrapper for MCP for proper DI (#1708)
This commit is contained in:
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user