feat(mcp): add, rename and delete trip guests

add_trip_member resolves an existing account by username or email, so a
travelling companion who never signed up could be put on a trip in the
planner and not through an assistant. A guest is assignable to budget
splits, packing, to-dos and day participants like any member, so leaving
them out of an import loses who was actually there.

Mirrors the owner-only /api/trips/:id/guests routes, including the
trip-scoped guard that keeps one trip's owner away from another trip's
guest and off a real member's user row.
This commit is contained in:
Maurice
2026-08-28 11:55:55 +02:00
committed by Maurice
parent 34522ad3bc
commit 4b21a70ab2
3 changed files with 369 additions and 2 deletions
+86
View File
@@ -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.',
@@ -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<number> {
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);
});
});
});
+4 -1
View File
@@ -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. |