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.
This commit is contained in:
Maurice
2026-08-28 12:17:40 +02:00
committed by Maurice
parent aa5002b2ed
commit f1bbd94f2b
3 changed files with 145 additions and 12 deletions
@@ -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<string, string>; 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<string, string>; 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,
@@ -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' });
});
});
});
+1 -1
View File
@@ -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. |