diff --git a/server/src/nest/trips/trips.mcp.ts b/server/src/nest/trips/trips.mcp.ts index e9af810f9..62939f95b 100644 --- a/server/src/nest/trips/trips.mcp.ts +++ b/server/src/nest/trips/trips.mcp.ts @@ -333,6 +333,92 @@ export class TripsMcp { return ok({ success: true }); } + // --- GUESTS (#1362) --- + // + // add_trip_member resolves an existing account by username or email, so a + // travelling companion who has never signed up could be put on a trip in the + // planner and not through an assistant. These three mirror the owner-only + // /api/trips/:id/guests routes (trip-members.controller.ts): a guest is a + // credential-less users row assignable to budget splits, packing and day + // participants exactly like a member, so importing a past trip no longer has + // to drop who was on it. + + @Tool({ + name: 'create_trip_guest', + description: 'Add a travelling companion who has no TREK account to a trip. Use this when the person cannot be found by username or email — a guest can be assigned to budget splits, packing items, to-dos and day participants like any member, but never signs in and is never emailed. Only the trip owner can do this.', + inputSchema: { + tripId: z.number().int().positive(), + name: z.string().min(1).max(50).describe('Display name of the guest, e.g. "Anna"'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + access: { group: 'trips', mode: 'write' }, + }) + async createTripGuest({ tripId, name }: { tripId: number; name: string }, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); + const ownerRow = this.trips.getOwner(tripId); + if (!ownerRow || ownerRow.user_id !== ctx.userId) + return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true }; + try { + // No notifyInvite: a guest has no inbox. + const { member } = this.members.createGuest(tripId, name, ctx.userId); + this.guards.safeBroadcast(tripId, 'member:added', { member }); + return ok({ member }); + } catch (err) { + const msg = err instanceof ValidationError ? err.message : 'Failed to create guest.'; + return { content: [{ type: 'text' as const, text: msg }], isError: true }; + } + } + + @Tool({ + name: 'rename_trip_guest', + description: 'Rename a guest on a trip. Only the trip owner can do this.', + inputSchema: { + tripId: z.number().int().positive(), + guestId: z.number().int().positive().describe('User ID of the guest, from list_trip_members'), + name: z.string().min(1).max(50).describe('New display name'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'trips', mode: 'write' }, + }) + async renameTripGuest({ tripId, guestId, name }: { tripId: number; guestId: number; name: string }, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); + const ownerRow = this.trips.getOwner(tripId); + if (!ownerRow || ownerRow.user_id !== ctx.userId) + return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true }; + try { + if (!this.members.renameGuest(tripId, guestId, name)) + return { content: [{ type: 'text' as const, text: 'Guest not found.' }], isError: true }; + } catch (err) { + const msg = err instanceof ValidationError ? err.message : 'Failed to rename guest.'; + return { content: [{ type: 'text' as const, text: msg }], isError: true }; + } + return ok({ success: true }); + } + + @Tool({ + name: 'delete_trip_guest', + description: 'Remove a guest from a trip. This deletes the guest outright — they exist only for this trip — and re-splits any expenses they were part of. Only the trip owner can do this.', + inputSchema: { + tripId: z.number().int().positive(), + guestId: z.number().int().positive().describe('User ID of the guest, from list_trip_members'), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + access: { group: 'trips', mode: 'write' }, + }) + async deleteTripGuest({ tripId, guestId }: { tripId: number; guestId: number }, ctx: McpContext) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.trips.canAccessTrip(tripId, ctx.userId)) return noAccess(); + const ownerRow = this.trips.getOwner(tripId); + if (!ownerRow || ownerRow.user_id !== ctx.userId) + return { content: [{ type: 'text' as const, text: 'Only the trip owner can manage guests.' }], isError: true }; + if (!this.members.deleteGuest(tripId, guestId)) + return { content: [{ type: 'text' as const, text: 'Guest not found.' }], isError: true }; + this.guards.safeBroadcast(tripId, 'member:removed', { userId: guestId }); + return ok({ success: true }); + } + @Tool({ name: 'copy_trip', description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items and to-dos are reset to unchecked. Returns the new trip.', diff --git a/server/tests/unit/mcp/tools-trip-members.test.ts b/server/tests/unit/mcp/tools-trip-members.test.ts index 66de9f59f..23ad93853 100644 --- a/server/tests/unit/mcp/tools-trip-members.test.ts +++ b/server/tests/unit/mcp/tools-trip-members.test.ts @@ -1,6 +1,7 @@ /** - * Unit tests for MCP trip member, copy, ICS, and share-link tools: + * Unit tests for MCP trip member, guest, copy, ICS, and share-link tools: * list_trip_members, add_trip_member, remove_trip_member, + * create_trip_guest, rename_trip_guest, delete_trip_guest, * copy_trip, export_trip_ics, get_share_link, create_share_link, delete_share_link. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; @@ -376,3 +377,280 @@ describe('Tool: delete_share_link', () => { }); }); }); + +// --------------------------------------------------------------------------- +// Guests (#1362) +// +// add_trip_member resolves an existing account, so these cover the companion who +// has none: created, renamed and deleted through the assistant, owner-gated the +// same way the /guests routes are, and trip-scoped so one trip's owner cannot +// reach another trip's guest. +// --------------------------------------------------------------------------- + +/** The guest id the tool reports back, for the rename/delete cases. */ +async function makeGuest(h: McpHarness, tripId: number, name: string): Promise { + const result = await h.client.callTool({ name: 'create_trip_guest', arguments: { tripId, name } }); + return (parseToolResult(result) as any).member.id; +} + +describe('Tool: create_trip_guest', () => { + it('creates a credential-less guest and returns the display name', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_trip_guest', + arguments: { tripId: trip.id, name: 'Anna' }, + }); + const data = parseToolResult(result) as any; + expect(data.member.username).toBe('Anna'); + expect(data.member.role).toBe('member'); + expect(data.member.is_guest).toBe(true); + + const row = testDb.prepare('SELECT is_guest, password_hash, display_name, email FROM users WHERE id = ?').get(data.member.id) as any; + expect(row.is_guest).toBe(1); + expect(row.password_hash).toBe(''); + expect(row.display_name).toBe('Anna'); + // A synthetic address on an undeliverable domain — a guest is never emailed. + expect(row.email).toMatch(/@guests\.invalid$/); + }); + }); + + it('joins the guest into the trip roster', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + await makeGuest(h, trip.id, 'Jake'); + const listed = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } }); + const data = parseToolResult(listed) as any; + expect(data.members.map((m: any) => m.username)).toContain('Jake'); + }); + }); + + it('broadcasts member:added event', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + await makeGuest(h, trip.id, 'Anna'); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:added', expect.any(Object)); + }); + }); + + it('rejects a whitespace-only name', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_trip_guest', + arguments: { tripId: trip.id, name: ' ' }, + }); + expect(result.isError).toBe(true); + expect(testDb.prepare('SELECT COUNT(*) AS n FROM users WHERE is_guest = 1').get()).toEqual({ n: 0 }); + }); + }); + + it('returns error when a member who is not the owner tries to add a guest', async () => { + const { user: owner } = createUser(testDb); + const { user: collaborator } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, collaborator.id); + await withHarness(collaborator.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_trip_guest', + arguments: { tripId: trip.id, name: 'Anna' }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns access denied for a non-member', async () => { + const { user: outsider } = createUser(testDb); + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(outsider.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_trip_guest', + arguments: { tripId: trip.id, name: 'Anna' }, + }); + 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: 'create_trip_guest', + arguments: { tripId: trip.id, name: 'Anna' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +describe('Tool: rename_trip_guest', () => { + it('renames the guest', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const guestId = await makeGuest(h, trip.id, 'Anna'); + const result = await h.client.callTool({ + name: 'rename_trip_guest', + arguments: { tripId: trip.id, guestId, name: 'Anna B.' }, + }); + expect((parseToolResult(result) as any).success).toBe(true); + const row = testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guestId) as any; + expect(row.display_name).toBe('Anna B.'); + }); + }); + + it('rejects a whitespace-only name', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const guestId = await makeGuest(h, trip.id, 'Anna'); + const result = await h.client.callTool({ + name: 'rename_trip_guest', + arguments: { tripId: trip.id, guestId, name: ' ' }, + }); + expect(result.isError).toBe(true); + const row = testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guestId) as any; + expect(row.display_name).toBe('Anna'); + }); + }); + + it('will not rename a guest 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 guestId = await makeGuest(h, otherTrip.id, 'Anna'); + const result = await h.client.callTool({ + name: 'rename_trip_guest', + arguments: { tripId: ownTrip.id, guestId, name: 'Renamed' }, + }); + expect(result.isError).toBe(true); + const row = testDb.prepare('SELECT display_name FROM users WHERE id = ?').get(guestId) as any; + expect(row.display_name).toBe('Anna'); + }); + }); + + it('returns error when a member who is not the owner tries to rename', async () => { + const { user: owner } = createUser(testDb); + const { user: collaborator } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, collaborator.id); + let guestId = 0; + await withHarness(owner.id, async (h) => { guestId = await makeGuest(h, trip.id, 'Anna'); }); + await withHarness(collaborator.id, async (h) => { + const result = await h.client.callTool({ + name: 'rename_trip_guest', + arguments: { tripId: trip.id, guestId, name: 'Renamed' }, + }); + 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: 'rename_trip_guest', + arguments: { tripId: trip.id, guestId: 1, name: 'Anna' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +describe('Tool: delete_trip_guest', () => { + it('deletes the guest outright', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const guestId = await makeGuest(h, trip.id, 'Anna'); + const result = await h.client.callTool({ + name: 'delete_trip_guest', + arguments: { tripId: trip.id, guestId }, + }); + expect((parseToolResult(result) as any).success).toBe(true); + expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guestId)).toBeUndefined(); + expect(testDb.prepare('SELECT user_id FROM trip_members WHERE user_id = ?').get(guestId)).toBeUndefined(); + }); + }); + + it('broadcasts member:removed event', async () => { + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const guestId = await makeGuest(h, trip.id, 'Anna'); + broadcastMock.mockClear(); + await h.client.callTool({ name: 'delete_trip_guest', arguments: { tripId: trip.id, guestId } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:removed', expect.objectContaining({ userId: guestId })); + }); + }); + + it('will not delete a guest 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 guestId = await makeGuest(h, otherTrip.id, 'Anna'); + const result = await h.client.callTool({ + name: 'delete_trip_guest', + arguments: { tripId: ownTrip.id, guestId }, + }); + expect(result.isError).toBe(true); + expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guestId)).toBeDefined(); + }); + }); + + it('will not delete a real member through the guest route', async () => { + const { user: owner } = createUser(testDb); + const { user: collaborator } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, collaborator.id); + await withHarness(owner.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_trip_guest', + arguments: { tripId: trip.id, guestId: collaborator.id }, + }); + expect(result.isError).toBe(true); + expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(collaborator.id)).toBeDefined(); + }); + }); + + it('returns error when a member who is not the owner tries to delete', async () => { + const { user: owner } = createUser(testDb); + const { user: collaborator } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, collaborator.id); + let guestId = 0; + await withHarness(owner.id, async (h) => { guestId = await makeGuest(h, trip.id, 'Anna'); }); + await withHarness(collaborator.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_trip_guest', + arguments: { tripId: trip.id, guestId }, + }); + expect(result.isError).toBe(true); + expect(testDb.prepare('SELECT id FROM users WHERE id = ?').get(guestId)).toBeDefined(); + }); + }); + + 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: 'delete_trip_guest', + arguments: { tripId: trip.id, guestId: 1 }, + }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 145dfde27..341c4451d 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -37,6 +37,9 @@ Requires `trips:read` or `trips:write` scope. | `list_trip_members` | List the owner and all collaborators of a trip. | | `add_trip_member` | Add a user to a trip by username or email. Owner only. | | `remove_trip_member` | Remove a collaborator from a trip. Owner only. | +| `create_trip_guest` | Add a travelling companion who has no TREK account. Assignable to budget splits, packing and day participants; never signs in, never emailed. Owner only. | +| `rename_trip_guest` | Rename a guest on a trip. Owner only. | +| `delete_trip_guest` | Delete a guest and re-split the expenses they were part of. Owner only. | | `copy_trip` | Duplicate a trip (days, places, itinerary, packing, budget, reservations). Packing items reset to unchecked. | | `export_trip_ics` | Export the trip itinerary and reservations as iCalendar (`.ics`) text. | | `get_share_link` | Get the current public share link for a trip and its permission flags. Requires `trips:share`. | @@ -105,7 +108,7 @@ Requires `reservations:write` scope. | Tool | Description | |---|---| -| `create_transport` | Create a transport booking (`flight`, `train`, `car`, `cruise`) with optional multi-stop endpoints, departure/arrival times, and confirmation details. | +| `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. | | `update_transport` | Update an existing transport booking. Pass `endpoints[]` to replace all stops. | | `delete_transport` | Delete a transport booking from a trip. |