Spaces:
Sleeping
Sleeping
| 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 }); | |
| } | |
| ); | |
| } | |