import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; import { canAccessTrip } from '../db/database'; import { broadcast } from '../websocket'; import { isDemoUser } from '../services/authService'; import { listTrips, createTrip, updateTrip, deleteTrip, getTripSummary, isOwner, verifyTripAccess, } from '../services/tripService'; import { listPlaces, createPlace, updatePlace, deletePlace } from '../services/placeService'; import { listCategories } from '../services/categoryService'; import { dayExists, placeExists, createAssignment, assignmentExistsInDay, deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime, } from '../services/assignmentService'; import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService'; import { createItem as createPackingItem, updateItem as updatePackingItem, deleteItem as deletePackingItem } from '../services/packingService'; import { createReservation, getReservation, updateReservation, deleteReservation } from '../services/reservationService'; import { getDay, updateDay, validateAccommodationRefs } from '../services/dayService'; import { createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote, deleteNote as deleteDayNote, dayExists as dayNoteExists } from '../services/dayNoteService'; import { createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote } from '../services/collabService'; import { markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem, } from '../services/atlasService'; const MAX_MCP_TRIP_DAYS = 90; function demoDenied() { return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true }; } function noAccess() { return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true }; } function ok(data: unknown) { return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; } export function registerTools(server: McpServer, userId: number): void { // --- TRIPS --- 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)'), }, }, 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 }); } ); 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(), }, }, async ({ tripId, title, description, start_date, end_date, currency }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); 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 }, 'user'); broadcast(tripId, 'trip:updated', { trip: updatedTrip }); return ok({ trip: updatedTrip }); } ); server.registerTool( 'delete_trip', { description: 'Delete a trip. Only the trip owner can delete it.', inputSchema: { tripId: z.number().int().positive(), }, }, async ({ tripId }) => { if (isDemoUser(userId)) return demoDenied(); if (!isOwner(tripId, userId)) return noAccess(); deleteTrip(tripId, userId, 'user'); return ok({ success: true, tripId }); } ); 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)'), }, }, async ({ include_archived }) => { const trips = listTrips(userId, include_archived ? null : 0); return ok({ trips }); } ); // --- PLACES --- server.registerTool( 'create_place', { description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.', inputSchema: { tripId: z.number().int().positive(), name: z.string().min(1).max(200), description: z.string().max(2000).optional(), lat: z.number().optional(), lng: z.number().optional(), address: z.string().max(500).optional(), category_id: z.number().int().positive().optional().describe('Category ID — use list_categories to see available options'), google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345") — enables opening hours if no Google ID'), notes: z.string().max(2000).optional(), website: z.string().max(500).optional(), phone: z.string().max(50).optional(), }, }, async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }); broadcast(tripId, 'place:created', { place }); return ok({ place }); } ); server.registerTool( 'update_place', { description: 'Update an existing place in a trip.', inputSchema: { tripId: z.number().int().positive(), placeId: z.number().int().positive(), name: z.string().min(1).max(200).optional(), description: z.string().max(2000).optional(), lat: z.number().optional(), lng: z.number().optional(), address: z.string().max(500).optional(), notes: z.string().max(2000).optional(), website: z.string().max(500).optional(), phone: z.string().max(50).optional(), }, }, async ({ tripId, placeId, name, description, lat, lng, address, notes, website, phone }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, notes, website, phone }); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; broadcast(tripId, 'place:updated', { place }); return ok({ place }); } ); server.registerTool( 'delete_place', { description: 'Delete a place from a trip.', inputSchema: { tripId: z.number().int().positive(), placeId: z.number().int().positive(), }, }, async ({ tripId, placeId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const deleted = deletePlace(String(tripId), String(placeId)); if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; broadcast(tripId, 'place:deleted', { placeId }); return ok({ success: true }); } ); // --- CATEGORIES --- server.registerTool( 'list_categories', { description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.', inputSchema: {}, }, async () => { const categories = listCategories(); return ok({ categories }); } ); // --- SEARCH --- server.registerTool( 'search_place', { description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id if configured). Use these IDs when calling create_place so the app can display opening hours and ratings.', inputSchema: { query: z.string().min(1).max(500).describe('Place name or address to search for'), }, }, async ({ query }) => { // Use Nominatim (no API key needed, always available) const params = new URLSearchParams({ q: query, format: 'json', addressdetails: '1', limit: '5', 'accept-language': 'en', }); const response = await fetch(`https://nominatim.openstreetmap.org/search?${params}`, { headers: { 'User-Agent': 'TREK Travel Planner' }, }); if (!response.ok) { return { content: [{ type: 'text' as const, text: 'Search failed — Nominatim API error.' }], isError: true }; } const data = await response.json() as { osm_type: string; osm_id: number; name: string; display_name: string; lat: string; lon: string }[]; const places = data.map(item => ({ osm_id: `${item.osm_type}:${item.osm_id}`, name: item.name || item.display_name?.split(',')[0] || '', address: item.display_name || '', lat: parseFloat(item.lat) || null, lng: parseFloat(item.lon) || null, })); return ok({ places }); } ); // --- ASSIGNMENTS --- server.registerTool( 'assign_place_to_day', { description: 'Assign a place to a specific day in a trip.', inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), placeId: z.number().int().positive(), notes: z.string().max(500).optional(), }, }, async ({ tripId, dayId, placeId, notes }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; const assignment = createAssignment(dayId, placeId, notes || null); broadcast(tripId, 'assignment:created', { assignment }); return ok({ assignment }); } ); server.registerTool( 'unassign_place', { description: 'Remove a place assignment from a day.', inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), assignmentId: z.number().int().positive(), }, }, async ({ tripId, dayId, assignmentId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!assignmentExistsInDay(assignmentId, dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true }; deleteAssignment(assignmentId); broadcast(tripId, 'assignment:deleted', { assignmentId, dayId }); return ok({ success: true }); } ); // --- BUDGET --- server.registerTool( 'create_budget_item', { description: 'Add a budget/expense item to a trip.', inputSchema: { tripId: z.number().int().positive(), name: z.string().min(1).max(200), category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'), total_price: z.number().nonnegative(), note: z.string().max(500).optional(), }, }, async ({ tripId, name, category, total_price, note }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const item = createBudgetItem(tripId, { category, name, total_price, note }); broadcast(tripId, 'budget:created', { item }); return ok({ item }); } ); server.registerTool( 'delete_budget_item', { description: 'Delete a budget item from a trip.', inputSchema: { tripId: z.number().int().positive(), itemId: z.number().int().positive(), }, }, async ({ tripId, itemId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const deleted = deleteBudgetItem(itemId, tripId); if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true }; broadcast(tripId, 'budget:deleted', { itemId }); return ok({ success: true }); } ); // --- PACKING --- server.registerTool( 'create_packing_item', { description: 'Add an item to the packing checklist for a trip.', inputSchema: { tripId: z.number().int().positive(), name: z.string().min(1).max(200), category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'), }, }, async ({ tripId, name, category }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const item = createPackingItem(tripId, { name, category: category || 'General' }); broadcast(tripId, 'packing:created', { item }); return ok({ item }); } ); server.registerTool( 'toggle_packing_item', { description: 'Check or uncheck a packing item.', inputSchema: { tripId: z.number().int().positive(), itemId: z.number().int().positive(), checked: z.boolean(), }, }, async ({ tripId, itemId, checked }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']); if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; broadcast(tripId, 'packing:updated', { item }); return ok({ item }); } ); server.registerTool( 'delete_packing_item', { description: 'Remove an item from the packing checklist.', inputSchema: { tripId: z.number().int().positive(), itemId: z.number().int().positive(), }, }, async ({ tripId, itemId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const deleted = deletePackingItem(tripId, itemId); if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; broadcast(tripId, 'packing:deleted', { itemId }); return ok({ success: true }); } ); // --- RESERVATIONS --- server.registerTool( 'create_reservation', { description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/train/car/cruise/event/tour/activity/other → use assignment_id; flight → no linking.', inputSchema: { tripId: z.number().int().positive(), title: z.string().min(1).max(200), type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'), location: z.string().max(500).optional(), confirmation_number: z.string().max(100).optional(), notes: z.string().max(1000).optional(), day_id: z.number().int().positive().optional(), place_id: z.number().int().positive().optional().describe('Hotel place to link (hotel type only)'), start_day_id: z.number().int().positive().optional().describe('Check-in day (hotel type only; requires place_id and end_day_id)'), end_day_id: z.number().int().positive().optional().describe('Check-out day (hotel type only; requires place_id and start_day_id)'), check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'), check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'), assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'), }, }, async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); // Validate that all referenced IDs belong to this trip if (day_id && !getDay(day_id, tripId)) return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true }; if (place_id && !placeExists(place_id, tripId)) return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true }; if (start_day_id && !getDay(start_day_id, tripId)) return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true }; if (end_day_id && !getDay(end_day_id, tripId)) return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true }; if (assignment_id && !getAssignmentForTrip(assignment_id, tripId)) return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true }; const createAccommodation = (type === 'hotel' && place_id && start_day_id && end_day_id) ? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined } : undefined; const { reservation, accommodationCreated } = createReservation(tripId, { title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, create_accommodation: createAccommodation, }); if (accommodationCreated) { broadcast(tripId, 'accommodation:created', {}); } broadcast(tripId, 'reservation:created', { reservation }); return ok({ reservation }); } ); server.registerTool( 'delete_reservation', { description: 'Delete a reservation from a trip.', inputSchema: { tripId: z.number().int().positive(), reservationId: z.number().int().positive(), }, }, async ({ tripId, reservationId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId); if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true }; if (accommodationDeleted) { broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }); } broadcast(tripId, 'reservation:deleted', { reservationId }); return ok({ success: true }); } ); server.registerTool( 'link_hotel_accommodation', { description: 'Set or update the check-in/check-out day links for a hotel reservation. Creates or updates the accommodation record that ties the reservation to a place and a date range. Use the day IDs from get_trip_summary.', inputSchema: { tripId: z.number().int().positive(), reservationId: z.number().int().positive(), place_id: z.number().int().positive().describe('The hotel place to link'), start_day_id: z.number().int().positive().describe('Check-in day ID'), end_day_id: z.number().int().positive().describe('Check-out day ID'), check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00")'), check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'), }, }, async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const current = getReservation(reservationId, tripId); if (!current) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true }; if (current.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true }; if (!placeExists(place_id, tripId)) return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true }; if (!getDay(start_day_id, tripId)) return { content: [{ type: 'text' as const, text: 'start_day_id does not belong to this trip.' }], isError: true }; if (!getDay(end_day_id, tripId)) return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true }; const isNewAccommodation = !current.accommodation_id; const { reservation } = updateReservation(reservationId, tripId, { place_id, type: current.type, status: current.status as string, create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined }, }, current); broadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {}); broadcast(tripId, 'reservation:updated', { reservation }); return ok({ reservation, accommodation_id: (reservation as any).accommodation_id }); } ); // --- DAYS --- server.registerTool( 'update_assignment_time', { description: 'Set the start and/or end time for a place assignment on a day (e.g. "09:00", "11:30"). Pass null to clear a time.', inputSchema: { tripId: z.number().int().positive(), assignmentId: z.number().int().positive(), place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'), end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'), }, }, async ({ tripId, assignmentId, place_time, end_time }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const existing = getAssignmentForTrip(assignmentId, tripId); if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true }; const assignment = updateTime( assignmentId, place_time !== undefined ? place_time : (existing as any).assignment_time, end_time !== undefined ? end_time : (existing as any).assignment_end_time ); broadcast(tripId, 'assignment:updated', { assignment }); return ok({ assignment }); } ); server.registerTool( 'update_day', { description: 'Set the title of a day in a trip (e.g. "Arrival in Paris", "Free day").', inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), title: z.string().max(200).nullable().describe('Day title, or null to clear it'), }, }, async ({ tripId, dayId, title }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const current = getDay(dayId, tripId); if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; const updated = updateDay(dayId, current, title !== undefined ? { title } : {}); broadcast(tripId, 'day:updated', { day: updated }); return ok({ day: updated }); } ); // --- RESERVATIONS (update) --- server.registerTool( 'update_reservation', { description: 'Update an existing reservation in a trip. Use status "confirmed" to confirm a pending recommendation, or "pending" to revert it. Linking: hotel → use place_id to link to an accommodation place; restaurant/train/car/cruise/event/tour/activity/other → use assignment_id to link to a day assignment; flight → no linking.', inputSchema: { tripId: z.number().int().positive(), reservationId: z.number().int().positive(), title: z.string().min(1).max(200).optional(), type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional(), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'), location: z.string().max(500).optional(), confirmation_number: z.string().max(100).optional(), notes: z.string().max(1000).optional(), status: z.enum(['pending', 'confirmed', 'cancelled']).optional(), place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'), assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'), }, }, async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const existing = getReservation(reservationId, tripId); if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true }; if (place_id != null && !placeExists(place_id, tripId)) return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true }; if (assignment_id != null && !getAssignmentForTrip(assignment_id, tripId)) return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true }; const { reservation } = updateReservation(reservationId, tripId, { title, type, reservation_time, location, confirmation_number, notes, status, place_id: place_id !== undefined ? place_id ?? undefined : undefined, assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined, }, existing); broadcast(tripId, 'reservation:updated', { reservation }); return ok({ reservation }); } ); // --- BUDGET (update) --- server.registerTool( 'update_budget_item', { description: 'Update an existing budget/expense item in a trip.', inputSchema: { tripId: z.number().int().positive(), itemId: z.number().int().positive(), name: z.string().min(1).max(200).optional(), category: z.string().max(100).optional(), total_price: z.number().nonnegative().optional(), persons: z.number().int().positive().nullable().optional(), days: z.number().int().positive().nullable().optional(), note: z.string().max(500).nullable().optional(), }, }, async ({ tripId, itemId, name, category, total_price, persons, days, note }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note }); if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true }; broadcast(tripId, 'budget:updated', { item }); return ok({ item }); } ); // --- PACKING (update) --- server.registerTool( 'update_packing_item', { description: 'Rename a packing item or change its category.', inputSchema: { tripId: z.number().int().positive(), itemId: z.number().int().positive(), name: z.string().min(1).max(200).optional(), category: z.string().max(100).optional(), }, }, async ({ tripId, itemId, name, category }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined); const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys); if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; broadcast(tripId, 'packing:updated', { item }); return ok({ item }); } ); // --- REORDER --- server.registerTool( 'reorder_day_assignments', { description: 'Reorder places within a day by providing the assignment IDs in the desired order.', inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'), }, }, async ({ tripId, dayId, assignmentIds }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; reorderAssignments(dayId, assignmentIds); broadcast(tripId, 'assignment:reordered', { dayId, assignmentIds }); return ok({ success: true, dayId, order: assignmentIds }); } ); // --- 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 totals, packing stats, reservations, and collab notes. Use this as a context loader before planning or modifying a trip.', inputSchema: { tripId: z.number().int().positive(), }, }, async ({ tripId }) => { if (!canAccessTrip(tripId, userId)) return noAccess(); const summary = getTripSummary(tripId); if (!summary) return noAccess(); return ok(summary); } ); // --- BUCKET LIST --- server.registerTool( 'create_bucket_list_item', { description: 'Add a destination to your personal travel bucket list.', inputSchema: { name: z.string().min(1).max(200).describe('Destination or experience name'), lat: z.number().optional(), lng: z.number().optional(), country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'), notes: z.string().max(1000).optional(), }, }, async ({ name, lat, lng, country_code, notes }) => { if (isDemoUser(userId)) return demoDenied(); const item = createBucketItem(userId, { name, lat, lng, country_code, notes }); return ok({ item }); } ); server.registerTool( 'delete_bucket_list_item', { description: 'Remove an item from your travel bucket list.', inputSchema: { itemId: z.number().int().positive(), }, }, async ({ itemId }) => { if (isDemoUser(userId)) return demoDenied(); const deleted = deleteBucketItem(userId, itemId); if (!deleted) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true }; return ok({ success: true }); } ); // --- ATLAS --- server.registerTool( 'mark_country_visited', { description: 'Mark a country as visited in your Atlas.', inputSchema: { country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'), }, }, async ({ country_code }) => { if (isDemoUser(userId)) return demoDenied(); markCountryVisited(userId, country_code.toUpperCase()); return ok({ success: true, country_code: country_code.toUpperCase() }); } ); server.registerTool( 'unmark_country_visited', { description: 'Remove a country from your visited countries in Atlas.', inputSchema: { country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'), }, }, async ({ country_code }) => { if (isDemoUser(userId)) return demoDenied(); unmarkCountryVisited(userId, country_code.toUpperCase()); return ok({ success: true, country_code: country_code.toUpperCase() }); } ); // --- COLLAB NOTES --- server.registerTool( 'create_collab_note', { description: 'Create a shared collaborative note on a trip (visible to all trip members in the Collab tab).', inputSchema: { tripId: z.number().int().positive(), title: z.string().min(1).max(200), content: z.string().max(10000).optional(), category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'), }, }, async ({ tripId, title, content, category, color }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const note = createCollabNote(tripId, userId, { title, content, category, color }); broadcast(tripId, 'collab:note:created', { note }); return ok({ note }); } ); server.registerTool( 'update_collab_note', { description: 'Edit an existing collaborative note on a trip.', inputSchema: { tripId: z.number().int().positive(), noteId: z.number().int().positive(), title: z.string().min(1).max(200).optional(), content: z.string().max(10000).optional(), category: z.string().max(100).optional(), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'), pinned: z.boolean().optional().describe('Pin the note to the top'), }, }, async ({ tripId, noteId, title, content, category, color, pinned }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned }); if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; broadcast(tripId, 'collab:note:updated', { note }); return ok({ note }); } ); server.registerTool( 'delete_collab_note', { description: 'Delete a collaborative note from a trip.', inputSchema: { tripId: z.number().int().positive(), noteId: z.number().int().positive(), }, }, async ({ tripId, noteId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const deleted = deleteCollabNote(tripId, noteId); if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; broadcast(tripId, 'collab:note:deleted', { noteId }); return ok({ success: true }); } ); // --- DAY NOTES --- server.registerTool( 'create_day_note', { description: 'Add a note to a specific day in a trip.', inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), text: z.string().min(1).max(500), time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'), icon: z.string().optional().describe('Emoji icon for the note'), }, }, async ({ tripId, dayId, text, time, icon }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; const note = createDayNote(dayId, tripId, text, time, icon); broadcast(tripId, 'dayNote:created', { dayId, note }); return ok({ note }); } ); server.registerTool( 'update_day_note', { description: 'Edit an existing note on a specific day.', inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), noteId: z.number().int().positive(), text: z.string().min(1).max(500).optional(), time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'), icon: z.string().optional().describe('Emoji icon for the note'), }, }, async ({ tripId, dayId, noteId, text, time, icon }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const existing = getDayNote(noteId, dayId, tripId); if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon }); broadcast(tripId, 'dayNote:updated', { dayId, note }); return ok({ note }); } ); server.registerTool( 'delete_day_note', { description: 'Delete a note from a specific day.', inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive(), noteId: z.number().int().positive(), }, }, async ({ tripId, dayId, noteId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const note = getDayNote(noteId, dayId, tripId); if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; deleteDayNote(noteId); broadcast(tripId, 'dayNote:deleted', { noteId, dayId }); return ok({ success: true }); } ); }