mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
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:
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. |
|
||||
|
||||
|
||||
Reference in New Issue
Block a user