mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
49af7a8b0d
Zod v4 changed z.record(valueType) to z.record(keyType, valueType). The single-arg form now sets keyType, leaving valueType as undefined. This caused tools/list to throw 'Cannot read properties of undefined (reading _zod)' when the SDK tried to serialize the metadata field to JSON Schema, silently returning an error for every tools/list call and making all MCP tools invisible in claude.ai.
159 lines
7.9 KiB
TypeScript
159 lines
7.9 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 { getDay } from '../../services/dayService';
|
|
import {
|
|
safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
TOOL_ANNOTATIONS_WRITE, demoDenied, noAccess, ok,
|
|
} 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.',
|
|
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(),
|
|
},
|
|
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 }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
|
|
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 } = 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,
|
|
endpoints,
|
|
needs_review,
|
|
});
|
|
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();
|
|
|
|
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();
|
|
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 });
|
|
}
|
|
);
|
|
}
|