mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
093e069ccc
* refactor(auth): session token validation and password-change consistency * refactor(journey): entry field allow-list and public share-link consistency * refactor(mcp): align tool authorization with the REST permission checks * chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)
178 lines
9.1 KiB
TypeScript
178 lines
9.1 KiB
TypeScript
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
|
|
import { z } from 'zod';
|
|
import { canAccessTrip } from '../../db/database';
|
|
import { isDemoUser } from '../../services/authService';
|
|
import {
|
|
createReservation, deleteReservation, getReservation, updateReservation,
|
|
} from '../../services/reservationService';
|
|
import { linkBudgetItemToReservation } from '../../services/budgetService';
|
|
import { getDay } from '../../services/dayService';
|
|
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 endpointSchema = z.array(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(),
|
|
lng: z.number().optional(),
|
|
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'),
|
|
})).optional();
|
|
|
|
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 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,
|
|
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 };
|
|
|
|
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,
|
|
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 });
|
|
}
|
|
);
|
|
}
|