diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 676a9d762..3b6904ea3 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -15,17 +15,26 @@ import type { EndpointInput } from './reservations.service'; import { AssignmentsService } from '../assignments/assignments.service'; import { transportLegsInputSchema, type TransportLegInput } from '@trek/shared'; -// The type picker in the planner (client/src/components/Planner/TransportModal.tsx) -// offers ten transport modes and ReservationsPanel renders all of them; the tools -// below used to accept four, so a bus or a ferry could be planned in the UI and not -// through an assistant. Both lists are the picker's, in the picker's order. +// 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 +// editable through the transport tools like any other. const TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other'] as const; +// What a caller may ASK for, which is the transport form's own picker +// (client/src/components/Planner/TransportModal.tsx), in its order. The tools +// below used to accept four of these nine, so a bus or a ferry could be planned +// in the UI and not through an assistant. +// +// `transit` is deliberately absent: the picker does not offer it either. A transit +// booking carries a provider itinerary in metadata.transit, and create_transit_journey +// is what writes one — a hand-made `transit` row would be a shape the transit UI +// does not expect. +const CREATABLE_TRANSPORT_TYPES = ['flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other'] as const; /** Only these two carry per-segment detail: the transport form writes metadata.legs for a flight or a train and for nothing else. */ 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; -type TransportType = typeof TRANSPORT_TYPES[number]; +type TransportType = typeof CREATABLE_TRANSPORT_TYPES[number]; type BookingType = typeof BOOKING_TYPES[number]; const endpointObjectSchema = z.object({ @@ -309,7 +318,7 @@ export class ReservationsMcp { @Tool({ name: 'create_reservation', - description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For anything travelled ON — flight, train, bus, car, taxi, bicycle, cruise, ferry, transit, transport_other — 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/parking/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.', + description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For anything travelled ON — flight, train, bus, car, taxi, bicycle, cruise, ferry, transport_other — 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/parking/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.', inputSchema: { tripId: z.number().int().positive(), title: z.string().min(1).max(200), @@ -388,7 +397,7 @@ export class ReservationsMcp { @Tool({ name: 'update_reservation', - description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For anything travelled ON — flight, train, bus, car, taxi, bicycle, cruise, ferry, transit, transport_other — use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/parking/other → use assignment_id to link to a day assignment.', + description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. For anything travelled ON — flight, train, bus, car, taxi, bicycle, cruise, ferry, transport_other — use update_transport instead. Linking: hotel → use place_id to link to an accommodation place; restaurant/event/tour/activity/parking/other → use assignment_id to link to a day assignment.', inputSchema: { tripId: z.number().int().positive(), reservationId: z.number().int().positive(), @@ -482,7 +491,14 @@ export class ReservationsMcp { if (!result) return errorResult('Reservation not found.'); this.guards.safeBroadcast(tripId, 'reservation:travelers-updated', { reservationId, travelers: result.travelers }); - return ok({ travelers: result.travelers }); + // Report what the roster filter dropped. Silently succeeding with a shorter + // list reads as "done" to a caller that guessed an id for a name it could + // not resolve, which is exactly what an importer does. + const attached = new Set(result.travelers.map(t => t.user_id)); + const ignored = [...new Set(user_ids)].filter(uid => !attached.has(uid)); + return ok(ignored.length > 0 + ? { travelers: result.travelers, ignored_user_ids: ignored, note: 'Ignored ids are not on this trip. Add them with add_trip_member or create_trip_guest first.' } + : { travelers: result.travelers }); } @Tool({ @@ -608,10 +624,10 @@ export class ReservationsMcp { @Tool({ name: 'create_transport', - description: 'Create a transport booking for a trip — flight, train, bus, car, taxi, bicycle, cruise, ferry, transit or transport_other. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.', + description: 'Create a transport booking for a trip — flight, train, bus, car, taxi, bicycle, cruise, ferry or transport_other. For scheduled public transit use create_transit_journey, which attaches the provider itinerary. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). For a booking WITH STOPOVERS also pass legs[] (one entry per segment, one fewer than endpoints[]), otherwise every segment inherits the stop time as both its arrival and its departure. The top-level confirmation_number is the booking reference; when a single segment was booked under its own reference, put that one on the leg instead. 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(TRANSPORT_TYPES), + type: z.enum(CREATABLE_TRANSPORT_TYPES), 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'), @@ -720,7 +736,7 @@ export class ReservationsMcp { inputSchema: { tripId: z.number().int().positive(), reservationId: z.number().int().positive(), - type: z.enum(TRANSPORT_TYPES).optional(), + type: z.enum(CREATABLE_TRANSPORT_TYPES).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'), diff --git a/server/tests/unit/mcp/tools-reservations.test.ts b/server/tests/unit/mcp/tools-reservations.test.ts index 15c0cce53..8bc5b3da1 100644 --- a/server/tests/unit/mcp/tools-reservations.test.ts +++ b/server/tests/unit/mcp/tools-reservations.test.ts @@ -592,6 +592,23 @@ describe('Tool: set_reservation_travelers', () => { }); const data = parseToolResult(result) as any; expect(data.travelers.map((t: any) => t.user_id)).toEqual([owner.id]); + // A caller that guessed an id for a name it could not resolve is told so + // rather than getting a plain success with a shorter list. + expect(data.ignored_user_ids).toEqual([outsider.id]); + }); + }); + + it('says nothing about ignored ids when every id was on the trip', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const reservationId = await makeBooking(h, trip.id); + const result = await h.client.callTool({ + name: 'set_reservation_travelers', + arguments: { tripId: trip.id, reservationId, user_ids: [owner.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.ignored_user_ids).toBeUndefined(); }); }); diff --git a/server/tests/unit/mcp/tools-transports.test.ts b/server/tests/unit/mcp/tools-transports.test.ts index 777b05f27..afa465da3 100644 --- a/server/tests/unit/mcp/tools-transports.test.ts +++ b/server/tests/unit/mcp/tools-transports.test.ts @@ -1052,11 +1052,11 @@ describe('Tool: update_transport (multi-leg)', () => { /** client/src/components/Planner/TransportModal.tsx, in the picker's order. */ const PICKER_TRANSPORT_TYPES = [ - 'flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transit', 'transport_other', + 'flight', 'train', 'bus', 'car', 'taxi', 'bicycle', 'cruise', 'ferry', 'transport_other', ] as const; -/** The six the tools used to reject outright. */ -const NEWLY_ACCEPTED = ['bus', 'taxi', 'bicycle', 'ferry', 'transit', 'transport_other'] as const; +/** The five the tools used to reject outright. */ +const NEWLY_ACCEPTED = ['bus', 'taxi', 'bicycle', 'ferry', 'transport_other'] as const; describe('Transport tools: the full type list', () => { it.each(PICKER_TRANSPORT_TYPES)('create_transport accepts %s', async (type) => { @@ -1172,6 +1172,36 @@ describe('Transport tools: the full type list', () => { }); }); + it('refuses to hand-make a transit booking, which create_transit_journey owns', 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_transport', + arguments: { tripId: trip.id, type: 'transit', title: 'Tram 4' }, + }); + expect((result as { isError?: boolean }).isError).toBe(true); + }); + }); + + it('still lets update_transport reach a stored transit booking', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // As create_transit_journey leaves it: a transport row carrying an itinerary. + const reservationId = Number(testDb.prepare( + "INSERT INTO reservations (trip_id, title, type, status) VALUES (?, 'Tram 4', 'transit', 'pending')" + ).run(trip.id).lastInsertRowid); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_transport', + arguments: { tripId: trip.id, reservationId, status: 'confirmed' }, + }); + expect((result as { isError?: boolean }).isError).toBeFalsy(); + const row = testDb.prepare('SELECT status, type FROM reservations WHERE id = ?').get(reservationId) as any; + expect(row).toMatchObject({ status: 'confirmed', type: 'transit' }); + }); + }); + it('still refuses legs on a mode whose form never writes them', async () => { const { user } = createUser(testDb); const trip = createTrip(testDb, user.id); diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 74c3e2899..356435db1 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -108,7 +108,7 @@ Requires `reservations:write` scope. | Tool | Description | |---|---| -| `create_transport` | Create a transport booking (`flight`, `train`, `bus`, `car`, `taxi`, `bicycle`, `cruise`, `ferry`, `transit`, `transport_other`) with optional multi-stop endpoints, departure/arrival times, and confirmation details. | +| `create_transport` | Create a transport booking — the nine types the transport form offers (`flight`, `train`, `bus`, `car`, `taxi`, `bicycle`, `cruise`, `ferry`, `transport_other`) — with optional multi-stop endpoints, departure/arrival times, and confirmation details. Scheduled public transit goes through `create_transit_journey` instead. | | `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace all stops. | | `delete_transport` | Delete a transport booking from a trip. | @@ -132,7 +132,7 @@ Requires `reservations:read` or `reservations:write` scope. | `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. | -| `set_reservation_travelers` | Set who is travelling on a booking, from the trip roster (members and guests). Replaces the list; an empty array clears it. | +| `set_reservation_travelers` | Set who is travelling on a booking, from the trip roster (members and guests). Replaces the list; an empty array clears it. Ids that are not on the trip come back under `ignored_user_ids` rather than being attached. | | `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and place. | ### Budget diff --git a/wiki/Reservations-and-Bookings.md b/wiki/Reservations-and-Bookings.md index 0cdde4637..7e5ffc037 100644 --- a/wiki/Reservations-and-Bookings.md +++ b/wiki/Reservations-and-Bookings.md @@ -39,6 +39,10 @@ Reservations are grouped into two collapsible sections: **Pending** and **Confir On desktop, a type filter bar lets you show only specific types. Filter selections are kept for the current browser session. +Travelers are set per booking from the trip roster, guests included. + +> **AI / MCP:** `set_reservation_travelers` writes that list; it replaces it wholesale and ignores anybody who is not on the trip. See [MCP-Tools-and-Resources](MCP-Tools-and-Resources). + A traveler filter sits next to it — on desktop and mobile — once the trip has more than one member and at least one booking has travelers assigned. Click an avatar to show only that person's bookings; several can be active at once. On desktop the selection is kept for the browser session like the type filter; on mobile it resets when you leave the tab. ## Reservation card contents diff --git a/wiki/Transport-Flights-Trains-Cars.md b/wiki/Transport-Flights-Trains-Cars.md index 18fde44b5..4dcb5656e 100644 --- a/wiki/Transport-Flights-Trains-Cars.md +++ b/wiki/Transport-Flights-Trains-Cars.md @@ -27,6 +27,8 @@ Self-hosters can point the `TRANSIT_API_URL` environment variable at their own M Nine types are available: **Flight**, **Train**, **Bus**, **Car**, **Taxi**, **Bicycle**, **Cruise**, **Ferry**, and **Other**. +> **AI / MCP:** `create_transport` accepts the same nine. Scheduled public transit is its own thing — `create_transit_journey` attaches the provider itinerary. See [MCP-Tools-and-Resources](MCP-Tools-and-Resources). + ## Common fields All transport types share these fields: diff --git a/wiki/Trip-Members-and-Sharing.md b/wiki/Trip-Members-and-Sharing.md index 971f67d3a..f9bc3c063 100644 --- a/wiki/Trip-Members-and-Sharing.md +++ b/wiki/Trip-Members-and-Sharing.md @@ -79,6 +79,8 @@ In the owner's Guests section, each guest row has a **Rename** (pencil) and a ** There is no limit on the number of guests per trip. +> **AI / MCP:** an assistant can do all three — `create_trip_guest`, `rename_trip_guest`, `delete_trip_guest` — and is held to the same owner-only rule. See [MCP-Tools-and-Resources](MCP-Tools-and-Resources). + ## Public Share Link The right column is only visible to users with the `share_manage` permission (default: trip owner).