From cacab0795f4cb91ac1ef09dc07b06bddc5094e57 Mon Sep 17 00:00:00 2001 From: Maurice Date: Sat, 8 Aug 2026 14:28:45 +0200 Subject: [PATCH] refactor(server): decorate the weather, airport and transport MCP tools Three of the four remaining hand-written registrars are gone. The tools keep their names, descriptions, input schemas, annotations and result shapes; the registration-time scope gates (`if (canWeather)`, `if (canGeo)`, `if (!canWrite(scopes, 'reservations')) return`) become declarative access markers, resolved by trekMcpAccessPolicy like every other decorated domain. get_weather / get_detailed_weather move into a new WeatherMcp over the injected WeatherService, including the catch that answers with the upstream message and isError rather than throwing - a client sees "Weather service not available." instead of a transport failure when the provider is down. search_airports / get_airport move into a new AirportsMcp with no injected service at all: the airport table is a static dataset. AirportsService owns the flight-endpoint backfill against the database, which neither tool touches. create/update/delete_transport move into ReservationsMcp rather than a domain of their own, because a transport IS a reservation - same table, same service, same reservation_edit permission - and that class already injects everything they needed from the four bridges the registrar was importing. That was budget.bridge's last outside-container consumer, so linkBudgetItemToReservation is pruned with it; the file is down to one export and dies as soon as BudgetMcp stops needing AuthService. journey.ts stays: it still reaches into services/journeyService, which cannot fold until memories does. --- server/src/mcp/tools.ts | 11 +- server/src/mcp/tools/mapsWeather.ts | 98 -------- server/src/mcp/tools/transports.ts | 229 ----------------- server/src/nest/airports/airports.mcp.ts | 46 ++++ server/src/nest/airports/airports.module.ts | 3 +- server/src/nest/budget/budget.bridge.ts | 18 +- .../src/nest/reservations/reservations.mcp.ts | 236 ++++++++++++++++++ server/src/nest/weather/weather.mcp.ts | 61 +++++ server/src/nest/weather/weather.module.ts | 3 +- server/tests/helpers/mcp-test-controllers.ts | 6 + .../tests/unit/nest/budget.service.db.test.ts | 13 +- 11 files changed, 372 insertions(+), 352 deletions(-) delete mode 100644 server/src/mcp/tools/mapsWeather.ts delete mode 100644 server/src/mcp/tools/transports.ts create mode 100644 server/src/nest/airports/airports.mcp.ts create mode 100644 server/src/nest/weather/weather.mcp.ts diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index d2b635568..b81585768 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -1,7 +1,5 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { registerJourneyTools } from './tools/journey'; -import { registerMapsWeatherTools } from './tools/mapsWeather'; -import { registerTransportTools } from './tools/transports'; import { registerMcpPrompts } from './tools/prompts'; import { getMcpRegistry } from './registry-handoff'; @@ -32,7 +30,9 @@ export function registerTools(server: McpServer, userId: number, scopes: string[ // The assignment tools moved to the DI-discovered src/nest/assignments/ // assignments.mcp.ts (@McpController, attached via the nest-mcp registry below). - registerMapsWeatherTools(server, userId, scopes); + // The weather tools moved to the DI-discovered src/nest/weather/weather.mcp.ts + // and the airport tools to src/nest/airports/airports.mcp.ts (@McpController, + // attached via the nest-mcp registry below). // The notification tools moved to the DI-discovered src/nest/notifications/ // notifications.mcp.ts (@McpController, attached via the nest-mcp registry below). @@ -43,7 +43,10 @@ export function registerTools(server: McpServer, userId: number, scopes: string[ // The collab tools moved to the DI-discovered src/nest/collab/collab.mcp.ts // (@McpController, attached via the nest-mcp registry below). - registerTransportTools(server, userId, scopes); + // The transport tools moved to the DI-discovered + // src/nest/reservations/reservations.mcp.ts — a transport is a reservation, + // same table and same service (@McpController, attached via the nest-mcp + // registry below). // The transit tools moved to the DI-discovered src/nest/transit/transit.mcp.ts // (@McpController, attached via the nest-mcp registry below). diff --git a/server/src/mcp/tools/mapsWeather.ts b/server/src/mcp/tools/mapsWeather.ts deleted file mode 100644 index 72a5662ed..000000000 --- a/server/src/mcp/tools/mapsWeather.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; -import { z } from 'zod'; -import { findByIata, searchAirports } from '../../nest/airports/airports.data'; -import { getWeather, getDetailedWeather } from '../../nest/weather/weather.impl'; -import { - TOOL_ANNOTATIONS_READONLY, - ok, -} from './_shared'; -import { canRead } from '../scopes'; - -export function registerMapsWeatherTools(server: McpServer, _userId: number, scopes: string[] | null): void { - const canGeo = canRead(scopes, 'geo'); - const canWeather = canRead(scopes, 'weather'); - - // The geo tools (get_place_details, reverse_geocode, resolve_maps_url) moved to - // the DI-discovered src/nest/maps/maps.mcp.ts (@McpController, attached via the - // nest-mcp registry). - - // --- WEATHER --- - - if (canWeather) server.registerTool( - 'get_weather', - { - description: 'Get weather forecast for a location and date.', - inputSchema: { - lat: z.number(), - lng: z.number(), - date: z.string().describe('ISO date YYYY-MM-DD'), - lang: z.string().optional().default('en'), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ lat, lng, date, lang }) => { - try { - const weather = await getWeather(String(lat), String(lng), date, lang ?? 'en'); - return ok({ weather }); - } catch (err: any) { - return { content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }], isError: true }; - } - } - ); - - if (canWeather) server.registerTool( - 'get_detailed_weather', - { - description: 'Get hourly/detailed weather forecast for a location and date.', - inputSchema: { - lat: z.number(), - lng: z.number(), - date: z.string().describe('ISO date YYYY-MM-DD'), - lang: z.string().optional().default('en'), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ lat, lng, date, lang }) => { - try { - const weather = await getDetailedWeather(String(lat), String(lng), date, lang ?? 'en'); - return ok({ weather }); - } catch (err: any) { - return { content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }], isError: true }; - } - } - ); - - // --- AIRPORTS --- - - if (canGeo) server.registerTool( - 'search_airports', - { - description: 'Search for airports by name, city, or IATA code. Returns matching airports with IATA code, name, city, country, coordinates, and timezone. Use before create_transport (flight) to get the correct IATA code and timezone for endpoints.', - inputSchema: { - query: z.string().min(1).max(200).describe('Airport name, city, or IATA code (e.g. "zurich", "ZRH", "charles de gaulle")'), - limit: z.number().int().min(1).max(50).optional().default(10), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ query, limit }) => { - const airports = searchAirports(query, limit ?? 10); - return ok({ airports }); - } - ); - - if (canGeo) server.registerTool( - 'get_airport', - { - description: 'Get a single airport by its IATA code. Returns name, city, country, coordinates, and timezone.', - inputSchema: { - iata: z.string().length(3).toUpperCase().describe('IATA airport code (e.g. "ZRH", "AMS", "CDG")'), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ iata }) => { - const airport = findByIata(iata); - if (!airport) return { content: [{ type: 'text' as const, text: 'Airport not found.' }], isError: true }; - return ok({ airport }); - } - ); -} diff --git a/server/src/mcp/tools/transports.ts b/server/src/mcp/tools/transports.ts deleted file mode 100644 index b60b076e9..000000000 --- a/server/src/mcp/tools/transports.ts +++ /dev/null @@ -1,229 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; -import { z } from 'zod'; -import { canAccessTrip } from '../../db/database'; -import { isDemoUser } from '../../nest/auth/auth.bridge'; -import { - createReservation, deleteReservation, getReservation, updateReservation, - type EndpointInput, -} from '../../nest/reservations/reservations.bridge'; -import { linkBudgetItemToReservation } from '../../nest/budget/budget.bridge'; -import { getDay } from '../../nest/days/days.bridge'; -import { findByIata } from '../../nest/airports/airports.data'; -import { - safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, - TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok, hasTripPermission, permissionDenied, -} from './_shared'; -import { canWrite } from '../scopes'; - -const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const; - -const endpointObjectSchema = z.object({ - role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'), - sequence: z.number().int().min(0).describe('Order within the route (0-based)'), - name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'), - code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'), - lat: z.number().optional().describe('Latitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'), - lng: z.number().optional().describe('Longitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'), - timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'), - local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'), - local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'), -}); -const endpointSchema = z.array(endpointObjectSchema).optional(); - -type Endpoint = z.infer; - -/** - * Endpoint coordinates are stored NOT NULL. Callers may supply a flight endpoint - * with only an IATA `code` (the tool description encourages this), so fill missing - * lat/lng/timezone from the airport database. Returns an error string for the first - * endpoint that can't be resolved rather than letting the NOT NULL bind throw. - * - * Normalizes to the service's EndpointInput shape (nullable fields coerced from the - * schema's optionals), so lat/lng are guaranteed present before the insert. - */ -function resolveEndpointCoords(endpoints: Endpoint[] | undefined): { endpoints: EndpointInput[] } | { error: string } { - if (!endpoints) return { endpoints: [] }; - const out: EndpointInput[] = []; - for (const e of endpoints) { - const base = { - role: e.role, - sequence: e.sequence, - name: e.name, - code: e.code ?? null, - timezone: e.timezone ?? null, - local_time: e.local_time ?? null, - local_date: e.local_date ?? null, - }; - if (e.lat != null && e.lng != null) { out.push({ ...base, lat: e.lat, lng: e.lng }); continue; } - if (e.code) { - const airport = findByIata(e.code); - if (airport) { - out.push({ ...base, lat: airport.lat, lng: airport.lng, timezone: e.timezone ?? airport.tz }); - continue; - } - return { error: `Could not resolve airport code "${e.code}". Use search_airports to find a valid IATA code, or supply lat/lng directly.` }; - } - return { error: `Endpoint "${e.name}" is missing coordinates. For flights set "code" to the IATA airport code; for other transport types supply lat/lng.` }; - } - return { endpoints: out }; -} - -export function registerTransportTools(server: McpServer, userId: number, scopes: string[] | null): void { - if (!canWrite(scopes, 'reservations')) return; - - server.registerTool( - 'create_transport', - { - description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.', - inputSchema: { - tripId: z.number().int().positive(), - type: z.enum(['flight', 'train', 'car', 'cruise']), - title: z.string().min(1).max(200), - status: z.enum(['pending', 'confirmed', 'cancelled']).optional().default('pending'), - start_day_id: z.number().int().positive().optional().describe('Departure day'), - end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'), - reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'), - reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'), - confirmation_number: z.string().max(100).optional(), - notes: z.string().max(1000).optional(), - metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), - endpoints: endpointSchema, - needs_review: z.boolean().optional(), - price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'), - budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied(); - - if (start_day_id && !getDay(start_day_id, tripId)) - return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true }; - if (end_day_id && !getDay(end_day_id, tripId)) - return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true }; - - const resolved = resolveEndpointCoords(endpoints); - if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true }; - - const meta: Record = { ...(metadata ?? {}) }; - if (price != null) meta.price = String(price); - - const { reservation } = createReservation(tripId, { - title, - type, - reservation_time, - reservation_end_time, - location: undefined, - confirmation_number, - notes, - day_id: start_day_id, - end_day_id: end_day_id ?? start_day_id, - status: status ?? 'pending', - metadata: Object.keys(meta).length > 0 ? meta : undefined, - endpoints: resolved.endpoints, - needs_review, - }); - - if (price != null && price > 0) { - const item = linkBudgetItemToReservation(tripId, reservation.id, { - name: title, - category: budget_category || type, - total_price: price, - }); - safeBroadcast(tripId, 'budget:created', { item }); - } - - safeBroadcast(tripId, 'reservation:created', { reservation }); - return ok({ reservation }); - } - ); - - server.registerTool( - 'update_transport', - { - description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates). Use status "confirmed" to confirm.', - inputSchema: { - tripId: z.number().int().positive(), - reservationId: z.number().int().positive(), - type: z.enum(['flight', 'train', 'car', 'cruise']).optional(), - title: z.string().min(1).max(200).optional(), - status: z.enum(['pending', 'confirmed', 'cancelled']).optional(), - start_day_id: z.number().int().positive().optional().describe('Departure day'), - end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'), - reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'), - reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'), - confirmation_number: z.string().max(100).optional(), - notes: z.string().max(1000).optional(), - metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), - endpoints: endpointSchema, - needs_review: z.boolean().optional(), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied(); - - const existing = getReservation(reservationId, tripId); - if (!existing) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true }; - - const resolvedType = type ?? existing.type; - if (!(TRANSPORT_TYPES as readonly string[]).includes(resolvedType)) - return { content: [{ type: 'text' as const, text: 'Reservation is not a transport type. Use update_reservation instead.' }], isError: true }; - - if (start_day_id && !getDay(start_day_id, tripId)) - return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true }; - if (end_day_id && !getDay(end_day_id, tripId)) - return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true }; - - // Only resolve when endpoints are explicitly provided; undefined leaves them untouched. - let resolvedEndpoints: EndpointInput[] | undefined; - if (endpoints !== undefined) { - const resolved = resolveEndpointCoords(endpoints); - if ('error' in resolved) return { content: [{ type: 'text' as const, text: resolved.error }], isError: true }; - resolvedEndpoints = resolved.endpoints; - } - - const { reservation } = updateReservation(reservationId, tripId, { - title, - type, - reservation_time, - reservation_end_time, - confirmation_number, - notes, - day_id: start_day_id, - end_day_id, - status, - metadata, - endpoints: resolvedEndpoints, - needs_review, - }, existing); - safeBroadcast(tripId, 'reservation:updated', { reservation }); - return ok({ reservation }); - } - ); - - server.registerTool( - 'delete_transport', - { - description: 'Delete a transport booking from a trip.', - inputSchema: { - tripId: z.number().int().positive(), - reservationId: z.number().int().positive(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - async ({ tripId, reservationId }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - if (!hasTripPermission('reservation_edit', tripId, userId)) return permissionDenied(); - const { deleted } = deleteReservation(reservationId, tripId); - if (!deleted) return { content: [{ type: 'text' as const, text: 'Transport not found.' }], isError: true }; - safeBroadcast(tripId, 'reservation:deleted', { reservationId }); - return ok({ success: true }); - } - ); -} diff --git a/server/src/nest/airports/airports.mcp.ts b/server/src/nest/airports/airports.mcp.ts new file mode 100644 index 000000000..0b25fe69e --- /dev/null +++ b/server/src/nest/airports/airports.mcp.ts @@ -0,0 +1,46 @@ +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '@trek/nest-mcp'; +import { z } from 'zod'; +import { findByIata, searchAirports } from './airports.data'; + +/** + * Airport lookup MCP tools, moved 1:1 from the legacy registrar in + * src/mcp/tools/mapsWeather.ts. Same names, descriptions, input schemas and + * result shapes; the registration-time `canRead(scopes, 'geo')` gate became the + * declarative `access` marker, matching maps.mcp.ts. + * + * No injected service: the airport table is a static dataset loaded once by + * airports.data.ts. AirportsService owns the flight-endpoint backfill against + * the database, which these two tools never touch. + */ +@McpController() +export class AirportsMcp { + @Tool({ + name: 'search_airports', + description: 'Search for airports by name, city, or IATA code. Returns matching airports with IATA code, name, city, country, coordinates, and timezone. Use before create_transport (flight) to get the correct IATA code and timezone for endpoints.', + inputSchema: { + query: z.string().min(1).max(200).describe('Airport name, city, or IATA code (e.g. "zurich", "ZRH", "charles de gaulle")'), + limit: z.number().int().min(1).max(50).optional().default(10), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'geo', mode: 'read' }, + }) + searchAirports({ query, limit }: { query: string; limit?: number }, _ctx: McpContext) { + const airports = searchAirports(query, limit ?? 10); + return ok({ airports }); + } + + @Tool({ + name: 'get_airport', + description: 'Get a single airport by its IATA code. Returns name, city, country, coordinates, and timezone.', + inputSchema: { + iata: z.string().length(3).toUpperCase().describe('IATA airport code (e.g. "ZRH", "AMS", "CDG")'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'geo', mode: 'read' }, + }) + getAirport({ iata }: { iata: string }, _ctx: McpContext) { + const airport = findByIata(iata); + if (!airport) return { content: [{ type: 'text' as const, text: 'Airport not found.' }], isError: true }; + return ok({ airport }); + } +} diff --git a/server/src/nest/airports/airports.module.ts b/server/src/nest/airports/airports.module.ts index d95ed185b..91e8175ba 100644 --- a/server/src/nest/airports/airports.module.ts +++ b/server/src/nest/airports/airports.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { AirportsController } from './airports.controller'; import { AirportsService } from './airports.service'; import { DatabaseModule } from '../database/database.module'; +import { AirportsMcp } from './airports.mcp'; /** Airports domain (L2 leaf module). Registered in AppModule. * DatabaseModule is imported explicitly: the flight-endpoint backfill needs the @@ -10,6 +11,6 @@ import { DatabaseModule } from '../database/database.module'; @Module({ imports: [DatabaseModule], controllers: [AirportsController], - providers: [AirportsService], + providers: [AirportsService, AirportsMcp], }) export class AirportsModule {} diff --git a/server/src/nest/budget/budget.bridge.ts b/server/src/nest/budget/budget.bridge.ts index 67368a609..d603050b4 100644 --- a/server/src/nest/budget/budget.bridge.ts +++ b/server/src/nest/budget/budget.bridge.ts @@ -6,8 +6,7 @@ import { PermissionsService } from '../permissions/permissions.service'; import { ExchangeRatesService } from './exchange-rates.service'; /** - * Non-Nest entry point for the budget domain — for the legacy create_transport - * registrar in src/mcp/tools/transports.ts, plus one in-container exception: + * One in-container exception, and nothing else left: * UserCleanupService (nest/auth) takes removeUserFromBudgetItems from here * because BudgetModule imports AuthModule for BudgetMcp's demo guard, so * AuthModule cannot import BudgetModule back — see the note there. (The budget MCP @@ -16,9 +15,10 @@ import { ExchangeRatesService } from './exchange-rates.service'; * domain folded into the DI-native TripsService, which injects BudgetService — * its bridge exports were pruned with it). Exports only the legacy * services/budgetService names still consumed outside the container, 1:1, so - * repointing a consumer is an import-path-only diff. Inside the container, - * inject BudgetService instead. Delete this file when the transports registrar - * migrates and BudgetMcp stops needing AuthService. + * Inside the container, inject BudgetService instead. The create_transport + * registrar was the last outside-container consumer; it moved into + * reservations.mcp.ts, so this file dies the moment BudgetMcp stops needing + * AuthService. * * Module-level construction is safe: `db` is the reinitialize-proof Proxy onto * the shared better-sqlite3 singleton, and ExchangeRatesService keeps its FX @@ -30,11 +30,3 @@ const budget = new BudgetService(new DatabaseService(db), new PermissionsService export function removeUserFromBudgetItems(userId: number): void { budget.removeUserFromBudgetItems(userId); } - -export function linkBudgetItemToReservation( - tripId: string | number, - reservationId: number, - data: Parameters[2], -) { - return budget.linkBudgetItemToReservation(tripId, reservationId, data); -} diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 9ba9adec3..0b1ec8462 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -10,6 +10,61 @@ import { placeExists, getAssignmentForTrip } from '../assignments/assignments.br import { safeBroadcast, noAccess, hasTripPermission, permissionDenied } from '../../mcp/tools/_shared'; import { ReservationsService } from './reservations.service'; import { DaysService } from '../days/days.service'; +import { findByIata } from '../airports/airports.data'; +import type { EndpointInput } from './reservations.service'; + +const TRANSPORT_TYPES = ['flight', 'train', 'car', 'cruise'] as const; + +const endpointObjectSchema = z.object({ + role: z.enum(['from', 'to', 'stop']).describe('Endpoint role: "from" (origin), "to" (destination), or "stop" (intermediate)'), + sequence: z.number().int().min(0).describe('Order within the route (0-based)'), + name: z.string().min(1).describe('Location name (e.g. "Paris Gare de Lyon", "ZRH Terminal 2")'), + code: z.string().optional().describe('IATA airport code for flights (e.g. "ZRH"). Leave empty for other transport types.'), + lat: z.number().optional().describe('Latitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'), + lng: z.number().optional().describe('Longitude. For flights, leave empty and set code instead — coordinates are filled from the airport.'), + timezone: z.string().optional().describe('IANA timezone (e.g. "Europe/Zurich"). Use airport tz for flights.'), + local_time: z.string().optional().describe('Local departure/arrival time at this endpoint, e.g. "14:35"'), + local_date: z.string().optional().describe('Local date at this endpoint, YYYY-MM-DD'), +}); +const endpointSchema = z.array(endpointObjectSchema).optional(); + +type TransportEndpoint = z.infer; + +/** + * Endpoint coordinates are stored NOT NULL. Callers may supply a flight endpoint + * with only an IATA `code` (the tool description encourages this), so fill missing + * lat/lng/timezone from the airport database. Returns an error string for the first + * endpoint that can't be resolved rather than letting the NOT NULL bind throw. + * + * Normalizes to the service's EndpointInput shape (nullable fields coerced from the + * schema's optionals), so lat/lng are guaranteed present before the insert. + */ +function resolveEndpointCoords(endpoints: TransportEndpoint[] | undefined): { endpoints: EndpointInput[] } | { error: string } { + if (!endpoints) return { endpoints: [] }; + const out: EndpointInput[] = []; + for (const e of endpoints) { + const base = { + role: e.role, + sequence: e.sequence, + name: e.name, + code: e.code ?? null, + timezone: e.timezone ?? null, + local_time: e.local_time ?? null, + local_date: e.local_date ?? null, + }; + if (e.lat != null && e.lng != null) { out.push({ ...base, lat: e.lat, lng: e.lng }); continue; } + if (e.code) { + const airport = findByIata(e.code); + if (airport) { + out.push({ ...base, lat: airport.lat, lng: airport.lng, timezone: e.timezone ?? airport.tz }); + continue; + } + return { error: `Could not resolve airport code "${e.code}". Use search_airports to find a valid IATA code, or supply lat/lng directly.` }; + } + return { error: `Endpoint "${e.name}" is missing coordinates. For flights set "code" to the IATA airport code; for other transport types supply lat/lng.` }; + } + return { endpoints: out }; +} function parseId(value: string | string[]): number | null { const n = Number(Array.isArray(value) ? value[0] : value); @@ -287,4 +342,185 @@ export class ReservationsMcp { }], }; } + + // ------------------------------------------------------------------------- + // Transports + // + // Ported 1:1 from the legacy registrar src/mcp/tools/transports.ts. They live + // here rather than in a domain of their own because a transport IS a + // reservation — same table, same service, same 'reservation_edit' permission + // — and this class already injects everything they need. The registration-time + // `if (!canWrite(scopes, 'reservations')) return;` that guarded the whole + // registrar became the per-tool access marker. + // ------------------------------------------------------------------------- + + @Tool({ + name: 'create_transport', + description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.', + inputSchema: { + tripId: z.number().int().positive(), + type: z.enum(['flight', 'train', 'car', 'cruise']), + title: z.string().min(1).max(200), + status: z.enum(['pending', 'confirmed', 'cancelled']).optional().default('pending'), + start_day_id: z.number().int().positive().optional().describe('Departure day'), + end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'), + reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'), + reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'), + confirmation_number: z.string().max(100).optional(), + notes: z.string().max(1000).optional(), + metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), + endpoints: endpointSchema, + needs_review: z.boolean().optional(), + price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'), + budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'reservations', mode: 'write' }, + }) + async createTransport( + { tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }: { + tripId: number; type: 'flight' | 'train' | 'car' | 'cruise'; title: string; + status?: 'pending' | 'confirmed' | 'cancelled'; start_day_id?: number; end_day_id?: number; + reservation_time?: string; reservation_end_time?: string; confirmation_number?: string; notes?: string; + metadata?: Record; endpoints?: TransportEndpoint[]; needs_review?: boolean; + price?: number; budget_category?: string; + }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + + if (start_day_id && !this.days.getDay(start_day_id, tripId)) + return errorResult('start_day_id does not belong to this trip.'); + if (end_day_id && !this.days.getDay(end_day_id, tripId)) + return errorResult('end_day_id does not belong to this trip.'); + + const resolved = resolveEndpointCoords(endpoints); + if ('error' in resolved) return errorResult(resolved.error); + + const meta: Record = { ...(metadata ?? {}) }; + if (price != null) meta.price = String(price); + + const { reservation } = this.reservations.create(tripId, { + title, + type, + reservation_time, + reservation_end_time, + location: undefined, + confirmation_number, + notes, + day_id: start_day_id, + end_day_id: end_day_id ?? start_day_id, + status: status ?? 'pending', + metadata: Object.keys(meta).length > 0 ? meta : undefined, + endpoints: resolved.endpoints, + needs_review, + }); + + if (price != null && price > 0) { + const item = this.budget.linkBudgetItemToReservation(tripId, reservation.id, { + name: title, + category: budget_category || type, + total_price: price, + }); + safeBroadcast(tripId, 'budget:created', { item }); + } + + safeBroadcast(tripId, 'reservation:created', { reservation }); + return ok({ reservation }); + } + + @Tool({ + name: 'update_transport', + description: 'Update an existing transport booking. Pass endpoints[] to replace the full list of stops (origin, destination, intermediates). Use status "confirmed" to confirm.', + inputSchema: { + tripId: z.number().int().positive(), + reservationId: z.number().int().positive(), + type: z.enum(['flight', 'train', 'car', 'cruise']).optional(), + title: z.string().min(1).max(200).optional(), + status: z.enum(['pending', 'confirmed', 'cancelled']).optional(), + start_day_id: z.number().int().positive().optional().describe('Departure day'), + end_day_id: z.number().int().positive().optional().describe('Arrival day (if different from departure)'), + reservation_time: z.string().optional().describe('ISO 8601 datetime or time string for departure'), + reservation_end_time: z.string().optional().describe('ISO 8601 datetime or time string for arrival'), + confirmation_number: z.string().max(100).optional(), + notes: z.string().max(1000).optional(), + metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), + endpoints: endpointSchema, + needs_review: z.boolean().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'reservations', mode: 'write' }, + }) + async updateTransport( + { tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }: { + tripId: number; reservationId: number; type?: 'flight' | 'train' | 'car' | 'cruise'; title?: string; + status?: 'pending' | 'confirmed' | 'cancelled'; start_day_id?: number; end_day_id?: number; + reservation_time?: string; reservation_end_time?: string; confirmation_number?: string; notes?: string; + metadata?: Record; endpoints?: TransportEndpoint[]; needs_review?: boolean; + }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + + const existing = this.reservations.getReservation(reservationId, tripId); + if (!existing) return errorResult('Transport not found.'); + + const resolvedType = type ?? existing.type; + if (!(TRANSPORT_TYPES as readonly string[]).includes(resolvedType)) + return errorResult('Reservation is not a transport type. Use update_reservation instead.'); + + if (start_day_id && !this.days.getDay(start_day_id, tripId)) + return errorResult('start_day_id does not belong to this trip.'); + if (end_day_id && !this.days.getDay(end_day_id, tripId)) + return errorResult('end_day_id does not belong to this trip.'); + + // Only resolve when endpoints are explicitly provided; undefined leaves them untouched. + let resolvedEndpoints: EndpointInput[] | undefined; + if (endpoints !== undefined) { + const resolved = resolveEndpointCoords(endpoints); + if ('error' in resolved) return errorResult(resolved.error); + resolvedEndpoints = resolved.endpoints; + } + + const { reservation } = this.reservations.update(reservationId, tripId, { + title, + type, + reservation_time, + reservation_end_time, + confirmation_number, + notes, + day_id: start_day_id, + end_day_id, + status, + metadata, + endpoints: resolvedEndpoints, + needs_review, + }, existing); + safeBroadcast(tripId, 'reservation:updated', { reservation }); + return ok({ reservation }); + } + + @Tool({ + name: 'delete_transport', + description: 'Delete a transport booking from a trip.', + inputSchema: { + tripId: z.number().int().positive(), + reservationId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'reservations', mode: 'write' }, + }) + async deleteTransport({ tripId, reservationId }: { tripId: number; reservationId: number }, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + const { deleted } = this.reservations.remove(reservationId, tripId); + if (!deleted) return errorResult('Transport not found.'); + safeBroadcast(tripId, 'reservation:deleted', { reservationId }); + return ok({ success: true }); + } } diff --git a/server/src/nest/weather/weather.mcp.ts b/server/src/nest/weather/weather.mcp.ts new file mode 100644 index 000000000..e39f3f7c2 --- /dev/null +++ b/server/src/nest/weather/weather.mcp.ts @@ -0,0 +1,61 @@ +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext } from '@trek/nest-mcp'; +import { z } from 'zod'; +import { WeatherService } from './weather.service'; + +/** + * Weather MCP tools, moved 1:1 from the legacy registrar in + * src/mcp/tools/mapsWeather.ts. Same names, descriptions, input schemas and + * result shapes; the registration-time `canRead(scopes, 'weather')` gate became + * the declarative `access` marker ('weather' is read-only — there is no + * weather:write). + * + * The catch that answers with the upstream message and `isError: true` rather + * than throwing is carried over verbatim: the client sees "Weather service not + * available." instead of a transport-level failure when the provider is down. + */ +@McpController() +export class WeatherMcp { + constructor(private readonly weather: WeatherService) {} + + @Tool({ + name: 'get_weather', + description: 'Get weather forecast for a location and date.', + inputSchema: { + lat: z.number(), + lng: z.number(), + date: z.string().describe('ISO date YYYY-MM-DD'), + lang: z.string().optional().default('en'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'weather', mode: 'read' }, + }) + async getWeather({ lat, lng, date, lang }: { lat: number; lng: number; date: string; lang?: string }, _ctx: McpContext) { + try { + const weather = await this.weather.get(String(lat), String(lng), date, lang ?? 'en'); + return ok({ weather }); + } catch (err) { + return { content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }], isError: true }; + } + } + + @Tool({ + name: 'get_detailed_weather', + description: 'Get hourly/detailed weather forecast for a location and date.', + inputSchema: { + lat: z.number(), + lng: z.number(), + date: z.string().describe('ISO date YYYY-MM-DD'), + lang: z.string().optional().default('en'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + access: { group: 'weather', mode: 'read' }, + }) + async getDetailedWeather({ lat, lng, date, lang }: { lat: number; lng: number; date: string; lang?: string }, _ctx: McpContext) { + try { + const weather = await this.weather.getDetailed(String(lat), String(lng), date, lang ?? 'en'); + return ok({ weather }); + } catch (err) { + return { content: [{ type: 'text' as const, text: (err as Error)?.message ?? 'Weather service not available.' }], isError: true }; + } + } +} diff --git a/server/src/nest/weather/weather.module.ts b/server/src/nest/weather/weather.module.ts index 3615fa1a7..28eedb5db 100644 --- a/server/src/nest/weather/weather.module.ts +++ b/server/src/nest/weather/weather.module.ts @@ -1,10 +1,11 @@ import { Module } from '@nestjs/common'; import { WeatherController } from './weather.controller'; import { WeatherService } from './weather.service'; +import { WeatherMcp } from './weather.mcp'; /** Weather domain (pilot leaf module). Registered in AppModule. */ @Module({ controllers: [WeatherController], - providers: [WeatherService], + providers: [WeatherService, WeatherMcp], }) export class WeatherModule {} diff --git a/server/tests/helpers/mcp-test-controllers.ts b/server/tests/helpers/mcp-test-controllers.ts index fba1bb932..49925e674 100644 --- a/server/tests/helpers/mcp-test-controllers.ts +++ b/server/tests/helpers/mcp-test-controllers.ts @@ -21,6 +21,9 @@ import { DayNotesService } from '../../src/nest/days/day-notes.service'; import { DaysMcp } from '../../src/nest/days/days.mcp'; import { DaysService } from '../../src/nest/days/days.service'; 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 { MapsService } from '../../src/nest/maps/maps.service'; import { NotificationsMcp } from '../../src/nest/notifications/notifications.mcp'; import { NotificationsService } from '../../src/nest/notifications/notifications.service'; @@ -89,6 +92,9 @@ export function createMcpTestRegistry(): McpRegistry { [ new TagsMcp(new TagsService(dbService), authService), new CategoriesMcp(new CategoriesService(dbService)), + // The weather and airport tools left the legacy mapsWeather registrar. + new WeatherMcp(new WeatherService()), + new AirportsMcp(), new TodoMcp(todoService, authService), new PackingMcp(packingService, authService), new BudgetMcp(budgetService, exchangeRatesService, dbService, authService), diff --git a/server/tests/unit/nest/budget.service.db.test.ts b/server/tests/unit/nest/budget.service.db.test.ts index 4f569d433..df75f5073 100644 --- a/server/tests/unit/nest/budget.service.db.test.ts +++ b/server/tests/unit/nest/budget.service.db.test.ts @@ -408,10 +408,11 @@ describe('rebaseTripCurrency', () => { }); describe('budget.bridge delegation', () => { - // The listBudgetItems / rebaseTripCurrency / removeUserFromBudgetItems bridge - // exports were pruned when their last outside-container consumers (legacy - // tripService, the trips MCP registrar, services/userCleanupService) migrated - // — 015/016/017 pin the same behavior on the service. + // Every bridge export except removeUserFromBudgetItems has been pruned as its + // last outside-container consumer migrated (legacy tripService, the trips MCP + // registrar, services/userCleanupService, and the create_transport registrar, + // which moved into reservations.mcp.ts). 015-018 pin the same behavior on the + // service. it('BUDGET-SVC-DB-015: listBudgetItems returns the hydrated list', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); @@ -447,14 +448,14 @@ describe('budget.bridge delegation', () => { expect(row).toEqual({ currency: 'EUR', exchange_rate: RATES.RUB.EUR }); }); - it('BUDGET-SVC-DB-018: linkBudgetItemToReservation stamps the reservation id through the bridge', () => { + it('BUDGET-SVC-DB-018: linkBudgetItemToReservation stamps the reservation id', () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); const reservationId = Number(testDb .prepare("INSERT INTO reservations (trip_id, title, type) VALUES (?, 'Flight', 'flight')") .run(trip.id).lastInsertRowid); - const item = bridge.linkBudgetItemToReservation(trip.id, reservationId, { name: 'Flight', total_price: 200 }); + const item = budget.linkBudgetItemToReservation(trip.id, reservationId, { name: 'Flight', total_price: 200 }); expect(item.reservation_id).toBe(reservationId); const row = testDb.prepare('SELECT reservation_id FROM budget_items WHERE id = ?').get(item.id) as { reservation_id: number | null };