mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
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.
This commit is contained in:
@@ -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).
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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<typeof endpointObjectSchema>;
|
||||
|
||||
/**
|
||||
* 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<string, string> = { ...(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 });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<BudgetService['linkBudgetItemToReservation']>[2],
|
||||
) {
|
||||
return budget.linkBudgetItemToReservation(tripId, reservationId, data);
|
||||
}
|
||||
|
||||
@@ -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<typeof endpointObjectSchema>;
|
||||
|
||||
/**
|
||||
* 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<string, string>; 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<string, string> = { ...(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<string, string>; 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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user