import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; import { canAccessTrip } from '../../db/database'; import { isDemoUser } from '../../services/authService'; import { listTrips, createTrip, updateTrip, deleteTrip, getTripSummary, isOwner, verifyTripAccess, listMembers as listTripMembers, getTripOwner, addMember as addTripMember, removeMember as removeTripMember, copyTripById, exportICS, NotFoundError, ValidationError, } from '../../services/tripService'; import { createOrUpdateShareLink, getShareLink, deleteShareLink, } from '../../services/shareService'; import { isAddonEnabled, getCollabFeatures } from '../../services/adminService'; import { ADDON_IDS } from '../../addons'; import { countMessages, listPolls } from '../../services/collabService'; import { listItems as listTodoItems, } from '../../services/todoService'; import { safeBroadcast, MAX_MCP_TRIP_DAYS, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, demoDenied, noAccess, ok, hasTripPermission, permissionDenied, } from './_shared'; import { canRead, canReadTrips, canWrite, canDeleteTrips, canShareTrips } from '../scopes'; export function registerTripTools(server: McpServer, userId: number, scopes: string[] | null, getDeprecationNotice: () => string | null = () => null): void { const R = canReadTrips(scopes); const W = canWrite(scopes, 'trips'); const D = canDeleteTrips(scopes); const S = canShareTrips(scopes); // --- TRIPS --- if (W) server.registerTool( 'create_trip', { description: 'Create a new trip. Returns the created trip with its generated days.', inputSchema: { title: z.string().min(1).max(200).describe('Trip title'), description: z.string().max(2000).optional().describe('Trip description'), start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Start date (YYYY-MM-DD)'), end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'), currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, async ({ title, description, start_date, end_date, currency }) => { if (isDemoUser(userId)) return demoDenied(); if (start_date) { const d = new Date(start_date + 'T00:00:00Z'); if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date) return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true }; } if (end_date) { const d = new Date(end_date + 'T00:00:00Z'); if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date) return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true }; } if (start_date && end_date && new Date(end_date) < new Date(start_date)) { return { content: [{ type: 'text' as const, text: 'End date must be after start date.' }], isError: true }; } const { trip } = createTrip(userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS); return ok({ trip }); } ); if (W) server.registerTool( 'update_trip', { description: 'Update an existing trip\'s details.', inputSchema: { tripId: z.number().int().positive(), title: z.string().min(1).max(200).optional(), description: z.string().max(2000).optional(), start_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), currency: z.string().length(3).optional(), is_archived: z.boolean().optional().describe('Archive (true) or unarchive (false) the trip'), cover_image: z.string().optional().describe('Cover image path, e.g. /uploads/covers/abc.jpg'), }, annotations: TOOL_ANNOTATIONS_WRITE, }, async ({ tripId, title, description, start_date, end_date, currency, is_archived, cover_image }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!hasTripPermission('trip_edit', tripId, userId)) return permissionDenied(); if (start_date) { const d = new Date(start_date + 'T00:00:00Z'); if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== start_date) return { content: [{ type: 'text' as const, text: 'start_date is not a valid calendar date.' }], isError: true }; } if (end_date) { const d = new Date(end_date + 'T00:00:00Z'); if (isNaN(d.getTime()) || d.toISOString().slice(0, 10) !== end_date) return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true }; } const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency, is_archived, cover_image }, 'user'); safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip }); return ok({ trip: updatedTrip }); } ); if (D) server.registerTool( 'delete_trip', { description: 'Delete a trip. Only the trip owner can delete it.', inputSchema: { tripId: z.number().int().positive(), }, annotations: TOOL_ANNOTATIONS_DELETE, }, async ({ tripId }) => { if (isDemoUser(userId)) return demoDenied(); if (!isOwner(tripId, userId)) return noAccess(); deleteTrip(tripId, userId, 'user'); return ok({ success: true, tripId }); } ); // list_trips and get_trip_summary are always registered regardless of OAuth scopes — // they are navigation tools that any MCP client needs to discover trip IDs. server.registerTool( 'list_trips', { description: 'List all trips the current user owns or is a member of. Use this for trip discovery before calling get_trip_summary.', inputSchema: { include_archived: z.boolean().optional().describe('Include archived trips (default false)'), }, annotations: TOOL_ANNOTATIONS_READONLY, }, async ({ include_archived }) => { const notice = getDeprecationNotice(); const trips = listTrips(userId, include_archived ? null : 0); if (notice) return { isError: true as const, content: [ { type: 'text' as const, text: notice }, { type: 'text' as const, text: JSON.stringify({ trips }, null, 2) }, ], }; return ok({ trips }); } ); // --- TRIP SUMMARY --- server.registerTool( 'get_trip_summary', { description: 'Get a full denormalized summary of a trip in a single call: metadata, members, days with assignments and notes, accommodations, budget line items (when enabled), packing list (when enabled), reservations, collab notes and poll/message counts (when enabled), and to-do items (when enabled). Use this as a context loader before planning or modifying a trip.', inputSchema: { tripId: z.number().int().positive(), }, annotations: TOOL_ANNOTATIONS_READONLY, }, async ({ tripId }) => { if (!canAccessTrip(tripId, userId)) return noAccess(); const summary = getTripSummary(tripId); if (!summary) return noAccess(); // Addon availability gates const packingEnabled = isAddonEnabled(ADDON_IDS.PACKING); const budgetEnabled = isAddonEnabled(ADDON_IDS.BUDGET); const collabEnabled = isAddonEnabled(ADDON_IDS.COLLAB); const collabFeatures = collabEnabled ? getCollabFeatures() : null; // Scope gates — sections not covered by the client's OAuth scopes are omitted. // Core trip data (metadata, days, members, accommodations) is always included // because this tool is always registered and needed for navigation. const canReadBudget = budgetEnabled && canRead(scopes, 'budget'); const canReadPacking = packingEnabled && canRead(scopes, 'packing'); const canReadCollab = collabEnabled && canRead(scopes, 'collab'); const canReadTodos = packingEnabled && canRead(scopes, 'todos'); const canReadRes = canRead(scopes, 'reservations'); const todos = canReadTodos ? listTodoItems(tripId) : []; let pollCount = 0; let messageCount = 0; if (canReadCollab) { if (collabFeatures?.polls) pollCount = listPolls(tripId).length; if (collabFeatures?.chat) messageCount = countMessages(tripId); } const notice = getDeprecationNotice(); const summaryData = { ...summary, reservations: canReadRes ? summary.reservations : undefined, packing: canReadPacking ? summary.packing : undefined, budget: canReadBudget ? summary.budget : undefined, collab_notes: canReadCollab && collabFeatures?.notes ? summary.collab_notes : [], todos, pollCount, messageCount, }; if (notice) return { isError: true as const, content: [ { type: 'text' as const, text: notice }, { type: 'text' as const, text: JSON.stringify(summaryData, null, 2) }, ], }; return ok(summaryData); } ); // --- TRIP MEMBERS, COPY, ICS, SHARE --- if (R) server.registerTool( 'list_trip_members', { description: 'List all members of a trip (owner + collaborators).', inputSchema: { tripId: z.number().int().positive(), }, annotations: TOOL_ANNOTATIONS_READONLY, }, async ({ tripId }) => { if (!canAccessTrip(tripId, userId)) return noAccess(); const ownerRow = getTripOwner(tripId); if (!ownerRow) return noAccess(); const { owner, members } = listTripMembers(tripId, ownerRow.user_id); return ok({ owner, members }); } ); if (W) server.registerTool( 'add_trip_member', { description: 'Add a user to a trip by their username or email address. Only the trip owner can do this.', inputSchema: { tripId: z.number().int().positive(), identifier: z.string().min(1).describe('Username or email of the user to add'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, async ({ tripId, identifier }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const ownerRow = getTripOwner(tripId); if (!ownerRow || ownerRow.user_id !== userId) return { content: [{ type: 'text' as const, text: 'Only the trip owner can add members.' }], isError: true }; try { const result = addTripMember(tripId, identifier, ownerRow.user_id, userId); safeBroadcast(tripId, 'member:added', { member: result.member }); return ok({ member: result.member }); } catch (err) { const msg = err instanceof ValidationError || err instanceof NotFoundError ? err.message : 'Failed to add member.'; return { content: [{ type: 'text' as const, text: msg }], isError: true }; } } ); if (W) server.registerTool( 'remove_trip_member', { description: 'Remove a member from a trip. Only the trip owner can do this.', inputSchema: { tripId: z.number().int().positive(), memberId: z.number().int().positive().describe('User ID of the member to remove'), }, annotations: TOOL_ANNOTATIONS_DELETE, }, async ({ tripId, memberId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const ownerRow = getTripOwner(tripId); if (!ownerRow || ownerRow.user_id !== userId) return { content: [{ type: 'text' as const, text: 'Only the trip owner can remove members.' }], isError: true }; removeTripMember(tripId, memberId); safeBroadcast(tripId, 'member:removed', { userId: memberId }); return ok({ success: true }); } ); if (W) server.registerTool( 'copy_trip', { description: 'Duplicate a trip (all days, places, itinerary, packing, budget, reservations, day notes). Packing items are reset to unchecked. Returns the new trip.', inputSchema: { tripId: z.number().int().positive().describe('Source trip ID to duplicate'), title: z.string().min(1).max(200).optional().describe('Title for the new trip (defaults to source title)'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, async ({ tripId, title }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); try { const newTripId = copyTripById(tripId, userId, title); const newTrip = canAccessTrip(newTripId, userId); return ok({ trip: { id: newTripId, ...newTrip } }); } catch { return { content: [{ type: 'text' as const, text: 'Failed to copy trip.' }], isError: true }; } } ); if (R) server.registerTool( 'export_trip_ics', { description: 'Export a trip\'s itinerary and reservations as iCalendar (.ics) format text. Useful for importing into calendar apps.', inputSchema: { tripId: z.number().int().positive(), }, annotations: TOOL_ANNOTATIONS_READONLY, }, async ({ tripId }) => { if (!canAccessTrip(tripId, userId)) return noAccess(); try { const { ics, filename } = exportICS(tripId); return ok({ ics, filename }); } catch { return { content: [{ type: 'text' as const, text: 'Trip not found.' }], isError: true }; } } ); if (S) server.registerTool( 'get_share_link', { description: 'Get the current public share link for a trip, including its permission flags. Returns null if no share link exists.', inputSchema: { tripId: z.number().int().positive(), }, annotations: TOOL_ANNOTATIONS_READONLY, }, async ({ tripId }) => { // Read parity with the REST route GET /api/trips/:tripId/share-link, which // only requires trip membership (share_manage gates create/delete, not read). if (!canAccessTrip(tripId, userId)) return noAccess(); const link = getShareLink(String(tripId)); return ok({ link }); } ); if (S) server.registerTool( 'create_share_link', { description: 'Create or update the public share link for a trip. Set permission flags to control what is visible to guests.', inputSchema: { tripId: z.number().int().positive(), share_map: z.boolean().optional().default(true).describe('Share the map and places'), share_bookings: z.boolean().optional().default(true).describe('Share reservations'), share_packing: z.boolean().optional().default(false).describe('Share packing list'), share_budget: z.boolean().optional().default(false).describe('Share budget'), share_collab: z.boolean().optional().default(false).describe('Share collab messages'), }, annotations: TOOL_ANNOTATIONS_WRITE, }, async ({ tripId, share_map, share_bookings, share_packing, share_budget, share_collab }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!hasTripPermission('share_manage', tripId, userId)) return permissionDenied(); const { token, created } = createOrUpdateShareLink(String(tripId), userId, { share_map: share_map ?? true, share_bookings: share_bookings ?? true, share_packing: share_packing ?? false, share_budget: share_budget ?? false, share_collab: share_collab ?? false, }); return ok({ token, created }); } ); if (S) server.registerTool( 'delete_share_link', { description: 'Revoke the public share link for a trip. Guests will no longer be able to access the shared view.', inputSchema: { tripId: z.number().int().positive(), }, annotations: TOOL_ANNOTATIONS_DELETE, }, async ({ tripId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!hasTripPermission('share_manage', tripId, userId)) return permissionDenied(); deleteShareLink(String(tripId)); return ok({ success: true }); } ); }