feat(mcp): set who is travelling on a booking

Reads have hydrated a booking's travellers since #1517; there was no way
to write them, so an imported booking could not record who was on it -
and creating a guest is only half an answer if the guest cannot then be
attached to the flight they were on.

Mirrors PUT /api/trips/:tripId/reservations/:id/travelers. The roster
filter stays in the service, so an off-trip id is dropped rather than
attached and the tool cannot be used to enumerate users.
This commit is contained in:
Maurice
2026-08-28 12:01:24 +02:00
committed by Maurice
parent 4b21a70ab2
commit 86c0c197db
3 changed files with 203 additions and 1 deletions
@@ -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.',
@@ -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<number> {
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);
});
});
});
+1
View File
@@ -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