diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index b81585768..0fbd7f0ba 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -1,6 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { registerJourneyTools } from './tools/journey'; -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 { @@ -59,7 +58,10 @@ export function registerTools(server: McpServer, userId: number, scopes: string[ // The todo tools moved to the DI-discovered src/nest/todo/todo.mcp.ts // (@McpController, attached via the nest-mcp registry below). - registerMcpPrompts(server, userId, isStaticToken); + // The prompts moved to the DI-discovered @McpController classes: + // packing-list to packing.mcp.ts, budget-overview to budget.mcp.ts and the + // static-token notice to auth.mcp.ts (its `if (isStaticToken)` became a + // `when` gate — the registry hands `when` the session context). // Decorator-registered domains (@trek/nest-mcp) — migrating off the legacy // registrar fan-out above, one domain at a time. Unset registry (direct diff --git a/server/src/mcp/tools/prompts.ts b/server/src/mcp/tools/prompts.ts deleted file mode 100644 index d5ad53cd1..000000000 --- a/server/src/mcp/tools/prompts.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; -import { z } from 'zod'; -import { canAccessTrip } from '../../db/database'; -import { getTripSummary } from '../../nest/trips/trips.bridge'; -import { listItems as listPackingItems } from '../../nest/packing/packing.bridge'; -import { isAddonEnabled } from '../../nest/addons/addons.bridge'; -import { ADDON_IDS } from '../../addons'; - -export function registerMcpPrompts(server: McpServer, _userId: number, isStaticToken = false): void { - if (isStaticToken) { - server.registerPrompt( - 'token_auth_notice', - { - title: 'Static Token Auth Notice', - description: 'Notification that this session uses a static API token which will be deprecated', - argsSchema: {}, - }, - async () => ({ - description: 'Static token deprecation notice', - messages: [{ - role: 'user' as const, - content: { - type: 'text' as const, - text: '⚠️ This MCP connection is authenticated with a static API token (trek_…). Static token authentication will be deprecated in a future version of TREK. Please inform the user that they should migrate to OAuth 2.1 by going to Settings → Integrations → MCP → OAuth Clients in TREK and registering an OAuth 2.1 application for their MCP client.', - }, - }], - }) - ); - } - const userId = _userId; - - // The trip-summary prompt moved to the DI-discovered - // src/nest/trips/trips.mcp.ts (@Prompt, attached via the nest-mcp registry - // in registerTools). - - if (isAddonEnabled(ADDON_IDS.PACKING)) server.registerPrompt( - 'packing-list', - { - title: 'Packing List', - description: 'Get a formatted packing checklist for a trip', - argsSchema: { - tripId: z.number().int().positive().describe('Trip ID'), - }, - }, - async ({ tripId }) => { - if (!canAccessTrip(tripId, userId)) { - return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] }; - } - // Hide other members' private items (#858) from the requesting user. - const items = listPackingItems(tripId, userId); - if (!items.length) { - return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] }; - } - const grouped = items.reduce((acc: Record, item: any) => { - const cat = item.category || 'General'; - if (!acc[cat]) acc[cat] = []; - acc[cat].push(item); - return acc; - }, {}); - const lines = Object.entries(grouped).map(([cat, items]) => - `## ${cat}\n${(items as any[]).map((i: any) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}` - ).join('\n\n'); - const { trip } = getTripSummary(tripId, userId) || {}; - return { - description: `Packing list for "${trip?.title || tripId}"`, - messages: [{ role: 'user', content: { type: 'text', text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }], - }; - } - ); - - if (isAddonEnabled(ADDON_IDS.BUDGET)) server.registerPrompt( - 'budget-overview', - { - title: 'Budget Overview', - description: 'Get a formatted budget summary for a trip', - argsSchema: { - tripId: z.number().int().positive().describe('Trip ID'), - }, - }, - async ({ tripId }) => { - if (!canAccessTrip(tripId, userId)) { - return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] }; - } - const summary = getTripSummary(tripId, userId); - if (!summary) { - return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] }; - } - const { trip, budget } = summary; - const currency = trip?.currency || 'EUR'; - const byCategory = (budget?.items || []).reduce((acc: Record, item: any) => { - const cat = item.category || 'Uncategorized'; - acc[cat] = (acc[cat] || 0) + (item.total_price || 0); - return acc; - }, {} as Record); - const total = Object.values(byCategory).reduce((s, v) => s + v, 0); - const lines = Object.entries(byCategory) - .sort(([, a], [, b]) => b - a) - .map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`) - .join('\n'); - const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length); - const perPerson = (total / memberCount).toFixed(2); - return { - description: `Budget overview for "${trip?.title || tripId}"`, - messages: [{ role: 'user', content: { type: 'text', text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }], - }; - } - ); -} diff --git a/server/src/nest/auth/auth.mcp.ts b/server/src/nest/auth/auth.mcp.ts new file mode 100644 index 000000000..26bf61221 --- /dev/null +++ b/server/src/nest/auth/auth.mcp.ts @@ -0,0 +1,36 @@ +import { McpController, Prompt, type McpContext } from '@trek/nest-mcp'; + +/** + * Auth-related MCP surface. One prompt today, moved 1:1 from the legacy + * registrar src/mcp/tools/prompts.ts. + * + * It lives in the auth domain rather than a domain of its own because it is a + * statement about how the session authenticated, not about any trip data — and + * it injects nothing, since the message is static. + * + * The registrar gated it with an `if (isStaticToken)` around the registration + * call. `when` receives the session context, so that becomes a declarative gate + * with the same effect: OAuth 2.1 sessions never see the prompt listed at all. + */ +@McpController() +export class AuthMcp { + @Prompt({ + name: 'token_auth_notice', + title: 'Static Token Auth Notice', + description: 'Notification that this session uses a static API token which will be deprecated', + argsSchema: {}, + when: (ctx: McpContext) => ctx.isStaticToken, + }) + async tokenAuthNotice() { + return { + description: 'Static token deprecation notice', + messages: [{ + role: 'user' as const, + content: { + type: 'text' as const, + text: '⚠️ This MCP connection is authenticated with a static API token (trek_…). Static token authentication will be deprecated in a future version of TREK. Please inform the user that they should migrate to OAuth 2.1 by going to Settings → Integrations → MCP → OAuth Clients in TREK and registering an OAuth 2.1 application for their MCP client.', + }, + }], + }; + } +} diff --git a/server/src/nest/auth/auth.module.ts b/server/src/nest/auth/auth.module.ts index 318fe4b4e..18a8ebbc1 100644 --- a/server/src/nest/auth/auth.module.ts +++ b/server/src/nest/auth/auth.module.ts @@ -5,6 +5,7 @@ import { AuthController } from './auth.controller'; import { PasskeyController } from './passkey.controller'; import { AuthService } from './auth.service'; import { PasskeyService } from './passkey.service'; +import { AuthMcp } from './auth.mcp'; import { UserCleanupService } from './user-cleanup.service'; import { WebauthnConfigService } from './webauthn-config.service'; import { AppConfigModule } from '../app-config/app-config.module'; @@ -33,7 +34,7 @@ import { TripMembershipModule } from '../trip-membership/trip-membership.module' @Module({ imports: [RateLimitModule, AuditModule, PermissionsModule, AtlasModule, TripMembershipModule, MailerModule, AppConfigModule], controllers: [AuthPublicController, AuthController, PasskeyController], - providers: [AuthService, PasskeyService, UserCleanupService, WebauthnConfigService], + providers: [AuthService, PasskeyService, UserCleanupService, WebauthnConfigService, AuthMcp], exports: [AuthService, PasskeyService, UserCleanupService], }) export class AuthModule {} diff --git a/server/src/nest/budget/budget.mcp.ts b/server/src/nest/budget/budget.mcp.ts index 073b6ca62..c6be8b37b 100644 --- a/server/src/nest/budget/budget.mcp.ts +++ b/server/src/nest/budget/budget.mcp.ts @@ -1,5 +1,5 @@ import { - McpController, Tool, ResourceTemplate, type McpContext, + McpController, Prompt, Tool, ResourceTemplate, type McpContext, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, @@ -9,7 +9,7 @@ import { AuthService } from '../auth/auth.service'; import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; -import { getTripOwner, listMembers } from '../trips/trips.bridge'; +import { getTripOwner, listMembers, getTripSummary } from '../trips/trips.bridge'; import { DatabaseService } from '../database/database.service'; import { BudgetService } from './budget.service'; import { ExchangeRatesService } from './exchange-rates.service'; @@ -466,4 +466,47 @@ export class BudgetMcp { }], }; } + + /** + * Moved 1:1 from the legacy registrar src/mcp/tools/prompts.ts. The + * registration-time `if (isAddonEnabled(BUDGET))` became the same `when` gate + * the tools above use, and the trip lookup keeps going through trips.bridge — + * injecting TripsService here would close a TripsModule/BudgetModule cycle. + */ + @Prompt({ + name: 'budget-overview', + title: 'Budget Overview', + description: 'Get a formatted budget summary for a trip', + argsSchema: { + tripId: z.number().int().positive().describe('Trip ID'), + }, + when: budgetAddonOn, + }) + async budgetOverviewPrompt({ tripId }: { tripId: number }, ctx: McpContext) { + if (!this.budget.verifyTripAccess(tripId, ctx.userId)) { + return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; + } + const summary = getTripSummary(tripId, ctx.userId); + if (!summary) { + return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found.' } }] }; + } + const { trip, budget } = summary; + const currency = trip?.currency || 'EUR'; + const byCategory = (budget?.items || []).reduce((acc: Record, item: { category?: string; total_price?: number }) => { + const cat = item.category || 'Uncategorized'; + acc[cat] = (acc[cat] || 0) + (item.total_price || 0); + return acc; + }, {} as Record); + const total = Object.values(byCategory).reduce((sum, v) => sum + v, 0); + const lines = Object.entries(byCategory) + .sort(([, a], [, b]) => b - a) + .map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`) + .join('\n'); + const memberCount = Math.max(1, [summary.members?.owner, ...(summary.members?.collaborators || [])].filter(Boolean).length); + const perPerson = (total / memberCount).toFixed(2); + return { + description: `Budget overview for "${trip?.title || tripId}"`, + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }], + }; + } } diff --git a/server/src/nest/packing/packing.mcp.ts b/server/src/nest/packing/packing.mcp.ts index 7f1fbeb71..120d41a83 100644 --- a/server/src/nest/packing/packing.mcp.ts +++ b/server/src/nest/packing/packing.mcp.ts @@ -1,5 +1,5 @@ import { - McpController, Tool, ResourceTemplate, type McpContext, + McpController, Prompt, Tool, ResourceTemplate, type McpContext, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, errorResult, ok, @@ -10,6 +10,10 @@ import { isAddonEnabled } from '../addons/addons.bridge'; import { ADDON_IDS } from '../../addons'; import { safeBroadcast, noAccess, hasTripPermission, permissionDenied, isAdminUser, adminRequired } from '../../mcp/tools/_shared'; import { PackingService } from './packing.service'; +// trips.bridge, not an injected TripsService: TripsModule imports PackingModule, +// so injecting it here would close a module cycle. Same documented in-container +// exception as atlas.mcp.ts, and the legacy registrar reached for it the same way. +import { getTripSummary } from '../trips/trips.bridge'; /** Legacy registrar gate: the whole packing surface rides the packing addon. */ const packingAddonOn = () => isAddonEnabled(ADDON_IDS.PACKING); @@ -463,4 +467,43 @@ export class PackingMcp { }], }; } + + /** + * Moved 1:1 from the legacy registrar src/mcp/tools/prompts.ts. The + * registration-time `if (isAddonEnabled(PACKING))` became the same `when` + * gate the tools above use. + */ + @Prompt({ + name: 'packing-list', + title: 'Packing List', + description: 'Get a formatted packing checklist for a trip', + argsSchema: { + tripId: z.number().int().positive().describe('Trip ID'), + }, + when: packingAddonOn, + }) + async packingListPrompt({ tripId }: { tripId: number }, ctx: McpContext) { + if (!this.packing.verifyTripAccess(tripId, ctx.userId)) { + return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'Trip not found or access denied.' } }] }; + } + // Hide other members' private items (#858) from the requesting user. + const items = this.packing.listItems(tripId, ctx.userId); + if (!items.length) { + return { messages: [{ role: 'user' as const, content: { type: 'text' as const, text: 'No packing items found for this trip.' } }] }; + } + const grouped = items.reduce((acc: Record, item: { category?: string }) => { + const cat = item.category || 'General'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(item); + return acc; + }, {}); + const lines = Object.entries(grouped).map(([cat, catItems]) => + `## ${cat}\n${(catItems as { checked?: unknown; name?: string }[]).map((i) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}` + ).join('\n\n'); + const { trip } = getTripSummary(tripId, ctx.userId) || {}; + return { + description: `Packing list for "${trip?.title || tripId}"`, + messages: [{ role: 'user' as const, content: { type: 'text' as const, text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }], + }; + } } diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index 49925e674..0c3a6418e 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -24,6 +24,7 @@ import { MapsMcp } from '../../src/nest/maps/maps.mcp'; import { WeatherMcp } from '../../src/nest/weather/weather.mcp'; import { WeatherService } from '../../src/nest/weather/weather.service'; import { AirportsMcp } from '../../src/nest/airports/airports.mcp'; +import { AuthMcp } from '../../src/nest/auth/auth.mcp'; import { MapsService } from '../../src/nest/maps/maps.service'; import { NotificationsMcp } from '../../src/nest/notifications/notifications.mcp'; import { NotificationsService } from '../../src/nest/notifications/notifications.service'; @@ -95,6 +96,7 @@ export function createMcpTestRegistry(): McpRegistry { // The weather and airport tools left the legacy mapsWeather registrar. new WeatherMcp(new WeatherService()), new AirportsMcp(), + new AuthMcp(), new TodoMcp(todoService, authService), new PackingMcp(packingService, authService), new BudgetMcp(budgetService, exchangeRatesService, dbService, authService), diff --git a/server/tests/unit/mcp/tools-prompts.test.ts b/server/tests/unit/mcp/tools-prompts.test.ts index 5a31a36a1..c1b7bb50b 100644 --- a/server/tests/unit/mcp/tools-prompts.test.ts +++ b/server/tests/unit/mcp/tools-prompts.test.ts @@ -53,16 +53,27 @@ const { mockGetTripSummary } = vi.hoisted(() => ({ // prompts.ts consumes getTripSummary via the trips bridge since the trip fold. vi.mock('../../../src/nest/trips/trips.bridge', () => ({ getTripSummary: mockGetTripSummary, + getTripOwner: vi.fn(), + listMembers: vi.fn(() => ({ members: [] })), })); import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; import { createUser, createTrip, addTripMember, createPackingItem, createBudgetItem } from '../../helpers/factories'; -import { registerMcpPrompts } from '../../../src/mcp/tools/prompts'; import { createTestRegistry } from '@trek/nest-mcp'; import { trekMcpAccessPolicy, trekMcpValidateAccess } from '../../../src/mcp/nest-mcp-policy'; import { TripsMcp } from '../../../src/nest/trips/trips.mcp'; +import { PackingMcp } from '../../../src/nest/packing/packing.mcp'; +import { PackingService } from '../../../src/nest/packing/packing.service'; +import { BudgetMcp } from '../../../src/nest/budget/budget.mcp'; +import { BudgetService } from '../../../src/nest/budget/budget.service'; +import { ExchangeRatesService } from '../../../src/nest/budget/exchange-rates.service'; +import { AuthMcp } from '../../../src/nest/auth/auth.mcp'; +import { DatabaseService } from '../../../src/nest/database/database.service'; +import { PermissionsService } from '../../../src/nest/permissions/permissions.service'; +import { RealtimeService } from '../../../src/nest/realtime/realtime.service'; +import type { AuthService } from '../../../src/nest/auth/auth.service'; import type { TripsService } from '../../../src/nest/trips/trips.service'; import type { TodoService } from '../../../src/nest/todo/todo.service'; import type { CollabService } from '../../../src/nest/collab/collab.service'; @@ -76,6 +87,22 @@ const tripsStub = { } as unknown as TripsService; const tripsMcp = new TripsMcp(tripsStub, { listItems: () => [] } as unknown as TodoService, { listPolls: () => [], countMessages: () => 0 } as unknown as CollabService); +// The three remaining prompts moved to their domains' @McpController classes: +// packing-list, budget-overview and the static-token notice. Built over the same +// in-memory DB so the cases below keep asserting real rows. +const promptDbs = () => new DatabaseService(testDb); +const authStub = { isDemoUser: () => false } as unknown as AuthService; +const packingMcp = new PackingMcp( + new PackingService(promptDbs(), new PermissionsService(promptDbs()), new RealtimeService()), + authStub, +); +const budgetMcp = new BudgetMcp( + new BudgetService(promptDbs(), new PermissionsService(promptDbs()), new ExchangeRatesService(), new RealtimeService()), + new ExchangeRatesService(), + promptDbs(), +); +const authMcp = new AuthMcp(); + beforeAll(() => { createTables(testDb); runMigrations(testDb); @@ -125,10 +152,9 @@ afterAll(() => { /** Build a fresh McpServer with prompts registered for the given userId. */ function buildServer(userId: number, opts: { isStaticToken?: boolean } = {}): McpServer { const server = new McpServer({ name: 'trek-test', version: '1.0.0' }); - registerMcpPrompts(server, userId, opts.isStaticToken ?? false); - // Attach the DI-discovered trip surface (carries the trip-summary @Prompt) - // the same way registerTools does in production. - createTestRegistry([tripsMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }) + // Every prompt is DI-discovered now; attach them the way registerTools does in + // production, including the isStaticToken flag the notice's `when` gate reads. + createTestRegistry([tripsMcp, packingMcp, budgetMcp, authMcp], { accessPolicy: trekMcpAccessPolicy, validateAccess: trekMcpValidateAccess }) .attach(server, { userId, scopes: null, isStaticToken: opts.isStaticToken ?? false }); return server; }