mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
da39b570eb
- Add Journey addon tools (list, get, entries, contributors, suggestions, available trips, create/update/delete journey and entries, reorder, contributors CRUD, preferences, share link management) - Add Journey resources (trek://journeys and sub-resources) - Split transport (flight/train/car/cruise) into dedicated tools with endpoints[] and needs_review support; narrow reservation types to non-transport only - Add airport lookup tools (search_airports, get_airport) under geo:read - Add import_places_from_url and bulk_delete_places to places tools - Add journey:read/write/share OAuth scopes (27 total) with translations across all 15 locales - Default end_day to start_day when creating a transport (MCP + UI) - Fix MCP.md drift: addon gates, removed files resource, corrected get_trip_summary description, todos under Packing addon
207 lines
12 KiB
TypeScript
207 lines
12 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, getReservation, updateReservation, deleteReservation,
|
|
updatePositions as updateReservationPositions,
|
|
} from '../../services/reservationService';
|
|
import { getDay } from '../../services/dayService';
|
|
import { placeExists, getAssignmentForTrip } from '../../services/assignmentService';
|
|
import {
|
|
safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE,
|
|
TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
demoDenied, noAccess, ok,
|
|
} from './_shared';
|
|
import { canWrite } from '../scopes';
|
|
|
|
export function registerReservationTools(server: McpServer, userId: number, scopes: string[] | null): void {
|
|
if (!canWrite(scopes, 'reservations')) return;
|
|
|
|
|
|
server.registerTool(
|
|
'create_reservation',
|
|
{
|
|
description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
title: z.string().min(1).max(200),
|
|
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
|
|
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
|
location: z.string().max(500).optional(),
|
|
confirmation_number: z.string().max(100).optional(),
|
|
notes: z.string().max(1000).optional(),
|
|
day_id: z.number().int().positive().optional(),
|
|
place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'),
|
|
start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'),
|
|
end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'),
|
|
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'),
|
|
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
|
|
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
|
|
},
|
|
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
|
|
// Validate that all referenced IDs belong to this trip
|
|
if (day_id && !getDay(day_id, tripId))
|
|
return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true };
|
|
if (place_id && !placeExists(place_id, tripId))
|
|
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], 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 };
|
|
if (assignment_id && !getAssignmentForTrip(assignment_id, tripId))
|
|
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
|
|
|
const createAccommodation = (type === 'hotel' && place_id && start_day_id && end_day_id)
|
|
? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined }
|
|
: undefined;
|
|
|
|
const { reservation, accommodationCreated } = createReservation(tripId, {
|
|
title, type, reservation_time, location, confirmation_number,
|
|
notes, day_id, place_id, assignment_id,
|
|
create_accommodation: createAccommodation,
|
|
});
|
|
|
|
if (accommodationCreated) {
|
|
safeBroadcast(tripId, 'accommodation:created', {});
|
|
}
|
|
safeBroadcast(tripId, 'reservation:created', { reservation });
|
|
return ok({ reservation });
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'update_reservation',
|
|
{
|
|
description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For flights, trains, cars, and cruises, use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/other → use assignment_id to link to a day assignment.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
reservationId: z.number().int().positive(),
|
|
title: z.string().min(1).max(200).optional(),
|
|
type: z.enum(['hotel', 'restaurant', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", or "other"'),
|
|
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
|
|
location: z.string().max(500).optional(),
|
|
confirmation_number: z.string().max(100).optional(),
|
|
notes: z.string().max(1000).optional(),
|
|
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'),
|
|
place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
|
|
assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
|
},
|
|
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
|
|
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: 'Reservation not found.' }], isError: true };
|
|
|
|
if (place_id != null && !placeExists(place_id, tripId))
|
|
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
|
if (assignment_id != null && !getAssignmentForTrip(assignment_id, tripId))
|
|
return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true };
|
|
|
|
const { reservation } = updateReservation(reservationId, tripId, {
|
|
title, type, reservation_time, location, confirmation_number, notes, status,
|
|
place_id: place_id !== undefined ? place_id ?? undefined : undefined,
|
|
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
|
|
}, existing);
|
|
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
|
return ok({ reservation });
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'delete_reservation',
|
|
{
|
|
description: 'Delete a reservation 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, accommodationDeleted } = deleteReservation(reservationId, tripId);
|
|
if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
|
if (accommodationDeleted) {
|
|
safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
|
|
}
|
|
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'reorder_reservations',
|
|
{
|
|
description: 'Update the display order of reservations within a day.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
positions: z.array(z.object({
|
|
id: z.number().int().positive(),
|
|
day_plan_position: z.number().int().min(0),
|
|
})).describe('Array of { id, day_plan_position } pairs'),
|
|
dayId: z.number().int().positive().optional().describe('Optionally scope the update to a specific day'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
|
},
|
|
async ({ tripId, positions, dayId }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
updateReservationPositions(tripId, positions, dayId);
|
|
safeBroadcast(tripId, 'reservation:positions', { positions, dayId });
|
|
return ok({ success: true });
|
|
}
|
|
);
|
|
|
|
server.registerTool(
|
|
'link_hotel_accommodation',
|
|
{
|
|
description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.',
|
|
inputSchema: {
|
|
tripId: z.number().int().positive(),
|
|
reservationId: z.number().int().positive(),
|
|
place_id: z.number().int().positive().describe('The hotel place to link'),
|
|
start_day_id: z.number().int().positive().describe('Check-in day ID'),
|
|
end_day_id: z.number().int().positive().describe('Check-out day ID'),
|
|
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00")'),
|
|
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_WRITE,
|
|
},
|
|
async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => {
|
|
if (isDemoUser(userId)) return demoDenied();
|
|
if (!canAccessTrip(tripId, userId)) return noAccess();
|
|
const current = getReservation(reservationId, tripId);
|
|
if (!current) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
|
|
if (current.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true };
|
|
|
|
if (!placeExists(place_id, tripId))
|
|
return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true };
|
|
if (!getDay(start_day_id, tripId))
|
|
return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true };
|
|
if (!getDay(end_day_id, tripId))
|
|
return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true };
|
|
|
|
const isNewAccommodation = !current.accommodation_id;
|
|
const { reservation } = updateReservation(reservationId, tripId, {
|
|
place_id,
|
|
type: current.type,
|
|
status: current.status as string,
|
|
create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined },
|
|
}, current);
|
|
|
|
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
|
|
safeBroadcast(tripId, 'reservation:updated', { reservation });
|
|
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
|
|
}
|
|
);
|
|
}
|