refactor(server): decorate the last three MCP prompts

The prompts registrar is gone. Each prompt moves to the domain that owns its
data instead of a shared file that had to import three bridges to serve them.

packing-list goes to PackingMcp and budget-overview to BudgetMcp, both reusing
the `when` addon gate their tools already carry — so a disabled addon keeps the
prompt out of the listing, exactly as the `if (isAddonEnabled(...))` around the
registration call did. Both keep taking the trip title through trips.bridge:
TripsModule imports PackingModule and BudgetModule, so injecting TripsService
would close a cycle. Same documented in-container exception as atlas.mcp.ts, and
the registrar reached for it the same way.

token_auth_notice goes to a new AuthMcp — it is a statement about how the
session authenticated, not about trip data, and it injects nothing because the
message is static. Its `if (isStaticToken)` guard becomes `when: (ctx) =>
ctx.isStaticToken`; the registry hands `when` the session context, so an
OAuth 2.1 session never sees the prompt listed at all, which is what the
registrar achieved by skipping registration.

src/mcp/tools/ is down to _shared.ts and journey.ts. journey stays until
services/journeyService folds, which waits on memories.
This commit is contained in:
Maurice
2026-08-08 19:08:24 +02:00
committed by Maurice
parent cacab0795f
commit 3efedd5e48
8 changed files with 164 additions and 119 deletions
+4 -2
View File
@@ -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
-108
View File
@@ -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<string, any[]>, 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<string, number>, item: any) => {
const cat = item.category || 'Uncategorized';
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
return acc;
}, {} as Record<string, number>);
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.'}` } }],
};
}
);
}
+36
View File
@@ -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.',
},
}],
};
}
}
+2 -1
View File
@@ -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 {}
+45 -2
View File
@@ -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<string, number>, item: { category?: string; total_price?: number }) => {
const cat = item.category || 'Uncategorized';
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
return acc;
}, {} as Record<string, number>);
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.'}` } }],
};
}
}
+44 -1
View File
@@ -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<string, unknown[]>, 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_` } }],
};
}
}
@@ -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),
+31 -5
View File
@@ -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;
}