diff --git a/server/src/nest/reservations/reservations.mcp.ts b/server/src/nest/reservations/reservations.mcp.ts index 600af35ca..676a9d762 100644 --- a/server/src/nest/reservations/reservations.mcp.ts +++ b/server/src/nest/reservations/reservations.mcp.ts @@ -457,6 +457,34 @@ export class ReservationsMcp { return ok({ success: true }); } + @Tool({ + name: 'set_reservation_travelers', + description: 'Set who is travelling on a booking, replacing the current list. Pass the user IDs of trip members or guests from list_trip_members; an empty array clears the list. Somebody who is not on the trip is ignored rather than added — use add_trip_member for a person with a TREK account, or create_trip_guest for one without.', + inputSchema: { + tripId: z.number().int().positive(), + reservationId: z.number().int().positive(), + user_ids: z.array(z.number().int().positive()).describe('User IDs of the travellers, from list_trip_members. Replaces the whole list; [] clears it.'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'reservations', mode: 'write' }, + }) + async setReservationTravelers( + { tripId, reservationId, user_ids }: { tripId: number; reservationId: number; user_ids: number[] }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.reservations.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('reservation_edit', tripId, ctx.userId)) return permissionDenied(); + + // The service filters the ids against the trip roster on its own, so an + // off-trip id cannot be attached; a missing booking is the only failure. + const result = this.reservations.setTravelers(String(reservationId), String(tripId), user_ids); + if (!result) return errorResult('Reservation not found.'); + + this.guards.safeBroadcast(tripId, 'reservation:travelers-updated', { reservationId, travelers: result.travelers }); + return ok({ travelers: result.travelers }); + } + @Tool({ name: 'reorder_reservations', description: 'Update the display order of reservations within a day.', diff --git a/server/tests/unit/mcp/tools-reservations.test.ts b/server/tests/unit/mcp/tools-reservations.test.ts index bcbe1b7fd..15c0cce53 100644 --- a/server/tests/unit/mcp/tools-reservations.test.ts +++ b/server/tests/unit/mcp/tools-reservations.test.ts @@ -36,7 +36,7 @@ vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment } from '../../helpers/factories'; +import { createUser, createTrip, createDay, createPlace, createReservation, createDayAssignment, addTripMember } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; beforeAll(() => { @@ -482,3 +482,176 @@ describe('Resource: trek://trips/{tripId}/reservations', () => { }); }); }); + +// --------------------------------------------------------------------------- +// set_reservation_travelers (#1517) +// +// Reads already hydrate a booking's travellers; there was no way to write them, +// so an imported booking could not record who was on it. The roster filter lives +// in the service, so the cases below pin that an off-trip id is dropped rather +// than attached, and that a guest — the only representation of a companion +// without an account — is assignable like any member. +// --------------------------------------------------------------------------- + +describe('Tool: set_reservation_travelers', () => { + async function makeBooking(h: McpHarness, tripId: number): Promise { + const created = await h.client.callTool({ + name: 'create_reservation', + arguments: { tripId, type: 'event', title: 'Concert' }, + }); + return (parseToolResult(created) as any).reservation.id; + } + + it('sets the travellers on a booking', async () => { + const { user: owner } = createUser(testDb); + const { user: friend } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, friend.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, friend.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.travelers.map((t: any) => t.user_id).sort()).toEqual([owner.id, friend.id].sort()); + const rows = testDb.prepare('SELECT user_id FROM reservation_travelers WHERE reservation_id = ?').all(reservationId) as any[]; + expect(rows).toHaveLength(2); + }); + }); + + it('assigns a guest, who has no account to look up', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const guestResult = await h.client.callTool({ + name: 'create_trip_guest', + arguments: { tripId: trip.id, name: 'Anna' }, + }); + const guestId = (parseToolResult(guestResult) as any).member.id; + const reservationId = await makeBooking(h, trip.id); + + const result = await h.client.callTool({ + name: 'set_reservation_travelers', + arguments: { tripId: trip.id, reservationId, user_ids: [guestId] }, + }); + const data = parseToolResult(result) as any; + expect(data.travelers).toHaveLength(1); + expect(data.travelers[0].username).toBe('Anna'); + expect(data.travelers[0].is_guest).toBeTruthy(); + }); + }); + + it('replaces the previous list rather than adding to it', async () => { + const { user: owner } = createUser(testDb); + const { user: friend } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, friend.id); + await withHarness(owner.id, async (h) => { + const reservationId = await makeBooking(h, trip.id); + await h.client.callTool({ + name: 'set_reservation_travelers', + arguments: { tripId: trip.id, reservationId, user_ids: [owner.id, friend.id] }, + }); + const result = await h.client.callTool({ + name: 'set_reservation_travelers', + arguments: { tripId: trip.id, reservationId, user_ids: [friend.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.travelers.map((t: any) => t.user_id)).toEqual([friend.id]); + }); + }); + + it('clears the list on an empty array', 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); + await h.client.callTool({ + name: 'set_reservation_travelers', + arguments: { tripId: trip.id, reservationId, user_ids: [owner.id] }, + }); + const result = await h.client.callTool({ + name: 'set_reservation_travelers', + arguments: { tripId: trip.id, reservationId, user_ids: [] }, + }); + expect((parseToolResult(result) as any).travelers).toEqual([]); + expect(testDb.prepare('SELECT user_id FROM reservation_travelers WHERE reservation_id = ?').all(reservationId)).toEqual([]); + }); + }); + + it('drops a user who is not on the trip instead of attaching them', async () => { + const { user: owner } = createUser(testDb); + const { user: outsider } = 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, outsider.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.travelers.map((t: any) => t.user_id)).toEqual([owner.id]); + }); + }); + + it('broadcasts reservation:travelers-updated', 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); + broadcastMock.mockClear(); + await h.client.callTool({ + name: 'set_reservation_travelers', + arguments: { tripId: trip.id, reservationId, user_ids: [owner.id] }, + }); + expect(broadcastMock).toHaveBeenCalledWith( + trip.id, + 'reservation:travelers-updated', + expect.objectContaining({ reservationId }), + ); + }); + }); + + it('404s a booking of another trip', async () => { + const { user: owner } = createUser(testDb); + const ownTrip = createTrip(testDb, owner.id); + const otherTrip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const reservationId = await makeBooking(h, otherTrip.id); + const result = await h.client.callTool({ + name: 'set_reservation_travelers', + arguments: { tripId: ownTrip.id, reservationId, user_ids: [owner.id] }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns access denied for a non-member', async () => { + const { user: owner } = createUser(testDb); + const { user: outsider } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + let reservationId = 0; + await withHarness(owner.id, async (h) => { reservationId = await makeBooking(h, trip.id); }); + await withHarness(outsider.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_reservation_travelers', + arguments: { tripId: trip.id, reservationId, user_ids: [outsider.id] }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_reservation_travelers', + arguments: { tripId: trip.id, reservationId: 1, user_ids: [] }, + }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 341c4451d..74c3e2899 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -132,6 +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. | | `link_hotel_accommodation` | Set or update a hotel reservation's check-in/out day links and place. | ### Budget