From f1bbd94f2b1393eb6a08d25e38a32c9a602fa90f Mon Sep 17 00:00:00 2001 From: Maurice Date: Fri, 28 Aug 2026 12:17:40 +0200 Subject: [PATCH] feat(mcp): booking link and end time on a reservation url is persisted on create and update and rendered as a link by the planner, and reservation_end_time is what makes a dinner run 19:00-21:30 rather than just starting. Neither was in a tool schema, so a booking written by an assistant lost both - which for an imported itinerary is every confirmation link it came with. url validates through reservationUrlSchema from @trek/shared, the same refinement the REST route applies, rather than a second hand-written one. --- .../src/nest/reservations/reservations.mcp.ts | 39 ++++-- .../tests/unit/mcp/tools-reservations.test.ts | 116 ++++++++++++++++++ wiki/MCP-Tools-and-Resources.md | 2 +- 3 files changed, 145 insertions(+), 12 deletions(-) diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 3b6904ea3..505295ff9 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -13,7 +13,7 @@ import { DaysService } from '../days/days.service'; import { findByIata } from '../airports/airports.data'; import type { EndpointInput } from './reservations.service'; import { AssignmentsService } from '../assignments/assignments.service'; -import { transportLegsInputSchema, type TransportLegInput } from '@trek/shared'; +import { transportLegsInputSchema, reservationUrlSchema, type TransportLegInput } from '@trek/shared'; // What counts as a transport booking, for the update_transport gate. Every value // ReservationsPanel renders with a transport icon, so a stored `transit` row is @@ -34,6 +34,15 @@ const LEG_TRANSPORT_TYPES = ['flight', 'train'] as const; /** Everything the picker offers that is not a transport — create_reservation's half. */ const BOOKING_TYPES = ['hotel', 'restaurant', 'event', 'tour', 'activity', 'parking', 'other'] as const; +/** + * The booking link. Same refinement the REST contract applies + * (shared/src/reservation/reservation.schema.ts), so a javascript:/data:/vbscript: + * URL is refused on this surface too rather than being stored and later rendered + * as an href. + */ +const urlField = reservationUrlSchema.max(2000).optional() + .describe('Link to the booking — the airline/hotel confirmation page, the ticket. Shown as a link on the booking.'); + type TransportType = typeof CREATABLE_TRANSPORT_TYPES[number]; type BookingType = typeof BOOKING_TYPES[number]; @@ -324,6 +333,8 @@ export class ReservationsMcp { title: z.string().min(1).max(200), type: z.enum(BOOKING_TYPES).describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", "parking", or "other"'), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'), + reservation_end_time: z.string().optional().describe('When it ends — a dinner from 19:00 to 21:30, a tour that runs all afternoon. Ignored for hotels, whose span is the check-in/check-out days.'), + url: urlField, location: z.string().max(500).optional(), confirmation_number: z.string().max(100).optional(), notes: z.string().max(1000).optional(), @@ -341,9 +352,9 @@ export class ReservationsMcp { access: { group: 'reservations', mode: 'write' }, }) async createReservation( - { 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, price, budget_category }: { + { tripId, title, type, reservation_time, reservation_end_time, url, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }: { tripId: number; title: string; type: BookingType; - reservation_time?: string; location?: string; confirmation_number?: string; notes?: string; + reservation_time?: string; reservation_end_time?: string; url?: string; location?: string; confirmation_number?: string; notes?: string; day_id?: number; place_id?: number; start_day_id?: number; end_day_id?: number; check_in?: string; check_out?: string; assignment_id?: number; price?: number; budget_category?: string; }, @@ -372,7 +383,7 @@ export class ReservationsMcp { const metadata = price != null ? { price: String(price) } : undefined; const { reservation, accommodationCreated } = this.reservations.create(tripId, { - title, type, reservation_time, location, confirmation_number, + title, type, reservation_time, reservation_end_time, url, location, confirmation_number, notes, day_id, place_id, assignment_id, create_accommodation: createAccommodation, metadata, @@ -404,6 +415,8 @@ export class ReservationsMcp { title: z.string().min(1).max(200).optional(), type: z.enum(BOOKING_TYPES).optional().describe('Reservation type: "hotel", "restaurant", "event", "tour", "activity", "parking", or "other"'), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'), + reservation_end_time: z.string().optional().describe('When it ends. Ignored for hotels, whose span is the check-in/check-out days.'), + url: urlField, location: z.string().max(500).optional(), confirmation_number: z.string().max(100).optional(), notes: z.string().max(1000).optional(), @@ -415,10 +428,10 @@ export class ReservationsMcp { access: { group: 'reservations', mode: 'write' }, }) async updateReservation( - { tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }: { + { tripId, reservationId, title, type, reservation_time, reservation_end_time, url, location, confirmation_number, notes, status, place_id, assignment_id }: { tripId: number; reservationId: number; title?: string; type?: BookingType; - reservation_time?: string; location?: string; confirmation_number?: string; notes?: string; + reservation_time?: string; reservation_end_time?: string; url?: string; location?: string; confirmation_number?: string; notes?: string; status?: 'pending' | 'confirmed' | 'cancelled'; place_id?: number | null; assignment_id?: number | null; }, ctx: McpContext, @@ -435,7 +448,7 @@ export class ReservationsMcp { return errorResult('assignment_id does not belong to this trip.'); const { reservation } = this.reservations.update(reservationId, tripId, { - title, type, reservation_time, location, confirmation_number, notes, status, + title, type, reservation_time, reservation_end_time, url, location, confirmation_number, notes, status, place_id: place_id !== undefined ? place_id ?? undefined : undefined, assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined, }, existing); @@ -635,6 +648,7 @@ export class ReservationsMcp { 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(), + url: urlField, 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 }. Values are plain strings, so per-segment detail belongs in legs[], not here.'), endpoints: endpointSchema, @@ -647,10 +661,10 @@ export class ReservationsMcp { 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, legs, needs_review, price, budget_category }: { + { tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, url, notes, metadata, endpoints, legs, needs_review, price, budget_category }: { tripId: number; type: TransportType; 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; + reservation_time?: string; reservation_end_time?: string; confirmation_number?: string; url?: string; notes?: string; metadata?: Record; endpoints?: TransportEndpoint[]; legs?: TransportLegInput[]; needs_review?: boolean; price?: number; budget_category?: string; }, @@ -708,6 +722,7 @@ export class ReservationsMcp { reservation_end_time: arrivalTime, location: undefined, confirmation_number, + url, notes, day_id: dayId, end_day_id: spanEndDayId ?? dayId, @@ -744,6 +759,7 @@ export class ReservationsMcp { 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(), + url: urlField, 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 }. Replaces the stored metadata wholesale. Values are plain strings, so per-segment detail belongs in legs[], not here.'), endpoints: endpointSchema, @@ -754,10 +770,10 @@ export class ReservationsMcp { 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, legs, needs_review }: { + { tripId, reservationId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, url, notes, metadata, endpoints, legs, needs_review }: { tripId: number; reservationId: number; type?: TransportType; 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; + reservation_time?: string; reservation_end_time?: string; confirmation_number?: string; url?: string; notes?: string; metadata?: Record; endpoints?: TransportEndpoint[]; legs?: TransportLegInput[]; needs_review?: boolean; }, ctx: McpContext, @@ -834,6 +850,7 @@ export class ReservationsMcp { reservation_time: departureTime, reservation_end_time: arrivalTime, confirmation_number, + url, notes, day_id: dayId, end_day_id: spanEndDayId, diff --git a/server/tests/unit/mcp/tools-reservations.test.ts b/server/tests/unit/mcp/tools-reservations.test.ts index 8bc5b3da1..4ebf08b55 100644 --- a/server/tests/unit/mcp/tools-reservations.test.ts +++ b/server/tests/unit/mcp/tools-reservations.test.ts @@ -672,3 +672,119 @@ describe('Tool: set_reservation_travelers', () => { }); }); }); + +// --------------------------------------------------------------------------- +// The booking link and the end time +// +// Both are persisted on create and update and rendered by the planner; neither +// was in any tool schema, so an imported booking arrived without its +// confirmation link and a dinner could only carry a start. +// --------------------------------------------------------------------------- + +describe('Reservation tools: url and reservation_end_time', () => { + it('stores the booking link on create', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_reservation', + arguments: { + tripId: trip.id, type: 'restaurant', title: 'Dinner', + url: 'https://example.com/booking/abc123', + }, + }); + const data = parseToolResult(result) as any; + expect(data.reservation.url).toBe('https://example.com/booking/abc123'); + const row = testDb.prepare('SELECT url FROM reservations WHERE id = ?').get(data.reservation.id) as any; + expect(row.url).toBe('https://example.com/booking/abc123'); + }); + }); + + it('stores an end time on a non-transport booking', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_reservation', + arguments: { + tripId: trip.id, type: 'restaurant', title: 'Dinner', + reservation_time: '2025-06-01T19:00:00', reservation_end_time: '2025-06-01T21:30:00', + }, + }); + const data = parseToolResult(result) as any; + const row = testDb.prepare('SELECT reservation_time, reservation_end_time FROM reservations WHERE id = ?').get(data.reservation.id) as any; + expect(row.reservation_time).toBe('2025-06-01T19:00:00'); + expect(row.reservation_end_time).toBe('2025-06-01T21:30:00'); + }); + }); + + it('updates the link and the end time on an existing booking', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const created = await h.client.callTool({ + name: 'create_reservation', + arguments: { tripId: trip.id, type: 'tour', title: 'Walking tour' }, + }); + const { reservation } = parseToolResult(created) as { reservation: { id: number } }; + + await h.client.callTool({ + name: 'update_reservation', + arguments: { + tripId: trip.id, reservationId: reservation.id, + url: 'https://tours.example/booking/9', reservation_end_time: '2025-06-02T16:00:00', + }, + }); + const row = testDb.prepare('SELECT url, reservation_end_time FROM reservations WHERE id = ?').get(reservation.id) as any; + expect(row.url).toBe('https://tours.example/booking/9'); + expect(row.reservation_end_time).toBe('2025-06-02T16:00:00'); + }); + }); + + it('refuses a javascript: link, like the REST contract does', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_reservation', + arguments: { tripId: trip.id, type: 'other', title: 'Nope', url: 'javascript:alert(1)' }, + }); + expect(result.isError).toBe(true); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM reservations').get()).toEqual({ n: 0 }); + }); + }); + + it('refuses a javascript: link split by a control character', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_reservation', + arguments: { tripId: trip.id, type: 'other', title: 'Nope', url: 'java\tscript:alert(1)' }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('stores the booking link on a transport booking too', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const created = await h.client.callTool({ + name: 'create_transport', + arguments: { + tripId: trip.id, type: 'bus', title: 'Zurich → Milan', + url: 'https://flix.example/booking/8891', + }, + }); + const { reservation } = parseToolResult(created) as { reservation: { id: number } }; + expect(testDb.prepare('SELECT url FROM reservations WHERE id = ?').get(reservation.id)).toEqual({ url: 'https://flix.example/booking/8891' }); + + await h.client.callTool({ + name: 'update_transport', + arguments: { tripId: trip.id, reservationId: reservation.id, url: 'https://flix.example/booking/8892' }, + }); + expect(testDb.prepare('SELECT url FROM reservations WHERE id = ?').get(reservation.id)).toEqual({ url: 'https://flix.example/booking/8892' }); + }); + }); +}); diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 356435db1..6de16f93c 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -128,7 +128,7 @@ Requires `reservations:read` or `reservations:write` scope. | Tool | Description | |---|---| -| `create_reservation` | Create a pending reservation — hotels, restaurants, events, tours, activities, and other types. | +| `create_reservation` | Create a pending reservation — hotels, restaurants, events, tours, activities, and other types. Carries the booking link (`url`) and an end time. | | `update_reservation` | Update any field including status (`pending` / `confirmed` / `cancelled`). | | `delete_reservation` | Delete a reservation and its linked accommodation record if applicable. | | `reorder_reservations` | Reorder reservations within a day. |