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:
Maurice
2026-08-08 14:28:45 +02:00
committed by Maurice
parent 790dd3b728
commit cacab0795f
11 changed files with 372 additions and 352 deletions
+7 -4
View File
@@ -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).
-98
View File
@@ -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 });
}
);
}
-229
View File
@@ -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 });
}
);
}
+46
View File
@@ -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 -1
View File
@@ -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 {}
+5 -13
View File
@@ -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 });
}
}
+61
View File
@@ -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 };
}
}
}
+2 -1
View File
@@ -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 };