From 63784d86a37b1d19bb29297e52f8f88e88b9c9d3 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 9 Apr 2026 18:09:08 +0200 Subject: [PATCH] refactor(mcp): extract all MCP tools into dedicated modules and add shared helpers --- server/src/mcp/resources.ts | 176 ++- server/src/mcp/tools.ts | 1124 +---------------- server/src/mcp/tools/_shared.ts | 51 + server/src/mcp/tools/assignments.ts | 175 +++ server/src/mcp/tools/atlas.ts | 192 +++ server/src/mcp/tools/budget.ts | 131 ++ server/src/mcp/tools/collab.ts | 268 ++++ server/src/mcp/tools/days.ts | 229 ++++ server/src/mcp/tools/files.ts | 231 ++++ server/src/mcp/tools/mapsWeather.ts | 109 ++ server/src/mcp/tools/notifications.ts | 145 +++ server/src/mcp/tools/packing.ts | 326 +++++ server/src/mcp/tools/places.ts | 158 +++ server/src/mcp/tools/prompts.ts | 116 ++ server/src/mcp/tools/reservations.ts | 203 +++ server/src/mcp/tools/tags.ts | 78 ++ server/src/mcp/tools/todos.ts | 185 +++ server/src/mcp/tools/trips.ts | 338 +++++ server/src/mcp/tools/vacay.ts | 393 ++++++ server/src/routes/trips.ts | 156 +-- server/src/services/collabService.ts | 5 + server/src/services/tripService.ts | 152 +++ server/tests/helpers/factories.ts | 26 + ...ols-assignments-reservations-extra.test.ts | 244 ++++ .../unit/mcp/tools-atlas-expanded.test.ts | 313 +++++ .../unit/mcp/tools-budget-advanced.test.ts | 213 ++++ .../unit/mcp/tools-collab-polls-chat.test.ts | 500 ++++++++ .../mcp/tools-days-accommodations.test.ts | 294 +++++ server/tests/unit/mcp/tools-files.test.ts | 456 +++++++ .../unit/mcp/tools-notifications.test.ts | 338 +++++ .../unit/mcp/tools-packing-advanced.test.ts | 459 +++++++ .../unit/mcp/tools-tags-maps-weather.test.ts | 312 +++++ server/tests/unit/mcp/tools-todos.test.ts | 438 +++++++ .../tests/unit/mcp/tools-trip-members.test.ts | 378 ++++++ server/tests/unit/mcp/tools-trips.test.ts | 14 + server/tests/unit/mcp/tools-vacay.test.ts | 477 +++++++ 36 files changed, 8154 insertions(+), 1249 deletions(-) create mode 100644 server/src/mcp/tools/_shared.ts create mode 100644 server/src/mcp/tools/assignments.ts create mode 100644 server/src/mcp/tools/atlas.ts create mode 100644 server/src/mcp/tools/budget.ts create mode 100644 server/src/mcp/tools/collab.ts create mode 100644 server/src/mcp/tools/days.ts create mode 100644 server/src/mcp/tools/files.ts create mode 100644 server/src/mcp/tools/mapsWeather.ts create mode 100644 server/src/mcp/tools/notifications.ts create mode 100644 server/src/mcp/tools/packing.ts create mode 100644 server/src/mcp/tools/places.ts create mode 100644 server/src/mcp/tools/prompts.ts create mode 100644 server/src/mcp/tools/reservations.ts create mode 100644 server/src/mcp/tools/tags.ts create mode 100644 server/src/mcp/tools/todos.ts create mode 100644 server/src/mcp/tools/trips.ts create mode 100644 server/src/mcp/tools/vacay.ts create mode 100644 server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts create mode 100644 server/tests/unit/mcp/tools-atlas-expanded.test.ts create mode 100644 server/tests/unit/mcp/tools-budget-advanced.test.ts create mode 100644 server/tests/unit/mcp/tools-collab-polls-chat.test.ts create mode 100644 server/tests/unit/mcp/tools-days-accommodations.test.ts create mode 100644 server/tests/unit/mcp/tools-files.test.ts create mode 100644 server/tests/unit/mcp/tools-notifications.test.ts create mode 100644 server/tests/unit/mcp/tools-packing-advanced.test.ts create mode 100644 server/tests/unit/mcp/tools-tags-maps-weather.test.ts create mode 100644 server/tests/unit/mcp/tools-todos.test.ts create mode 100644 server/tests/unit/mcp/tools-trip-members.test.ts create mode 100644 server/tests/unit/mcp/tools-vacay.test.ts diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index 806422e6..537e2c4f 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -3,13 +3,18 @@ import { canAccessTrip } from '../db/database'; import { listTrips, getTrip, getTripOwner, listMembers } from '../services/tripService'; import { listDays, listAccommodations } from '../services/dayService'; import { listPlaces } from '../services/placeService'; -import { listBudgetItems } from '../services/budgetService'; -import { listItems as listPackingItems } from '../services/packingService'; +import { listBudgetItems, getPerPersonSummary, calculateSettlement } from '../services/budgetService'; +import { listItems as listPackingItems, listBags } from '../services/packingService'; import { listReservations } from '../services/reservationService'; import { listNotes as listDayNotes } from '../services/dayNoteService'; -import { listNotes as listCollabNotes } from '../services/collabService'; +import { listNotes as listCollabNotes, listPolls, listMessages } from '../services/collabService'; +import { listItems as listTodoItems } from '../services/todoService'; +import { listFiles } from '../services/fileService'; import { listCategories } from '../services/categoryService'; -import { listBucketList, listVisitedCountries } from '../services/atlasService'; +import { listBucketList, listVisitedCountries, getStats as getAtlasStats, listManuallyVisitedRegions } from '../services/atlasService'; +import { getNotifications } from '../services/inAppNotifications'; +import { getActivePlanId, getActivePlan, getPlanData, getEntries as getVacayEntries, getHolidays } from '../services/vacayService'; +import { isAddonEnabled } from '../services/adminService'; function parseId(value: string | string[]): number | null { const n = Number(Array.isArray(value) ? value[0] : value); @@ -183,6 +188,32 @@ export function registerResources(server: McpServer, userId: number): void { } ); + // Trip files (active, not trash) + server.registerResource( + 'trip-files', + new ResourceTemplate('trek://trips/{tripId}/files', { list: undefined }), + { description: 'Active files attached to a trip (excludes trash)', mimeType: 'application/json' }, + async (uri, { tripId }) => { + const id = parseId(tripId); + if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); + const files = listFiles(id, false); + return jsonContent(uri.href, files); + } + ); + + // Trip to-do list + server.registerResource( + 'trip-todos', + new ResourceTemplate('trek://trips/{tripId}/todos', { list: undefined }), + { description: 'To-do items for a trip, ordered by position', mimeType: 'application/json' }, + async (uri, { tripId }) => { + const id = parseId(tripId); + if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); + const items = listTodoItems(id); + return jsonContent(uri.href, items); + } + ); + // All place categories (global, no trip filter) server.registerResource( 'categories', @@ -215,4 +246,141 @@ export function registerResources(server: McpServer, userId: number): void { return jsonContent(uri.href, countries); } ); + + // Budget per-person summary + server.registerResource( + 'trip-budget-per-person', + new ResourceTemplate('trek://trips/{tripId}/budget/per-person', { list: undefined }), + { description: 'Per-person budget summary for a trip (total spent per member, split breakdown)', mimeType: 'application/json' }, + async (uri, { tripId }) => { + const id = parseId(tripId); + if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); + const summary = getPerPersonSummary(id); + return jsonContent(uri.href, summary); + } + ); + + // Budget settlement + server.registerResource( + 'trip-budget-settlement', + new ResourceTemplate('trek://trips/{tripId}/budget/settlement', { list: undefined }), + { description: 'Suggested settlement transactions to balance who owes whom', mimeType: 'application/json' }, + async (uri, { tripId }) => { + const id = parseId(tripId); + if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); + const settlement = calculateSettlement(id); + return jsonContent(uri.href, settlement); + } + ); + + // Packing bags + server.registerResource( + 'trip-packing-bags', + new ResourceTemplate('trek://trips/{tripId}/packing/bags', { list: undefined }), + { description: 'All packing bags for a trip with their members', mimeType: 'application/json' }, + async (uri, { tripId }) => { + const id = parseId(tripId); + if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); + const bags = listBags(id); + return jsonContent(uri.href, bags); + } + ); + + // In-app notifications + server.registerResource( + 'notifications-in-app', + 'trek://notifications/in-app', + { description: "The current user's in-app notifications (most recent 50, unread first)", mimeType: 'application/json' }, + async (uri) => { + const result = getNotifications(userId, { limit: 50 }); + return jsonContent(uri.href, result); + } + ); + + // Atlas stats and regions (addon-gated) + if (isAddonEnabled('atlas')) { + server.registerResource( + 'atlas-stats', + 'trek://atlas/stats', + { description: "User's atlas statistics — visited country counts and breakdown", mimeType: 'application/json' }, + async (uri) => { + const stats = await getAtlasStats(userId); + return jsonContent(uri.href, stats); + } + ); + + server.registerResource( + 'atlas-regions', + 'trek://atlas/regions', + { description: 'List of manually visited regions for the current user', mimeType: 'application/json' }, + async (uri) => { + const regions = listManuallyVisitedRegions(userId); + return jsonContent(uri.href, regions); + } + ); + } + + // Collab polls & messages (addon-gated) + if (isAddonEnabled('collab')) { + server.registerResource( + 'trip-collab-polls', + new ResourceTemplate('trek://trips/{tripId}/collab/polls', { list: undefined }), + { description: 'All polls for a trip with vote counts per option', mimeType: 'application/json' }, + async (uri, { tripId }) => { + const id = parseId(tripId); + if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); + const polls = listPolls(id); + return jsonContent(uri.href, polls); + } + ); + + server.registerResource( + 'trip-collab-messages', + new ResourceTemplate('trek://trips/{tripId}/collab/messages', { list: undefined }), + { description: 'Most recent 100 chat messages for a trip', mimeType: 'application/json' }, + async (uri, { tripId }) => { + const id = parseId(tripId); + if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); + const messages = listMessages(id); + return jsonContent(uri.href, messages); + } + ); + } + + // Vacay resources (addon-gated) + if (isAddonEnabled('vacay')) { + server.registerResource( + 'vacay-plan', + 'trek://vacay/plan', + { description: "Full snapshot of the user's active vacation plan (members, years, settings)", mimeType: 'application/json' }, + async (uri) => { + const plan = getPlanData(userId); + return jsonContent(uri.href, plan); + } + ); + + server.registerResource( + 'vacay-entries', + new ResourceTemplate('trek://vacay/entries/{year}', { list: undefined }), + { description: 'All vacation entries for the active plan and a specific year', mimeType: 'application/json' }, + async (uri, { year }) => { + const planId = getActivePlanId(userId); + const entries = getVacayEntries(planId, Array.isArray(year) ? year[0] : year); + return jsonContent(uri.href, entries); + } + ); + + server.registerResource( + 'vacay-holidays', + new ResourceTemplate('trek://vacay/holidays/{year}', { list: undefined }), + { description: "Cached public holidays for the plan's configured region and year", mimeType: 'application/json' }, + async (uri, { year }) => { + const plan = getActivePlan(userId); + if (!plan.holidays_enabled || !plan.holidays_region) return jsonContent(uri.href, []); + const yearStr = Array.isArray(year) ? year[0] : year; + const result = await getHolidays(yearStr, plan.holidays_region); + return jsonContent(uri.href, result.data ?? []); + } + ); + } } diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index dc2f9dd6..4df78dc2 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -1,1111 +1,51 @@ 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, listItems as listPackingItems } 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'; -import { searchPlaces } from '../services/mapsService'; - -function safeBroadcast(tripId: number, event: string, payload: Record): void { - try { - broadcast(tripId, event, payload); - } catch (err) { - console.error(`[MCP] broadcast failed for ${event}:`, err?.message ?? err); - } -} - -const MAX_MCP_TRIP_DAYS = 90; - -const TOOL_ANNOTATIONS_READONLY = { - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, -} as const; - -const TOOL_ANNOTATIONS_WRITE = { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: true, - openWorldHint: false, -} as const; - -const TOOL_ANNOTATIONS_DELETE = { - readOnlyHint: false, - destructiveHint: true, - idempotentHint: true, - openWorldHint: false, -} as const; - -const TOOL_ANNOTATIONS_NON_IDEMPOTENT = { - readOnlyHint: false, - destructiveHint: false, - idempotentHint: false, - openWorldHint: false, -} as const; - -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) }] }; -} +import { registerTodoTools } from './tools/todos'; +import { registerFileTools } from './tools/files'; +import { registerAssignmentTools } from './tools/assignments'; +import { registerReservationTools } from './tools/reservations'; +import { registerTagTools } from './tools/tags'; +import { registerMapsWeatherTools } from './tools/mapsWeather'; +import { registerNotificationTools } from './tools/notifications'; +import { registerAtlasTools } from './tools/atlas'; +import { registerPlaceTools } from './tools/places'; +import { registerDayTools } from './tools/days'; +import { registerBudgetTools } from './tools/budget'; +import { registerPackingTools } from './tools/packing'; +import { registerCollabTools } from './tools/collab'; +import { registerTripTools } from './tools/trips'; +import { registerVacayTools } from './tools/vacay'; +import { registerMcpPrompts } from './tools/prompts'; export function registerTools(server: McpServer, userId: number): void { - // --- TRIPS --- + registerTripTools(server, userId); - 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 }); - } - ); + registerPlaceTools(server, userId); - 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(), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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'); - safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip }); - return ok({ trip: updatedTrip }); - } - ); + registerBudgetTools(server, userId); - 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 }); - } - ); + registerPackingTools(server, userId); - 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 trips = listTrips(userId, include_archived ? null : 0); - return ok({ trips }); - } - ); + registerReservationTools(server, userId); - // --- PLACES --- + registerDayTools(server, userId); - 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(), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - 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 }); - safeBroadcast(tripId, 'place:created', { place }); - return ok({ place }); - } - ); + registerAssignmentTools(server, userId); - 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(), - category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'), - price: z.number().optional(), - currency: z.string().length(3).optional(), - place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'), - end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'), - duration_minutes: z.number().int().positive().optional(), - notes: z.string().max(2000).optional(), - website: z.string().max(500).optional(), - phone: z.string().max(50).optional(), - transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(), - osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'), - google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }); - if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; - safeBroadcast(tripId, 'place:updated', { place }); - return ok({ place }); - } - ); + registerTagTools(server, userId); - server.registerTool( - 'delete_place', - { - description: 'Delete a place from a trip.', - inputSchema: { - tripId: z.number().int().positive(), - placeId: z.number().int().positive(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - 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 }; - safeBroadcast(tripId, 'place:deleted', { placeId }); - return ok({ success: true }); - } - ); + registerMapsWeatherTools(server, userId); - server.registerTool( - 'list_places', - { - description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.', - inputSchema: { - tripId: z.number().int().positive(), - search: z.string().optional(), - category: z.string().optional(), - tag: z.string().optional(), - assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all').describe('Filter by assignment status: "all" (default), "unassigned" (not on any day), or "assigned" (scheduled on a day)'), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ tripId, search, category, tag, assignment }) => { - if (!canAccessTrip(tripId, userId)) return noAccess(); - const places = listPlaces(String(tripId), { search, category, tag, assignment }); - return ok({ places }); - } - ); + registerNotificationTools(server, userId); - // --- CATEGORIES --- + registerAtlasTools(server, userId); - 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: {}, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async () => { - const categories = listCategories(); - return ok({ categories }); - } - ); + registerCollabTools(server, userId); - // --- SEARCH --- + registerFileTools(server, userId); - 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'), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - async ({ query }) => { - try { - const result = await searchPlaces(userId, query); - return ok(result); - } catch { - return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true }; - } - } - ); + registerVacayTools(server, userId); - // --- ASSIGNMENTS --- + registerTodoTools(server, userId); - 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(), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - 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); - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - 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); - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - 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 }); - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - 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 }; - safeBroadcast(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)'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, name, category }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - const item = createPackingItem(tripId, { name, category: category || 'General' }); - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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 }; - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - 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 }; - safeBroadcast(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']).describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "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)'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - 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) { - safeBroadcast(tripId, 'accommodation:created', {}); - } - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - 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) { - safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }); - } - safeBroadcast(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")'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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); - - safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {}); - safeBroadcast(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'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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 - ); - safeBroadcast(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'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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 } : {}); - safeBroadcast(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().describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "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(), - status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'), - 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'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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); - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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 }; - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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 }; - safeBroadcast(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'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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); - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_READONLY, - }, - 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(), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - 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(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - 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")'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - 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'), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - 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'), - pinned: z.boolean().optional().default(false).describe('Pin the note to the top'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - async ({ tripId, title, content, category, color, pinned }) => { - if (isDemoUser(userId)) return demoDenied(); - if (!canAccessTrip(tripId, userId)) return noAccess(); - const note = createCollabNote(tripId, userId, { title, content, category, color, pinned }); - safeBroadcast(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'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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 }; - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - 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 }; - safeBroadcast(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'), - }, - annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, - }, - 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); - safeBroadcast(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'), - }, - annotations: TOOL_ANNOTATIONS_WRITE, - }, - 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 }); - safeBroadcast(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(), - }, - annotations: TOOL_ANNOTATIONS_DELETE, - }, - 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); - safeBroadcast(tripId, 'dayNote:deleted', { noteId, dayId }); - return ok({ success: true }); - } - ); - - // --- PROMPTS --- - - server.registerPrompt( - 'trip-summary', - { - title: 'Trip Summary', - description: 'Load a full summary of a trip for context before planning or modifications', - argsSchema: { - tripId: z.number().int().positive().describe('Trip ID to summarize'), - }, - }, - async ({ tripId }) => { - if (!canAccessTrip(tripId, userId)) { - return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] }; - } - const summary = getTripSummary(tripId); - if (!summary) { - return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] }; - } - const { trip, days, members, budget, packing, reservations, collabNotes } = summary; - const packingStats = packing ? { total: packing.length, packed: packing.filter((p: any) => p.checked).length } : { total: 0, packed: 0 }; - const budgetTotal = budget?.reduce((sum: number, b: any) => sum + (b.total_price || 0), 0) || 0; - const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''} -Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'} -Members: ${members?.length || 0} (${members?.map((m: any) => m.name || m.email).join(', ') || 'none'}) -Days: ${days?.length || 0} -Packing: ${packingStats.packed}/${packingStats.total} items packed -Budget: ${budgetTotal} ${trip?.currency || 'EUR'} total -Reservations: ${reservations?.length || 0} -Collab Notes: ${collabNotes?.length || 0} -${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`; - return { - description: `Summary of trip "${trip?.title || tripId}"`, - messages: [{ role: 'user', content: { type: 'text', text } }], - }; - } - ); - - server.registerPrompt( - 'packing-list', - { - title: 'Packing List', - description: 'Get a formatted packing checklist for a trip', - argsSchema: { - tripId: z.number().int().positive().describe('Trip ID'), - }, - }, - async ({ tripId }) => { - if (!canAccessTrip(tripId, userId)) { - return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] }; - } - const items = listPackingItems(tripId); - if (!items.length) { - return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] }; - } - const grouped = items.reduce((acc: Record, item: any) => { - const cat = item.category || 'General'; - if (!acc[cat]) acc[cat] = []; - acc[cat].push(item); - return acc; - }, {}); - const lines = Object.entries(grouped).map(([cat, items]) => - `## ${cat}\n${(items as any[]).map((i: any) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}` - ).join('\n\n'); - const { trip } = getTripSummary(tripId) || {}; - return { - description: `Packing list for "${trip?.title || tripId}"`, - messages: [{ role: 'user', content: { type: 'text', text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }], - }; - } - ); - - server.registerPrompt( - 'budget-overview', - { - title: 'Budget Overview', - description: 'Get a formatted budget summary for a trip', - argsSchema: { - tripId: z.number().int().positive().describe('Trip ID'), - }, - }, - async ({ tripId }) => { - if (!canAccessTrip(tripId, userId)) { - return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] }; - } - const summary = getTripSummary(tripId); - if (!summary) { - return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] }; - } - const { trip, budget } = summary; - const currency = trip?.currency || 'EUR'; - const byCategory = (budget || []).reduce((acc: Record, item: any) => { - const cat = item.category || 'Uncategorized'; - acc[cat] = (acc[cat] || 0) + (item.total_price || 0); - return acc; - }, {} as Record); - const total = Object.values(byCategory).reduce((s, v) => s + v, 0); - const lines = Object.entries(byCategory) - .sort(([, a], [, b]) => b - a) - .map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`) - .join('\n'); - const perPerson = (summary.members?.length || 1) > 0 ? (total / (summary.members?.length || 1)).toFixed(2) : total.toFixed(2); - return { - description: `Budget overview for "${trip?.title || tripId}"`, - messages: [{ role: 'user', content: { type: 'text', text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }], - }; - } - ); + registerMcpPrompts(server, userId); } diff --git a/server/src/mcp/tools/_shared.ts b/server/src/mcp/tools/_shared.ts new file mode 100644 index 00000000..4978aa74 --- /dev/null +++ b/server/src/mcp/tools/_shared.ts @@ -0,0 +1,51 @@ +import { broadcast } from '../../websocket'; + +export function safeBroadcast(tripId: number, event: string, payload: Record): void { + try { + broadcast(tripId, event, payload); + } catch (err) { + console.error(`[MCP] broadcast failed for ${event}:`, err?.message ?? err); + } +} + +export const MAX_MCP_TRIP_DAYS = 90; + +export const TOOL_ANNOTATIONS_READONLY = { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const; + +export const TOOL_ANNOTATIONS_WRITE = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, +} as const; + +export const TOOL_ANNOTATIONS_DELETE = { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: false, +} as const; + +export const TOOL_ANNOTATIONS_NON_IDEMPOTENT = { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: false, +} as const; + +export function demoDenied() { + return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true }; +} + +export function noAccess() { + return { content: [{ type: 'text' as const, text: 'Trip not found or access denied.' }], isError: true }; +} + +export function ok(data: unknown) { + return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; +} diff --git a/server/src/mcp/tools/assignments.ts b/server/src/mcp/tools/assignments.ts new file mode 100644 index 00000000..c164439e --- /dev/null +++ b/server/src/mcp/tools/assignments.ts @@ -0,0 +1,175 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import { + dayExists, placeExists, createAssignment, assignmentExistsInDay, + deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime, + moveAssignment, + getParticipants as getAssignmentParticipants, + setParticipants as setAssignmentParticipants, +} from '../../services/assignmentService'; +import { getDay } from '../../services/dayService'; +import { + safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, noAccess, ok, +} from './_shared'; + +export function registerAssignmentTools(server: McpServer, userId: number): void { + // --- 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(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + 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); + safeBroadcast(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(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + 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); + safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId }); + return ok({ success: true }); + } + ); + + 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'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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 + ); + safeBroadcast(tripId, 'assignment:updated', { assignment }); + return ok({ assignment }); + } + ); + + server.registerTool( + 'move_assignment', + { + description: 'Move a place assignment to a different day.', + inputSchema: { + tripId: z.number().int().positive(), + assignmentId: z.number().int().positive(), + newDayId: z.number().int().positive(), + oldDayId: z.number().int().positive(), + orderIndex: z.number().int().min(0).optional().default(0), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, assignmentId, newDayId, oldDayId, orderIndex }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const result = moveAssignment(assignmentId, newDayId, orderIndex ?? 0, oldDayId); + safeBroadcast(tripId, 'assignment:moved', { assignment: result.assignment, oldDayId: result.oldDayId }); + return ok({ assignment: result.assignment }); + } + ); + + server.registerTool( + 'get_assignment_participants', + { + description: 'Get the list of users participating in a specific place assignment.', + inputSchema: { + tripId: z.number().int().positive(), + assignmentId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId, assignmentId }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const participants = getAssignmentParticipants(assignmentId); + return ok({ participants }); + } + ); + + server.registerTool( + 'set_assignment_participants', + { + description: 'Set the participants for a place assignment (replaces current list).', + inputSchema: { + tripId: z.number().int().positive(), + assignmentId: z.number().int().positive(), + userIds: z.array(z.number().int().positive()).describe('User IDs to set as participants; empty array clears all'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, assignmentId, userIds }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const participants = setAssignmentParticipants(assignmentId, userIds); + safeBroadcast(tripId, 'assignment:participants', { assignmentId, participants }); + return ok({ participants }); + } + ); + + // --- 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'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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); + safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds }); + return ok({ success: true, dayId, order: assignmentIds }); + } + ); +} diff --git a/server/src/mcp/tools/atlas.ts b/server/src/mcp/tools/atlas.ts new file mode 100644 index 00000000..4eb5d737 --- /dev/null +++ b/server/src/mcp/tools/atlas.ts @@ -0,0 +1,192 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { isDemoUser } from '../../services/authService'; +import { + markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem, + getStats as getAtlasStats, listManuallyVisitedRegions, + markRegionVisited, unmarkRegionVisited, getCountryPlaces, updateBucketItem, +} from '../../services/atlasService'; +import { isAddonEnabled } from '../../services/adminService'; +import { + TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + TOOL_ANNOTATIONS_READONLY, + demoDenied, ok, +} from './_shared'; + +export function registerAtlasTools(server: McpServer, userId: number): void { + // --- 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(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + 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(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + 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")'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + 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'), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ country_code }) => { + if (isDemoUser(userId)) return demoDenied(); + unmarkCountryVisited(userId, country_code.toUpperCase()); + return ok({ success: true, country_code: country_code.toUpperCase() }); + } + ); + + // --- ATLAS EXPANDED --- + + if (isAddonEnabled('atlas')) { + server.registerTool( + 'get_atlas_stats', + { + description: 'Get atlas statistics — total visited countries, region counts, continent breakdown.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const stats = await getAtlasStats(userId); + return ok({ stats }); + } + ); + + server.registerTool( + 'list_visited_regions', + { + description: 'List all manually visited sub-country regions for the current user.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const regions = listManuallyVisitedRegions(userId); + return ok({ regions }); + } + ); + + server.registerTool( + 'mark_region_visited', + { + description: 'Mark a sub-country region as visited.', + inputSchema: { + regionCode: z.string().describe('ISO region code e.g. US-CA'), + regionName: z.string(), + countryCode: z.string().describe('ISO 3166-1 alpha-2 country code'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ regionCode, regionName, countryCode }) => { + if (isDemoUser(userId)) return demoDenied(); + markRegionVisited(userId, regionCode, regionName, countryCode); + const region = listManuallyVisitedRegions(userId).find(r => r.region_code === regionCode); + return ok({ region }); + } + ); + + server.registerTool( + 'unmark_region_visited', + { + description: 'Remove a region from the visited list.', + inputSchema: { + regionCode: z.string(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ regionCode }) => { + if (isDemoUser(userId)) return demoDenied(); + unmarkRegionVisited(userId, regionCode); + return ok({ success: true }); + } + ); + + server.registerTool( + 'get_country_atlas_places', + { + description: 'Get places saved in the user\'s atlas for a specific country.', + inputSchema: { + countryCode: z.string().describe('ISO 3166-1 alpha-2 country code'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ countryCode }) => { + const result = getCountryPlaces(userId, countryCode); + return ok(result); + } + ); + + server.registerTool( + 'update_bucket_list_item', + { + description: 'Update a bucket list item (notes, name, target date, location).', + inputSchema: { + itemId: z.number().int().positive(), + name: z.string().optional(), + notes: z.string().optional(), + lat: z.number().nullable().optional(), + lng: z.number().nullable().optional(), + country_code: z.string().optional(), + target_date: z.string().nullable().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ itemId, name, notes, lat, lng, country_code, target_date }) => { + if (isDemoUser(userId)) return demoDenied(); + const item = updateBucketItem(userId, itemId, { name, notes, lat, lng, country_code, target_date }); + if (!item) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true }; + return ok({ item }); + } + ); + } +} diff --git a/server/src/mcp/tools/budget.ts b/server/src/mcp/tools/budget.ts new file mode 100644 index 00000000..cd75be46 --- /dev/null +++ b/server/src/mcp/tools/budget.ts @@ -0,0 +1,131 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import { + createBudgetItem, updateBudgetItem, deleteBudgetItem, + updateMembers as updateBudgetMembers, + toggleMemberPaid, +} from '../../services/budgetService'; +import { + safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, noAccess, ok, +} from './_shared'; + +export function registerBudgetTools(server: McpServer, userId: number): void { + // --- 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(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + 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 }); + safeBroadcast(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(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + 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 }; + safeBroadcast(tripId, 'budget:deleted', { itemId }); + return ok({ success: true }); + } + ); + + // --- 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(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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 }; + safeBroadcast(tripId, 'budget:updated', { item }); + return ok({ item }); + } + ); + + // --- BUDGET ADVANCED --- + + server.registerTool( + 'set_budget_item_members', + { + description: 'Set which trip members are splitting a budget item (replaces current member list).', + inputSchema: { + tripId: z.number().int().positive(), + itemId: z.number().int().positive(), + userIds: z.array(z.number().int().positive()).describe('User IDs splitting this item; empty array clears all'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, itemId, userIds }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const item = updateBudgetMembers(itemId, tripId, userIds); + safeBroadcast(tripId, 'budget:members-updated', { item }); + return ok({ item }); + } + ); + + server.registerTool( + 'toggle_budget_member_paid', + { + description: 'Mark or unmark a member as having paid their share of a budget item.', + inputSchema: { + tripId: z.number().int().positive(), + itemId: z.number().int().positive(), + memberId: z.number().int().positive().describe('User ID of the member'), + paid: z.boolean(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, itemId, memberId, paid }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const member = toggleMemberPaid(itemId, memberId, paid); + safeBroadcast(tripId, 'budget:member-paid-updated', { itemId, member }); + return ok({ member }); + } + ); +} diff --git a/server/src/mcp/tools/collab.ts b/server/src/mcp/tools/collab.ts new file mode 100644 index 00000000..3c35adfa --- /dev/null +++ b/server/src/mcp/tools/collab.ts @@ -0,0 +1,268 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import { + createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote, + listPolls, createPoll, votePoll, closePoll, deletePoll, + listMessages, createMessage, deleteMessage, addOrRemoveReaction, +} from '../../services/collabService'; +import { isAddonEnabled } from '../../services/adminService'; +import { + safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_NON_IDEMPOTENT, TOOL_ANNOTATIONS_READONLY, + demoDenied, noAccess, ok, +} from './_shared'; + +export function registerCollabTools(server: McpServer, userId: number): void { + // --- 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'), + pinned: z.boolean().optional().default(false).describe('Pin the note to the top'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, title, content, category, color, pinned }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const note = createCollabNote(tripId, userId, { title, content, category, color, pinned }); + safeBroadcast(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'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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 }; + safeBroadcast(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(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + 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 }; + safeBroadcast(tripId, 'collab:note:deleted', { noteId }); + return ok({ success: true }); + } + ); + + // --- COLLAB POLLS & CHAT --- + + if (isAddonEnabled('collab')) { + server.registerTool( + 'list_collab_polls', + { + description: 'List all polls for a trip.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const polls = listPolls(tripId); + return ok({ polls }); + } + ); + + server.registerTool( + 'create_collab_poll', + { + description: 'Create a new poll in the collab panel.', + inputSchema: { + tripId: z.number().int().positive(), + question: z.string().min(1), + options: z.array(z.string()).min(2).describe('Poll answer options (at least 2)'), + multiple: z.boolean().optional().describe('Allow multiple choice'), + deadline: z.string().optional().describe('ISO date string for poll deadline'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, question, options, multiple, deadline }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const poll = createPoll(tripId, userId, { question, options, multiple, deadline }); + safeBroadcast(tripId, 'collab:poll:created', { poll }); + return ok({ poll }); + } + ); + + server.registerTool( + 'vote_collab_poll', + { + description: 'Vote on a poll option (or remove vote if already voted for that option).', + inputSchema: { + tripId: z.number().int().positive(), + pollId: z.number().int().positive(), + optionIndex: z.number().int().min(0).describe('Zero-based index of the option to vote for'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, pollId, optionIndex }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const result = votePoll(tripId, pollId, userId, optionIndex); + if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true }; + safeBroadcast(tripId, 'collab:poll:voted', { poll: result.poll }); + return ok({ poll: result.poll }); + } + ); + + server.registerTool( + 'close_collab_poll', + { + description: 'Close a poll so no more votes can be cast.', + inputSchema: { + tripId: z.number().int().positive(), + pollId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, pollId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const poll = closePoll(tripId, pollId); + if (!poll) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true }; + safeBroadcast(tripId, 'collab:poll:closed', { poll }); + return ok({ poll }); + } + ); + + server.registerTool( + 'delete_collab_poll', + { + description: 'Delete a poll and all its votes.', + inputSchema: { + tripId: z.number().int().positive(), + pollId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, pollId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const deleted = deletePoll(tripId, pollId); + if (!deleted) return { content: [{ type: 'text' as const, text: 'Poll not found.' }], isError: true }; + safeBroadcast(tripId, 'collab:poll:deleted', { pollId }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'list_collab_messages', + { + description: 'List chat messages for a trip (most recent 100, oldest-first).', + inputSchema: { + tripId: z.number().int().positive(), + before: z.number().int().positive().optional().describe('Load messages with ID less than this (pagination)'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId, before }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const messages = listMessages(tripId, before); + return ok({ messages }); + } + ); + + server.registerTool( + 'send_collab_message', + { + description: "Send a chat message to a trip's collab channel.", + inputSchema: { + tripId: z.number().int().positive(), + text: z.string().min(1), + replyTo: z.number().int().positive().optional().describe('Reply to a specific message ID'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, text, replyTo }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const result = createMessage(tripId, userId, text, replyTo ?? null); + if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true }; + safeBroadcast(tripId, 'collab:message:created', { message: result.message }); + return ok({ message: result.message }); + } + ); + + server.registerTool( + 'delete_collab_message', + { + description: 'Delete a chat message (only the message owner can delete their own messages).', + inputSchema: { + tripId: z.number().int().positive(), + messageId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, messageId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const result = deleteMessage(tripId, messageId, userId); + if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true }; + safeBroadcast(tripId, 'collab:message:deleted', { messageId, username: result.username }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'react_collab_message', + { + description: 'Toggle a reaction emoji on a chat message (adds if not present, removes if already reacted).', + inputSchema: { + tripId: z.number().int().positive(), + messageId: z.number().int().positive(), + emoji: z.string().describe('Single emoji character'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, messageId, emoji }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const result = addOrRemoveReaction(messageId, tripId, userId, emoji); + if (!result.found) return { content: [{ type: 'text' as const, text: 'Message not found.' }], isError: true }; + safeBroadcast(tripId, 'collab:message:reacted', { messageId, reactions: result.reactions }); + return ok({ reactions: result.reactions }); + } + ); + } +} diff --git a/server/src/mcp/tools/days.ts b/server/src/mcp/tools/days.ts new file mode 100644 index 00000000..f213731f --- /dev/null +++ b/server/src/mcp/tools/days.ts @@ -0,0 +1,229 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import { + getDay, updateDay, validateAccommodationRefs, + createDay, deleteDay, + createAccommodation, getAccommodation, updateAccommodation, deleteAccommodation, +} from '../../services/dayService'; +import { + createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote, + deleteNote as deleteDayNote, dayExists as dayNoteExists, +} from '../../services/dayNoteService'; +import { + safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, noAccess, ok, +} from './_shared'; + +export function registerDayTools(server: McpServer, userId: number): void { + // --- DAYS --- + + 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'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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 } : {}); + safeBroadcast(tripId, 'day:updated', { day: updated }); + return ok({ day: updated }); + } + ); + + server.registerTool( + 'create_day', + { + description: 'Add a new day to a trip (optionally with a specific date and notes).', + inputSchema: { + tripId: z.number().int().positive(), + date: z.string().optional().describe('ISO date string YYYY-MM-DD, optional for dateless trips'), + notes: z.string().optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, date, notes }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const day = createDay(tripId, date, notes); + safeBroadcast(tripId, 'day:created', { day }); + return ok({ day }); + } + ); + + server.registerTool( + 'delete_day', + { + description: 'Delete a day from a trip.', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, dayId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + deleteDay(dayId); + safeBroadcast(tripId, 'day:deleted', { id: dayId }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'create_accommodation', + { + description: 'Add an accommodation (hotel, Airbnb, etc.) to a trip, linked to a place and a date range.', + inputSchema: { + tripId: z.number().int().positive(), + place_id: z.number().int().positive().describe('The place to use as the accommodation'), + 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"'), + confirmation: z.string().max(100).optional(), + notes: z.string().max(1000).optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const errors = validateAccommodationRefs(tripId, place_id, start_day_id, end_day_id); + if (errors.length > 0) return { content: [{ type: 'text' as const, text: errors.map(e => e.message).join(', ') }], isError: true }; + const accommodation = createAccommodation(tripId, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }); + safeBroadcast(tripId, 'accommodation:created', { accommodation }); + return ok({ accommodation }); + } + ); + + server.registerTool( + 'update_accommodation', + { + description: 'Update fields on an existing accommodation.', + inputSchema: { + tripId: z.number().int().positive(), + accommodationId: z.number().int().positive(), + place_id: z.number().int().positive().optional(), + start_day_id: z.number().int().positive().optional(), + end_day_id: z.number().int().positive().optional(), + check_in: z.string().max(10).optional(), + check_out: z.string().max(10).optional(), + confirmation: z.string().max(100).optional(), + notes: z.string().max(1000).optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, accommodationId, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const existing = getAccommodation(accommodationId, tripId); + if (!existing) return { content: [{ type: 'text' as const, text: 'Accommodation not found.' }], isError: true }; + const accommodation = updateAccommodation(accommodationId, existing, { place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes }); + safeBroadcast(tripId, 'accommodation:updated', { accommodation }); + return ok({ accommodation }); + } + ); + + server.registerTool( + 'delete_accommodation', + { + description: 'Delete an accommodation from a trip.', + inputSchema: { + tripId: z.number().int().positive(), + accommodationId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, accommodationId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const { linkedReservationId } = deleteAccommodation(accommodationId); + safeBroadcast(tripId, 'accommodation:deleted', { id: accommodationId, linkedReservationId }); + return ok({ success: true, linkedReservationId }); + } + ); + + // --- 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'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + 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); + safeBroadcast(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'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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 }); + safeBroadcast(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(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + 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); + safeBroadcast(tripId, 'dayNote:deleted', { noteId, dayId }); + return ok({ success: true }); + } + ); +} diff --git a/server/src/mcp/tools/files.ts b/server/src/mcp/tools/files.ts new file mode 100644 index 00000000..e99eb5fa --- /dev/null +++ b/server/src/mcp/tools/files.ts @@ -0,0 +1,231 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import { + listFiles, getFileById, getDeletedFile, updateFile, toggleStarred, + softDeleteFile, restoreFile, permanentDeleteFile, emptyTrash, + createFileLink, deleteFileLink, getFileLinks, +} from '../../services/fileService'; +import { + safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + TOOL_ANNOTATIONS_DELETE, + demoDenied, noAccess, ok, +} from './_shared'; + +export function registerFileTools(server: McpServer, userId: number): void { + // --- FILES --- + + server.registerTool( + 'list_files', + { + description: 'List trip files. By default returns active files; set showTrash=true to list the trash instead.', + inputSchema: { + tripId: z.number().int().positive(), + showTrash: z.boolean().optional().default(false).describe('List trash instead of active files'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId, showTrash }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const files = listFiles(tripId, showTrash ?? false); + return ok({ files }); + } + ); + + server.registerTool( + 'update_file_metadata', + { + description: 'Update a file\'s metadata: description, linked place, or linked reservation.', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + description: z.string().max(1000).nullable().optional(), + place_id: z.number().int().positive().nullable().optional().describe('Link to a place; null to unlink'), + reservation_id: z.number().int().positive().nullable().optional().describe('Link to a reservation; null to unlink'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, fileId, description, place_id, reservation_id }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const file = getFileById(fileId, tripId); + if (!file) return { content: [{ type: 'text' as const, text: 'File not found.' }], isError: true }; + const updated = updateFile(fileId, file, { + description: description !== undefined ? (description ?? undefined) : undefined, + place_id: place_id !== undefined ? (place_id !== null ? String(place_id) : null) : undefined, + reservation_id: reservation_id !== undefined ? (reservation_id !== null ? String(reservation_id) : null) : undefined, + }); + safeBroadcast(tripId, 'file:updated', { file: updated }); + return ok({ file: updated }); + } + ); + + server.registerTool( + 'toggle_file_star', + { + description: 'Toggle the starred status of a file (starred files appear at the top).', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, fileId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const file = getFileById(fileId, tripId); + if (!file) return { content: [{ type: 'text' as const, text: 'File not found.' }], isError: true }; + const updated = toggleStarred(fileId, file.starred); + safeBroadcast(tripId, 'file:updated', { file: updated }); + return ok({ file: updated }); + } + ); + + server.registerTool( + 'trash_file', + { + description: 'Move a file to trash (soft delete). Recoverable with restore_file.', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, fileId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const file = getFileById(fileId, tripId); + if (!file) return { content: [{ type: 'text' as const, text: 'File not found.' }], isError: true }; + softDeleteFile(fileId); + safeBroadcast(tripId, 'file:deleted', { fileId }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'restore_file', + { + description: 'Restore a file from trash back to the active file list.', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, fileId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const file = getDeletedFile(fileId, tripId); + if (!file) return { content: [{ type: 'text' as const, text: 'File not found in trash.' }], isError: true }; + const restored = restoreFile(fileId); + safeBroadcast(tripId, 'file:created', { file: restored }); + return ok({ file: restored }); + } + ); + + server.registerTool( + 'permanent_delete_file', + { + description: 'Permanently delete a file from trash. This cannot be undone — the file is removed from disk.', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, fileId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const file = getDeletedFile(fileId, tripId); + if (!file) return { content: [{ type: 'text' as const, text: 'File not found in trash.' }], isError: true }; + permanentDeleteFile(file); + safeBroadcast(tripId, 'file:deleted', { fileId }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'empty_trash', + { + description: 'Permanently delete all files in the trash for a trip. Cannot be undone.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const deleted = emptyTrash(tripId); + return ok({ success: true, deleted }); + } + ); + + server.registerTool( + 'link_file', + { + description: 'Link a file to a place, reservation, or assignment. The file must belong to the trip.', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + place_id: z.number().int().positive().optional(), + reservation_id: z.number().int().positive().optional(), + assignment_id: z.number().int().positive().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, fileId, place_id, reservation_id, assignment_id }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const file = getFileById(fileId, tripId); + if (!file) return { content: [{ type: 'text' as const, text: 'File not found.' }], isError: true }; + const links = createFileLink(fileId, { + place_id: place_id ? String(place_id) : null, + reservation_id: reservation_id ? String(reservation_id) : null, + assignment_id: assignment_id ? String(assignment_id) : null, + }); + return ok({ success: true, links }); + } + ); + + server.registerTool( + 'unlink_file', + { + description: 'Remove a specific link between a file and a place/reservation/assignment. Use list_file_links to get the link ID.', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + linkId: z.number().int().positive().describe('ID of the file link to remove'), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, fileId, linkId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const file = getFileById(fileId, tripId); + if (!file) return { content: [{ type: 'text' as const, text: 'File not found.' }], isError: true }; + deleteFileLink(linkId, fileId); + return ok({ success: true }); + } + ); + + server.registerTool( + 'list_file_links', + { + description: 'List all entity links for a file (places, reservations, assignments it is attached to).', + inputSchema: { + tripId: z.number().int().positive(), + fileId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId, fileId }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const file = getFileById(fileId, tripId); + if (!file) return { content: [{ type: 'text' as const, text: 'File not found.' }], isError: true }; + const links = getFileLinks(fileId); + return ok({ links }); + } + ); +} diff --git a/server/src/mcp/tools/mapsWeather.ts b/server/src/mcp/tools/mapsWeather.ts new file mode 100644 index 00000000..0a4eb47a --- /dev/null +++ b/server/src/mcp/tools/mapsWeather.ts @@ -0,0 +1,109 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { searchPlaces, getPlaceDetails, reverseGeocode, resolveGoogleMapsUrl } from '../../services/mapsService'; +import { getWeather, getDetailedWeather } from '../../services/weatherService'; +import { + TOOL_ANNOTATIONS_READONLY, + ok, +} from './_shared'; + +export function registerMapsWeatherTools(server: McpServer, userId: number): void { + // --- MAPS EXTRAS --- + + server.registerTool( + 'get_place_details', + { + description: 'Fetch detailed information about a place by its Google Place ID.', + inputSchema: { + placeId: z.string().describe('Google Place ID'), + lang: z.string().optional().default('en'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ placeId, lang }) => { + const details = await getPlaceDetails(userId, placeId, lang ?? 'en'); + if (!details) return { content: [{ type: 'text' as const, text: 'Place not found or maps service not configured.' }], isError: true }; + return ok({ details }); + } + ); + + server.registerTool( + 'reverse_geocode', + { + description: 'Get a human-readable address for given coordinates.', + inputSchema: { + lat: z.number(), + lng: z.number(), + lang: z.string().optional().default('en'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ lat, lng, lang }) => { + const result = await reverseGeocode(String(lat), String(lng), lang ?? 'en'); + if (!result) return { content: [{ type: 'text' as const, text: 'Reverse geocode failed or maps service not configured.' }], isError: true }; + return ok(result); + } + ); + + server.registerTool( + 'resolve_maps_url', + { + description: 'Resolve a Google Maps share URL to coordinates and place name.', + inputSchema: { + url: z.string().describe('Google Maps share URL'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ url }) => { + const result = await resolveGoogleMapsUrl(url); + if (!result) return { content: [{ type: 'text' as const, text: 'Could not resolve URL or maps service not configured.' }], isError: true }; + return ok(result); + } + ); + + // --- WEATHER --- + + server.registerTool( + 'get_weather', + { + description: 'Get weather forecast for a location and date.', + inputSchema: { + lat: z.number(), + lng: z.number(), + date: z.string().describe('ISO date YYYY-MM-DD'), + lang: z.string().optional().default('en'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ lat, lng, date, lang }) => { + try { + const weather = await getWeather(String(lat), String(lng), date, lang ?? 'en'); + return ok({ weather }); + } catch (err: any) { + return { content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }], isError: true }; + } + } + ); + + server.registerTool( + 'get_detailed_weather', + { + description: 'Get hourly/detailed weather forecast for a location and date.', + inputSchema: { + lat: z.number(), + lng: z.number(), + date: z.string().describe('ISO date YYYY-MM-DD'), + lang: z.string().optional().default('en'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ lat, lng, date, lang }) => { + try { + const weather = await getDetailedWeather(String(lat), String(lng), date, lang ?? 'en'); + return ok({ weather }); + } catch (err: any) { + return { content: [{ type: 'text' as const, text: err?.message ?? 'Weather service not available.' }], isError: true }; + } + } + ); +} diff --git a/server/src/mcp/tools/notifications.ts b/server/src/mcp/tools/notifications.ts new file mode 100644 index 00000000..4b848737 --- /dev/null +++ b/server/src/mcp/tools/notifications.ts @@ -0,0 +1,145 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { isDemoUser } from '../../services/authService'; +import { + getNotifications, getUnreadCount, + markRead as markNotificationRead, markUnread as markNotificationUnread, + markAllRead, deleteNotification, deleteAll as deleteAllNotifications, + respondToBoolean, +} from '../../services/inAppNotifications'; +import { + TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, ok, +} from './_shared'; + +export function registerNotificationTools(server: McpServer, userId: number): void { + // --- NOTIFICATIONS --- + + server.registerTool( + 'list_notifications', + { + description: 'List in-app notifications for the current user.', + inputSchema: { + limit: z.number().int().positive().optional().default(20), + offset: z.number().int().min(0).optional().default(0), + unread_only: z.boolean().optional().default(false), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ limit, offset, unread_only }) => { + const result = getNotifications(userId, { limit: limit ?? 20, offset: offset ?? 0, unreadOnly: unread_only ?? false }); + return ok(result); + } + ); + + server.registerTool( + 'get_unread_notification_count', + { + description: 'Get the number of unread in-app notifications.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const count = getUnreadCount(userId); + return ok({ count }); + } + ); + + server.registerTool( + 'mark_notification_read', + { + description: 'Mark a single notification as read.', + inputSchema: { + notificationId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ notificationId }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = markNotificationRead(notificationId, userId); + if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true }; + return ok({ success: true }); + } + ); + + server.registerTool( + 'mark_notification_unread', + { + description: 'Mark a single notification as unread.', + inputSchema: { + notificationId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ notificationId }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = markNotificationUnread(notificationId, userId); + if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true }; + return ok({ success: true }); + } + ); + + server.registerTool( + 'mark_all_notifications_read', + { + description: "Mark all of the current user's notifications as read.", + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async () => { + if (isDemoUser(userId)) return demoDenied(); + const count = markAllRead(userId); + return ok({ success: true, count }); + } + ); + + server.registerTool( + 'delete_notification', + { + description: 'Delete a single in-app notification.', + inputSchema: { + notificationId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ notificationId }) => { + if (isDemoUser(userId)) return demoDenied(); + const success = deleteNotification(notificationId, userId); + if (!success) return { content: [{ type: 'text' as const, text: 'Notification not found.' }], isError: true }; + return ok({ success: true }); + } + ); + + server.registerTool( + 'delete_all_notifications', + { + description: "Delete all in-app notifications for the current user.", + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async () => { + if (isDemoUser(userId)) return demoDenied(); + const count = deleteAllNotifications(userId); + return ok({ success: true, count }); + } + ); + + server.registerTool( + 'respond_to_notification', + { + description: 'Respond to a boolean (yes/no) notification such as a trip invite or poll.', + inputSchema: { + notificationId: z.number().int().positive(), + response: z.enum(['positive', 'negative']), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ notificationId, response }) => { + if (isDemoUser(userId)) return demoDenied(); + const result = await respondToBoolean(notificationId, userId, response); + if (!result.success) return { content: [{ type: 'text' as const, text: result.error ?? 'Failed to respond.' }], isError: true }; + return ok({ notification: result.notification }); + } + ); +} diff --git a/server/src/mcp/tools/packing.ts b/server/src/mcp/tools/packing.ts new file mode 100644 index 00000000..06284a8c --- /dev/null +++ b/server/src/mcp/tools/packing.ts @@ -0,0 +1,326 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import { + createItem as createPackingItem, updateItem as updatePackingItem, + deleteItem as deletePackingItem, + reorderItems as reorderPackingItems, + listBags, createBag, updateBag, deleteBag, setBagMembers, + getCategoryAssignees as getPackingCategoryAssignees, + updateCategoryAssignees as updatePackingCategoryAssignees, + applyTemplate, saveAsTemplate, bulkImport, +} from '../../services/packingService'; +import { + safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, noAccess, ok, +} from './_shared'; + +export function registerPackingTools(server: McpServer, userId: number): void { + // --- 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)'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, name, category }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const item = createPackingItem(tripId, { name, category: category || 'General' }); + safeBroadcast(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(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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 }; + safeBroadcast(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(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + 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 }; + safeBroadcast(tripId, 'packing:deleted', { itemId }); + return ok({ success: true }); + } + ); + + // --- 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(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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 }; + safeBroadcast(tripId, 'packing:updated', { item }); + return ok({ item }); + } + ); + + // --- PACKING ADVANCED --- + + server.registerTool( + 'reorder_packing_items', + { + description: 'Set the display order of packing items within a trip.', + inputSchema: { + tripId: z.number().int().positive(), + orderedIds: z.array(z.number().int().positive()).describe('Packing item IDs in desired order'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, orderedIds }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + reorderPackingItems(tripId, orderedIds); + safeBroadcast(tripId, 'packing:reordered', { orderedIds }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'list_packing_bags', + { + description: 'List all packing bags for a trip.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const bags = listBags(tripId); + return ok({ bags }); + } + ); + + server.registerTool( + 'create_packing_bag', + { + description: 'Create a new packing bag (e.g. "Carry-on", "Checked bag").', + inputSchema: { + tripId: z.number().int().positive(), + name: z.string().min(1).max(100), + color: z.string().optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, name, color }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const bag = createBag(tripId, { name, color }); + safeBroadcast(tripId, 'packing:bag-created', { bag }); + return ok({ bag }); + } + ); + + server.registerTool( + 'update_packing_bag', + { + description: 'Rename or recolor a packing bag.', + inputSchema: { + tripId: z.number().int().positive(), + bagId: z.number().int().positive(), + name: z.string().optional(), + color: z.string().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, bagId, name, color }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const fields: Record = {}; + const bodyKeys: string[] = []; + if (name !== undefined) { fields.name = name; bodyKeys.push('name'); } + if (color !== undefined) { fields.color = color; bodyKeys.push('color'); } + const bag = updateBag(tripId, bagId, fields, bodyKeys); + safeBroadcast(tripId, 'packing:bag-updated', { bag }); + return ok({ bag }); + } + ); + + server.registerTool( + 'delete_packing_bag', + { + description: 'Delete a packing bag (items in the bag are unassigned, not deleted).', + inputSchema: { + tripId: z.number().int().positive(), + bagId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, bagId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + deleteBag(tripId, bagId); + safeBroadcast(tripId, 'packing:bag-deleted', { id: bagId }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'set_bag_members', + { + description: 'Assign trip members to a packing bag (determines who packs what bag).', + inputSchema: { + tripId: z.number().int().positive(), + bagId: z.number().int().positive(), + userIds: z.array(z.number().int().positive()), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, bagId, userIds }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + setBagMembers(tripId, bagId, userIds); + safeBroadcast(tripId, 'packing:bag-members-updated', { bagId, userIds }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'get_packing_category_assignees', + { + description: 'Get which trip members are assigned to each packing category.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const assignees = getPackingCategoryAssignees(tripId); + return ok({ assignees }); + } + ); + + server.registerTool( + 'set_packing_category_assignees', + { + description: 'Assign trip members to a packing category.', + inputSchema: { + tripId: z.number().int().positive(), + categoryName: z.string().min(1).max(100), + userIds: z.array(z.number().int().positive()), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, categoryName, userIds }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + updatePackingCategoryAssignees(tripId, categoryName, userIds); + safeBroadcast(tripId, 'packing:assignees', { categoryName, userIds }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'apply_packing_template', + { + description: 'Apply a packing template to a trip (adds items from the template).', + inputSchema: { + tripId: z.number().int().positive(), + templateId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, templateId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const applied = applyTemplate(tripId, templateId); + if (applied === null) return { content: [{ type: 'text' as const, text: 'Template not found.' }], isError: true }; + safeBroadcast(tripId, 'packing:template-applied', { templateId }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'save_packing_template', + { + description: 'Save the current packing list as a reusable template.', + inputSchema: { + tripId: z.number().int().positive(), + templateName: z.string().min(1).max(100), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, templateName }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + saveAsTemplate(tripId, userId, templateName); + return ok({ success: true }); + } + ); + + server.registerTool( + 'bulk_import_packing', + { + description: 'Import multiple packing items at once from a list.', + inputSchema: { + tripId: z.number().int().positive(), + items: z.array(z.object({ + name: z.string().min(1).max(200), + category: z.string().optional(), + quantity: z.number().int().positive().optional(), + })).min(1), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, items }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + bulkImport(tripId, items); + safeBroadcast(tripId, 'packing:updated', {}); + return ok({ success: true, count: items.length }); + } + ); +} diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts new file mode 100644 index 00000000..cc41411a --- /dev/null +++ b/server/src/mcp/tools/places.ts @@ -0,0 +1,158 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import { listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService'; +import { listCategories } from '../../services/categoryService'; +import { searchPlaces } from '../../services/mapsService'; +import { + safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, noAccess, ok, +} from './_shared'; + +export function registerPlaceTools(server: McpServer, userId: number): void { + // --- 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(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + 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 }); + safeBroadcast(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(), + category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'), + price: z.number().optional(), + currency: z.string().length(3).optional(), + place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'), + end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'), + duration_minutes: z.number().int().positive().optional(), + notes: z.string().max(2000).optional(), + website: z.string().max(500).optional(), + phone: z.string().max(50).optional(), + transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(), + osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'), + google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }); + if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; + safeBroadcast(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(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + 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 }; + safeBroadcast(tripId, 'place:deleted', { placeId }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'list_places', + { + description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.', + inputSchema: { + tripId: z.number().int().positive(), + search: z.string().optional(), + category: z.string().optional(), + tag: z.string().optional(), + assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all').describe('Filter by assignment status: "all" (default), "unassigned" (not on any day), or "assigned" (scheduled on a day)'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId, search, category, tag, assignment }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const places = listPlaces(String(tripId), { search, category, tag, assignment }); + return ok({ places }); + } + ); + + // --- 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: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + 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'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ query }) => { + try { + const result = await searchPlaces(userId, query); + return ok(result); + } catch { + return { content: [{ type: 'text' as const, text: 'Place search failed.' }], isError: true }; + } + } + ); +} diff --git a/server/src/mcp/tools/prompts.ts b/server/src/mcp/tools/prompts.ts new file mode 100644 index 00000000..7f640f16 --- /dev/null +++ b/server/src/mcp/tools/prompts.ts @@ -0,0 +1,116 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { getTripSummary } from '../../services/tripService'; +import { listItems as listPackingItems } from '../../services/packingService'; + +export function registerMcpPrompts(server: McpServer, _userId: number): void { + const userId = _userId; + + server.registerPrompt( + 'trip-summary', + { + title: 'Trip Summary', + description: 'Load a full summary of a trip for context before planning or modifications', + argsSchema: { + tripId: z.number().int().positive().describe('Trip ID to summarize'), + }, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) { + return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] }; + } + const summary = getTripSummary(tripId); + if (!summary) { + return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] }; + } + const { trip, days, members, budget, packing, reservations, collabNotes } = summary; + const packingStats = packing ? { total: packing.length, packed: packing.filter((p: any) => p.checked).length } : { total: 0, packed: 0 }; + const budgetTotal = budget?.reduce((sum: number, b: any) => sum + (b.total_price || 0), 0) || 0; + const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''} +Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'} +Members: ${members?.length || 0} (${members?.map((m: any) => m.name || m.email).join(', ') || 'none'}) +Days: ${days?.length || 0} +Packing: ${packingStats.packed}/${packingStats.total} items packed +Budget: ${budgetTotal} ${trip?.currency || 'EUR'} total +Reservations: ${reservations?.length || 0} +Collab Notes: ${collabNotes?.length || 0} +${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`; + return { + description: `Summary of trip "${trip?.title || tripId}"`, + messages: [{ role: 'user', content: { type: 'text', text } }], + }; + } + ); + + server.registerPrompt( + 'packing-list', + { + title: 'Packing List', + description: 'Get a formatted packing checklist for a trip', + argsSchema: { + tripId: z.number().int().positive().describe('Trip ID'), + }, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) { + return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] }; + } + const items = listPackingItems(tripId); + if (!items.length) { + return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] }; + } + const grouped = items.reduce((acc: Record, item: any) => { + const cat = item.category || 'General'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(item); + return acc; + }, {}); + const lines = Object.entries(grouped).map(([cat, items]) => + `## ${cat}\n${(items as any[]).map((i: any) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}` + ).join('\n\n'); + const { trip } = getTripSummary(tripId) || {}; + return { + description: `Packing list for "${trip?.title || tripId}"`, + messages: [{ role: 'user', content: { type: 'text', text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }], + }; + } + ); + + server.registerPrompt( + 'budget-overview', + { + title: 'Budget Overview', + description: 'Get a formatted budget summary for a trip', + argsSchema: { + tripId: z.number().int().positive().describe('Trip ID'), + }, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) { + return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] }; + } + const summary = getTripSummary(tripId); + if (!summary) { + return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] }; + } + const { trip, budget } = summary; + const currency = trip?.currency || 'EUR'; + const byCategory = (budget || []).reduce((acc: Record, item: any) => { + const cat = item.category || 'Uncategorized'; + acc[cat] = (acc[cat] || 0) + (item.total_price || 0); + return acc; + }, {} as Record); + const total = Object.values(byCategory).reduce((s, v) => s + v, 0); + const lines = Object.entries(byCategory) + .sort(([, a], [, b]) => b - a) + .map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`) + .join('\n'); + const perPerson = (summary.members?.length || 1) > 0 ? (total / (summary.members?.length || 1)).toFixed(2) : total.toFixed(2); + return { + description: `Budget overview for "${trip?.title || tripId}"`, + messages: [{ role: 'user', content: { type: 'text', text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }], + }; + } + ); +} diff --git a/server/src/mcp/tools/reservations.ts b/server/src/mcp/tools/reservations.ts new file mode 100644 index 00000000..4639dea8 --- /dev/null +++ b/server/src/mcp/tools/reservations.ts @@ -0,0 +1,203 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import { + createReservation, getReservation, updateReservation, deleteReservation, + updatePositions as updateReservationPositions, +} from '../../services/reservationService'; +import { getDay } from '../../services/dayService'; +import { placeExists, getAssignmentForTrip } from '../../services/assignmentService'; +import { + safeBroadcast, TOOL_ANNOTATIONS_WRITE, TOOL_ANNOTATIONS_DELETE, + TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, noAccess, ok, +} from './_shared'; + +export function registerReservationTools(server: McpServer, userId: number): void { + + 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']).describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "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)'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + 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) { + safeBroadcast(tripId, 'accommodation:created', {}); + } + safeBroadcast(tripId, 'reservation:created', { reservation }); + return ok({ reservation }); + } + ); + + 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().describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "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(), + status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'), + 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'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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); + safeBroadcast(tripId, 'reservation:updated', { 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(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + 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) { + safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }); + } + safeBroadcast(tripId, 'reservation:deleted', { reservationId }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'reorder_reservations', + { + description: 'Update the display order of reservations within a day.', + inputSchema: { + tripId: z.number().int().positive(), + positions: z.array(z.object({ + id: z.number().int().positive(), + day_plan_position: z.number().int().min(0), + })).describe('Array of { id, day_plan_position } pairs'), + dayId: z.number().int().positive().optional().describe('Optionally scope the update to a specific day'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, positions, dayId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + updateReservationPositions(tripId, positions, dayId); + safeBroadcast(tripId, 'reservation:positions', { positions, dayId }); + 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")'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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); + + safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {}); + safeBroadcast(tripId, 'reservation:updated', { reservation }); + return ok({ reservation, accommodation_id: (reservation as any).accommodation_id }); + } + ); +} diff --git a/server/src/mcp/tools/tags.ts b/server/src/mcp/tools/tags.ts new file mode 100644 index 00000000..37f17b50 --- /dev/null +++ b/server/src/mcp/tools/tags.ts @@ -0,0 +1,78 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { isDemoUser } from '../../services/authService'; +import { listTags, createTag, updateTag, deleteTag } from '../../services/tagService'; +import { + TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, ok, +} from './_shared'; + +export function registerTagTools(server: McpServer, userId: number): void { + // --- TAGS --- + + server.registerTool( + 'list_tags', + { + description: 'List all tags belonging to the current user.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const tags = listTags(userId); + return ok({ tags }); + } + ); + + server.registerTool( + 'create_tag', + { + description: 'Create a new tag (user-scoped label for places).', + inputSchema: { + name: z.string().min(1).max(100), + color: z.string().optional().describe('Hex color string e.g. #6366f1'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ name, color }) => { + if (isDemoUser(userId)) return demoDenied(); + const tag = createTag(userId, name, color); + return ok({ tag }); + } + ); + + server.registerTool( + 'update_tag', + { + description: 'Update the name or color of an existing tag.', + inputSchema: { + tagId: z.number().int().positive(), + name: z.string().optional(), + color: z.string().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tagId, name, color }) => { + if (isDemoUser(userId)) return demoDenied(); + const tag = updateTag(tagId, name, color); + if (!tag) return { content: [{ type: 'text' as const, text: 'Tag not found.' }], isError: true }; + return ok({ tag }); + } + ); + + server.registerTool( + 'delete_tag', + { + description: 'Delete a tag (removes it from all places it was attached to).', + inputSchema: { + tagId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tagId }) => { + if (isDemoUser(userId)) return demoDenied(); + deleteTag(tagId); + return ok({ success: true }); + } + ); +} diff --git a/server/src/mcp/tools/todos.ts b/server/src/mcp/tools/todos.ts new file mode 100644 index 00000000..fa716b0b --- /dev/null +++ b/server/src/mcp/tools/todos.ts @@ -0,0 +1,185 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { canAccessTrip } from '../../db/database'; +import { isDemoUser } from '../../services/authService'; +import { + listItems as listTodoItems, createItem as createTodoItem, updateItem as updateTodoItem, + deleteItem as deleteTodoItem, reorderItems as reorderTodoItems, + getCategoryAssignees as getTodoCategoryAssignees, updateCategoryAssignees as updateTodoCategoryAssignees, +} from '../../services/todoService'; +import { + safeBroadcast, TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, noAccess, ok, +} from './_shared'; + +export function registerTodoTools(server: McpServer, userId: number): void { + // --- TODOS --- + + server.registerTool( + 'list_todos', + { + description: 'List all to-do items for a trip, ordered by position.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const items = listTodoItems(tripId); + return ok({ items }); + } + ); + + server.registerTool( + 'create_todo', + { + description: 'Create a new to-do item for a trip.', + inputSchema: { + tripId: z.number().int().positive(), + name: z.string().min(1).max(500).describe('To-do item name'), + category: z.string().max(100).optional().describe('Category (e.g. "Logistics", "Booking")'), + due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('Due date (YYYY-MM-DD)'), + description: z.string().max(2000).optional().describe('Additional description'), + assigned_user_id: z.number().int().positive().optional().describe('User ID to assign this task to'), + priority: z.number().int().min(0).max(3).optional().describe('Priority: 0=none, 1=low, 2=medium, 3=high'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, name, category, due_date, description, assigned_user_id, priority }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const item = createTodoItem(tripId, { name, category, due_date, description, assigned_user_id, priority }); + safeBroadcast(tripId, 'todo:created', { item }); + return ok({ item }); + } + ); + + server.registerTool( + 'update_todo', + { + description: 'Update an existing to-do item. Only provided fields are changed; omitted fields stay as-is. Pass null to clear a nullable field.', + inputSchema: { + tripId: z.number().int().positive(), + itemId: z.number().int().positive(), + name: z.string().min(1).max(500).optional(), + category: z.string().max(100).optional(), + due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).nullable().optional().describe('Set to null to clear the due date'), + description: z.string().max(2000).nullable().optional().describe('Set to null to clear'), + assigned_user_id: z.number().int().positive().nullable().optional().describe('Set to null to unassign'), + priority: z.number().int().min(0).max(3).nullable().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, itemId, name, category, due_date, description, assigned_user_id, priority }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + // Build bodyKeys to signal which nullable fields were explicitly provided + const bodyKeys: string[] = []; + if (due_date !== undefined) bodyKeys.push('due_date'); + if (description !== undefined) bodyKeys.push('description'); + if (assigned_user_id !== undefined) bodyKeys.push('assigned_user_id'); + if (priority !== undefined) bodyKeys.push('priority'); + const item = updateTodoItem(tripId, itemId, { name, category, due_date, description, assigned_user_id, priority }, bodyKeys); + if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true }; + safeBroadcast(tripId, 'todo:updated', { item }); + return ok({ item }); + } + ); + + server.registerTool( + 'toggle_todo', + { + description: 'Mark a to-do item as checked (done) or unchecked.', + inputSchema: { + tripId: z.number().int().positive(), + itemId: z.number().int().positive(), + checked: z.boolean().describe('True to mark done, false to uncheck'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, itemId, checked }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const item = updateTodoItem(tripId, itemId, { checked: checked ? 1 : 0 }, []); + if (!item) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true }; + safeBroadcast(tripId, 'todo:updated', { item }); + return ok({ item }); + } + ); + + server.registerTool( + 'delete_todo', + { + description: 'Delete a to-do item.', + inputSchema: { + tripId: z.number().int().positive(), + itemId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ tripId, itemId }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const deleted = deleteTodoItem(tripId, itemId); + if (!deleted) return { content: [{ type: 'text' as const, text: 'To-do item not found.' }], isError: true }; + safeBroadcast(tripId, 'todo:deleted', { itemId }); + return ok({ success: true }); + } + ); + + server.registerTool( + 'reorder_todos', + { + description: 'Reorder to-do items within a trip by providing a new ordered list of item IDs.', + inputSchema: { + tripId: z.number().int().positive(), + orderedIds: z.array(z.number().int().positive()).min(1).describe('All item IDs in the desired order'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, orderedIds }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + reorderTodoItems(tripId, orderedIds); + return ok({ success: true }); + } + ); + + server.registerTool( + 'get_todo_category_assignees', + { + description: 'Get the default assignees configured per to-do category for a trip.', + inputSchema: { + tripId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const assignees = getTodoCategoryAssignees(tripId); + return ok({ assignees }); + } + ); + + server.registerTool( + 'set_todo_category_assignees', + { + description: 'Set the default assignees for a to-do category on a trip. Pass an empty array to clear.', + inputSchema: { + tripId: z.number().int().positive(), + categoryName: z.string().min(1).max(100).describe('Category name'), + userIds: z.array(z.number().int().positive()).describe('User IDs to assign as defaults for this category'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ tripId, categoryName, userIds }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const assignees = updateTodoCategoryAssignees(tripId, categoryName, userIds); + safeBroadcast(tripId, 'todo:assignees', { category: categoryName, assignees }); + return ok({ assignees }); + } + ); +} diff --git a/server/src/mcp/tools/trips.ts b/server/src/mcp/tools/trips.ts new file mode 100644 index 00000000..3380588f --- /dev/null +++ b/server/src/mcp/tools/trips.ts @@ -0,0 +1,338 @@ +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 } from '../../services/adminService'; +import { countMessages, listPolls } from '../../services/collabService'; +import { + listItems as listTodoItems, +} from '../../services/todoService'; +import { listFiles } from '../../services/fileService'; +import { + safeBroadcast, MAX_MCP_TRIP_DAYS, + TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, noAccess, ok, +} from './_shared'; + +export function registerTripTools(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)'), + }, + 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 }); + } + ); + + 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(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + 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'); + safeBroadcast(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(), + }, + 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 }); + } + ); + + 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 trips = listTrips(userId, include_archived ? null : 0); + 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 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(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const summary = getTripSummary(tripId); + if (!summary) return noAccess(); + const todos = listTodoItems(tripId); + const files = listFiles(tripId, false).map((f: any) => ({ + id: f.id, + original_name: f.original_name, + mime_type: f.mime_type, + file_size: f.file_size, + starred: !!f.starred, + deleted: !!f.deleted_at, + created_at: f.created_at, + })); + let pollCount = 0; + if (isAddonEnabled('collab')) { + pollCount = listPolls(tripId).length; + } + let messageCount = 0; + if (isAddonEnabled('collab')) { + messageCount = countMessages(tripId); + } + return ok({ ...summary, todos, files, pollCount, messageCount }); + } + ); + + // --- TRIP MEMBERS, COPY, ICS, SHARE --- + + 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 }); + } + ); + + 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 }; + } + } + ); + + 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 }); + } + ); + + 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 }; + } + } + ); + + 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 }; + } + } + ); + + 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 }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const link = getShareLink(String(tripId)); + return ok({ link }); + } + ); + + 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(); + 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 }); + } + ); + + 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(); + deleteShareLink(String(tripId)); + return ok({ success: true }); + } + ); +} diff --git a/server/src/mcp/tools/vacay.ts b/server/src/mcp/tools/vacay.ts new file mode 100644 index 00000000..96083df9 --- /dev/null +++ b/server/src/mcp/tools/vacay.ts @@ -0,0 +1,393 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { z } from 'zod'; +import { isDemoUser, getCurrentUser } from '../../services/authService'; +import { + getOwnPlan, getActivePlan, getActivePlanId, getPlanData, + updatePlan, setUserColor, + sendInvite as sendVacayInvite, acceptInvite, declineInvite, cancelInvite, dissolvePlan, + getAvailableUsers, + listYears, addYear, deleteYear, + getEntries as getVacayEntries, toggleEntry, toggleCompanyHoliday, + getStats as getVacayStats, updateStats as updateVacayStats, + addHolidayCalendar, updateHolidayCalendar, deleteHolidayCalendar, + getCountries as getHolidayCountries, getHolidays, +} from '../../services/vacayService'; +import { isAddonEnabled } from '../../services/adminService'; +import { + TOOL_ANNOTATIONS_READONLY, TOOL_ANNOTATIONS_WRITE, + TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, + demoDenied, ok, +} from './_shared'; + +export function registerVacayTools(server: McpServer, userId: number): void { + if (isAddonEnabled('vacay')) { + server.registerTool( + 'get_vacay_plan', + { + description: "Get the current user's active vacation plan (own or joined).", + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const plan = getPlanData(userId); + return ok({ plan }); + } + ); + + server.registerTool( + 'update_vacay_plan', + { + description: 'Update vacation plan settings (weekends blocking, holidays, carry-over).', + inputSchema: { + block_weekends: z.boolean().optional(), + holidays_enabled: z.boolean().optional(), + holidays_region: z.string().nullable().optional(), + company_holidays_enabled: z.boolean().optional(), + carry_over_enabled: z.boolean().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + await updatePlan(planId, { block_weekends, holidays_enabled, holidays_region, company_holidays_enabled, carry_over_enabled }, undefined); + return ok({ success: true }); + } + ); + + server.registerTool( + 'set_vacay_color', + { + description: "Set the current user's color in the vacation plan calendar.", + inputSchema: { + color: z.string().describe('Hex color e.g. #6366f1'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ color }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + setUserColor(userId, planId, color, undefined); + return ok({ success: true }); + } + ); + + server.registerTool( + 'get_available_vacay_users', + { + description: 'List users who can be invited to the current vacation plan.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const planId = getActivePlanId(userId); + const users = getAvailableUsers(userId, planId); + return ok({ users }); + } + ); + + server.registerTool( + 'send_vacay_invite', + { + description: 'Invite a user to join the vacation plan by their user ID.', + inputSchema: { + targetUserId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ targetUserId }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + const me = getCurrentUser(userId); + if (!me) return { content: [{ type: 'text' as const, text: 'User not found.' }], isError: true }; + const result = sendVacayInvite(planId, userId, me.username, me.email, targetUserId); + if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true }; + return ok({ success: true }); + } + ); + + server.registerTool( + 'accept_vacay_invite', + { + description: 'Accept a pending invitation to join another user\'s vacation plan.', + inputSchema: { + planId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ planId }) => { + if (isDemoUser(userId)) return demoDenied(); + const result = acceptInvite(userId, planId, undefined); + if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true }; + return ok({ success: true }); + } + ); + + server.registerTool( + 'decline_vacay_invite', + { + description: 'Decline a pending vacation plan invitation.', + inputSchema: { + planId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ planId }) => { + declineInvite(userId, planId, undefined); + return ok({ success: true }); + } + ); + + server.registerTool( + 'cancel_vacay_invite', + { + description: 'Cancel an outgoing invitation (owner cancels invite they sent).', + inputSchema: { + targetUserId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ targetUserId }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + cancelInvite(planId, targetUserId); + return ok({ success: true }); + } + ); + + server.registerTool( + 'dissolve_vacay_plan', + { + description: 'Dissolve the shared plan — all members are removed and everyone returns to their own individual plan.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async () => { + if (isDemoUser(userId)) return demoDenied(); + dissolvePlan(userId, undefined); + return ok({ success: true }); + } + ); + + server.registerTool( + 'list_vacay_years', + { + description: 'List calendar years tracked in the current vacation plan.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const planId = getActivePlanId(userId); + const years = listYears(planId); + return ok({ years }); + } + ); + + server.registerTool( + 'add_vacay_year', + { + description: 'Add a calendar year to the vacation plan.', + inputSchema: { + year: z.number().int().min(2000).max(2100), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ year }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + const years = addYear(planId, year, undefined); + return ok({ years }); + } + ); + + server.registerTool( + 'delete_vacay_year', + { + description: 'Remove a calendar year from the vacation plan.', + inputSchema: { + year: z.number().int(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ year }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + const years = deleteYear(planId, year, undefined); + return ok({ years }); + } + ); + + server.registerTool( + 'get_vacay_entries', + { + description: 'Get all vacation day entries for a plan and year.', + inputSchema: { + year: z.number().int(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ year }) => { + const planId = getActivePlanId(userId); + const entries = getVacayEntries(planId, String(year)); + return ok({ entries }); + } + ); + + server.registerTool( + 'toggle_vacay_entry', + { + description: 'Toggle a day on or off as a vacation day for the current user.', + inputSchema: { + date: z.string().describe('ISO date YYYY-MM-DD'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ date }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + const result = toggleEntry(userId, planId, date, undefined); + return ok(result); + } + ); + + server.registerTool( + 'toggle_company_holiday', + { + description: 'Toggle a date as a company holiday for the whole plan.', + inputSchema: { + date: z.string(), + note: z.string().optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ date, note }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + const result = toggleCompanyHoliday(planId, date, note, undefined); + return ok(result); + } + ); + + server.registerTool( + 'get_vacay_stats', + { + description: 'Get vacation statistics for a specific year (days used, remaining, carried over).', + inputSchema: { + year: z.number().int(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ year }) => { + const planId = getActivePlanId(userId); + const stats = getVacayStats(planId, year); + return ok({ stats }); + } + ); + + server.registerTool( + 'update_vacay_stats', + { + description: 'Update the vacation day allowance for a specific user and year.', + inputSchema: { + year: z.number().int(), + vacationDays: z.number().int().min(0), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ year, vacationDays }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + updateVacayStats(userId, planId, year, vacationDays, undefined); + return ok({ success: true }); + } + ); + + server.registerTool( + 'add_holiday_calendar', + { + description: 'Add a public holiday calendar (by region code) to the vacation plan.', + inputSchema: { + region: z.string().describe('Country/region code e.g. US, GB, DE'), + label: z.string().nullable().optional(), + color: z.string().optional(), + sortOrder: z.number().int().optional(), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ region, label, color, sortOrder }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + const calendar = addHolidayCalendar(planId, region, label ?? null, color, sortOrder, undefined); + return ok({ calendar }); + } + ); + + server.registerTool( + 'update_holiday_calendar', + { + description: 'Update label or color for a holiday calendar.', + inputSchema: { + calendarId: z.number().int().positive(), + label: z.string().nullable().optional(), + color: z.string().optional(), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + }, + async ({ calendarId, label, color }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + const cal = updateHolidayCalendar(calendarId, planId, { label, color }, undefined); + if (!cal) return { content: [{ type: 'text' as const, text: 'Holiday calendar not found.' }], isError: true }; + return ok({ calendar: cal }); + } + ); + + server.registerTool( + 'delete_holiday_calendar', + { + description: 'Remove a holiday calendar from the vacation plan.', + inputSchema: { + calendarId: z.number().int().positive(), + }, + annotations: TOOL_ANNOTATIONS_DELETE, + }, + async ({ calendarId }) => { + if (isDemoUser(userId)) return demoDenied(); + const planId = getActivePlanId(userId); + deleteHolidayCalendar(calendarId, planId, undefined); + return ok({ success: true }); + } + ); + + server.registerTool( + 'list_holiday_countries', + { + description: 'List countries available for public holiday calendars.', + inputSchema: {}, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async () => { + const result = await getHolidayCountries(); + if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true }; + return ok({ countries: result.data }); + } + ); + + server.registerTool( + 'list_holidays', + { + description: 'List public holidays for a country and year.', + inputSchema: { + country: z.string().describe('ISO 3166-1 alpha-2 code'), + year: z.number().int(), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ country, year }) => { + const result = await getHolidays(String(year), country); + if (result.error) return { content: [{ type: 'text' as const, text: result.error }], isError: true }; + return ok({ holidays: result.data }); + } + ); + } +} diff --git a/server/src/routes/trips.ts b/server/src/routes/trips.ts index b9d7b94e..cf1f2fc4 100644 --- a/server/src/routes/trips.ts +++ b/server/src/routes/trips.ts @@ -23,6 +23,7 @@ import { addMember, removeMember, exportICS, + copyTripById, verifyTripAccess, NotFoundError, ValidationError, @@ -199,160 +200,9 @@ router.post('/:id/copy', authenticate, (req: Request, res: Response) => { if (!canAccessTrip(req.params.id, authReq.user.id)) return res.status(404).json({ error: 'Trip not found' }); - const src = db.prepare('SELECT * FROM trips WHERE id = ?').get(req.params.id) as Trip | undefined; - if (!src) return res.status(404).json({ error: 'Trip not found' }); - - const title = req.body.title || src.title; - - const copyTrip = db.transaction(() => { - // 1. Create new trip - const tripResult = db.prepare(` - INSERT INTO trips (user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days) - VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?) - `).run(authReq.user.id, title, src.description, src.start_date, src.end_date, src.currency, src.cover_image, src.reminder_days ?? 3); - const newTripId = tripResult.lastInsertRowid; - - // 2. Copy days → build ID map - const oldDays = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(req.params.id) as any[]; - const dayMap = new Map(); - const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)'); - for (const d of oldDays) { - const r = insertDay.run(newTripId, d.day_number, d.date, d.notes, d.title); - dayMap.set(d.id, r.lastInsertRowid); - } - - // 3. Copy places → build ID map - const oldPlaces = db.prepare('SELECT * FROM places WHERE trip_id = ?').all(req.params.id) as any[]; - const placeMap = new Map(); - const insertPlace = db.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, - reservation_status, reservation_notes, reservation_datetime, place_time, end_time, - duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const p of oldPlaces) { - const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id, - p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime, - p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id, - p.website, p.phone, p.transport_mode, p.osm_id); - placeMap.set(p.id, r.lastInsertRowid); - } - - // 4. Copy place_tags - const oldTags = db.prepare(` - SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ? - `).all(req.params.id) as any[]; - const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); - for (const t of oldTags) { - const newPlaceId = placeMap.get(t.place_id); - if (newPlaceId) insertTag.run(newPlaceId, t.tag_id); - } - - // 5. Copy day_assignments → build ID map - const oldAssignments = db.prepare(` - SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ? - `).all(req.params.id) as any[]; - const assignmentMap = new Map(); - const insertAssignment = db.prepare(` - INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const a of oldAssignments) { - const newDayId = dayMap.get(a.day_id); - const newPlaceId = placeMap.get(a.place_id); - if (newDayId && newPlaceId) { - const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes, - a.reservation_status, a.reservation_notes, a.reservation_datetime, - a.assignment_time, a.assignment_end_time); - assignmentMap.set(a.id, r.lastInsertRowid); - } - } - - // 6. Copy day_accommodations → build ID map (before reservations, which reference them) - const oldAccom = db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(req.params.id) as any[]; - const accomMap = new Map(); - const insertAccom = db.prepare(` - INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const a of oldAccom) { - const newPlaceId = placeMap.get(a.place_id); - const newStartDay = dayMap.get(a.start_day_id); - const newEndDay = dayMap.get(a.end_day_id); - if (newPlaceId && newStartDay && newEndDay) { - const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_out, a.confirmation, a.notes); - accomMap.set(a.id, r.lastInsertRowid); - } - } - - // 7. Copy reservations - const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(req.params.id) as any[]; - const insertReservation = db.prepare(` - INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time, - location, confirmation_number, notes, status, type, metadata, day_plan_position) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const r of oldReservations) { - insertReservation.run(newTripId, - r.day_id ? (dayMap.get(r.day_id) ?? null) : null, - r.place_id ? (placeMap.get(r.place_id) ?? null) : null, - r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null, - r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null, - r.title, r.reservation_time, r.reservation_end_time, - r.location, r.confirmation_number, r.notes, r.status, r.type, - r.metadata, r.day_plan_position); - } - - // 8. Copy budget_items (paid_by_user_id reset to null) - const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(req.params.id) as any[]; - const insertBudget = db.prepare(` - INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) - VALUES (?, ?, ?, ?, ?, ?, ?, ?) - `); - for (const b of oldBudget) { - insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order); - } - - // 9. Copy packing_bags → build ID map - const oldBags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(req.params.id) as any[]; - const bagMap = new Map(); - const insertBag = db.prepare(` - INSERT INTO packing_bags (trip_id, name, color, weight_limit_grams, sort_order) - VALUES (?, ?, ?, ?, ?) - `); - for (const bag of oldBags) { - const r = insertBag.run(newTripId, bag.name, bag.color, bag.weight_limit_grams, bag.sort_order); - bagMap.set(bag.id, r.lastInsertRowid); - } - - // 10. Copy packing_items (checked reset to 0) - const oldPacking = db.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(req.params.id) as any[]; - const insertPacking = db.prepare(` - INSERT INTO packing_items (trip_id, name, checked, category, sort_order, weight_grams, bag_id) - VALUES (?, ?, 0, ?, ?, ?, ?) - `); - for (const p of oldPacking) { - insertPacking.run(newTripId, p.name, p.category, p.sort_order, p.weight_grams, - p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null); - } - - // 11. Copy day_notes - const oldNotes = db.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(req.params.id) as any[]; - const insertNote = db.prepare(` - INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) - VALUES (?, ?, ?, ?, ?, ?) - `); - for (const n of oldNotes) { - const newDayId = dayMap.get(n.day_id); - if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order); - } - - return newTripId; - }); - try { - const newTripId = copyTrip(); - writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId: Number(newTripId), title } }); + const newTripId = copyTripById(req.params.id, authReq.user.id, req.body.title); + writeAudit({ userId: authReq.user.id, action: 'trip.copy', ip: getClientIp(req), details: { sourceTripId: Number(req.params.id), newTripId, title: req.body.title } }); const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId: authReq.user.id, tripId: newTripId }); res.status(201).json({ trip }); } catch { diff --git a/server/src/services/collabService.ts b/server/src/services/collabService.ts index 7f0b8b0e..08bd4529 100644 --- a/server/src/services/collabService.ts +++ b/server/src/services/collabService.ts @@ -318,6 +318,11 @@ export function formatMessage(msg: CollabMessage, reactions?: GroupedReaction[]) return { ...msg, user_avatar: avatarUrl(msg), avatar_url: avatarUrl(msg), reactions: reactions || [] }; } +export function countMessages(tripId: string | number): number { + const row = db.prepare('SELECT COUNT(*) as cnt FROM collab_messages WHERE trip_id = ?').get(tripId) as { cnt: number }; + return row.cnt; +} + export function listMessages(tripId: string | number, before?: string | number) { const query = ` SELECT m.*, u.username, u.avatar, diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index e5d4e27d..b14a2547 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -431,6 +431,158 @@ export function exportICS(tripId: string | number): { ics: string; filename: str return { ics, filename: `${safeFilename}.ics` }; } +// ── Copy / duplicate ───────────────────────────────────────────────────── + +/** + * Duplicates a trip (all days, places, assignments, accommodations, reservations, + * budget, packing bags/items, day notes) into a new trip owned by `newOwnerId`. + * Packing items are reset to unchecked. Budget paid status is cleared. + * Returns the new trip's ID. + */ +export function copyTripById(sourceTripId: string | number, newOwnerId: number, title?: string): number { + const src = db.prepare('SELECT * FROM trips WHERE id = ?').get(sourceTripId) as any; + if (!src) throw new NotFoundError('Trip not found'); + + const newTitle = title || src.title; + + const fn = db.transaction(() => { + const tripResult = db.prepare(` + INSERT INTO trips (user_id, title, description, start_date, end_date, currency, cover_image, is_archived, reminder_days) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?) + `).run(newOwnerId, newTitle, src.description, src.start_date, src.end_date, src.currency, src.cover_image, src.reminder_days ?? 3); + const newTripId = tripResult.lastInsertRowid; + + const oldDays = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number').all(sourceTripId) as any[]; + const dayMap = new Map(); + const insertDay = db.prepare('INSERT INTO days (trip_id, day_number, date, notes, title) VALUES (?, ?, ?, ?, ?)'); + for (const d of oldDays) { + const r = insertDay.run(newTripId, d.day_number, d.date, d.notes, d.title); + dayMap.set(d.id, r.lastInsertRowid); + } + + const oldPlaces = db.prepare('SELECT * FROM places WHERE trip_id = ?').all(sourceTripId) as any[]; + const placeMap = new Map(); + const insertPlace = db.prepare(` + INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, price, currency, + reservation_status, reservation_notes, reservation_datetime, place_time, end_time, + duration_minutes, notes, image_url, google_place_id, website, phone, transport_mode, osm_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const p of oldPlaces) { + const r = insertPlace.run(newTripId, p.name, p.description, p.lat, p.lng, p.address, p.category_id, + p.price, p.currency, p.reservation_status, p.reservation_notes, p.reservation_datetime, + p.place_time, p.end_time, p.duration_minutes, p.notes, p.image_url, p.google_place_id, + p.website, p.phone, p.transport_mode, p.osm_id); + placeMap.set(p.id, r.lastInsertRowid); + } + + const oldTags = db.prepare(` + SELECT pt.* FROM place_tags pt JOIN places p ON p.id = pt.place_id WHERE p.trip_id = ? + `).all(sourceTripId) as any[]; + const insertTag = db.prepare('INSERT OR IGNORE INTO place_tags (place_id, tag_id) VALUES (?, ?)'); + for (const t of oldTags) { + const newPlaceId = placeMap.get(t.place_id); + if (newPlaceId) insertTag.run(newPlaceId, t.tag_id); + } + + const oldAssignments = db.prepare(` + SELECT da.* FROM day_assignments da JOIN days d ON d.id = da.day_id WHERE d.trip_id = ? + `).all(sourceTripId) as any[]; + const assignmentMap = new Map(); + const insertAssignment = db.prepare(` + INSERT INTO day_assignments (day_id, place_id, order_index, notes, reservation_status, reservation_notes, reservation_datetime, assignment_time, assignment_end_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const a of oldAssignments) { + const newDayId = dayMap.get(a.day_id); + const newPlaceId = placeMap.get(a.place_id); + if (newDayId && newPlaceId) { + const r = insertAssignment.run(newDayId, newPlaceId, a.order_index, a.notes, + a.reservation_status, a.reservation_notes, a.reservation_datetime, + a.assignment_time, a.assignment_end_time); + assignmentMap.set(a.id, r.lastInsertRowid); + } + } + + const oldAccom = db.prepare('SELECT * FROM day_accommodations WHERE trip_id = ?').all(sourceTripId) as any[]; + const accomMap = new Map(); + const insertAccom = db.prepare(` + INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation, notes) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const a of oldAccom) { + const newPlaceId = placeMap.get(a.place_id); + const newStartDay = dayMap.get(a.start_day_id); + const newEndDay = dayMap.get(a.end_day_id); + if (newPlaceId && newStartDay && newEndDay) { + const r = insertAccom.run(newTripId, newPlaceId, newStartDay, newEndDay, a.check_in, a.check_out, a.confirmation, a.notes); + accomMap.set(a.id, r.lastInsertRowid); + } + } + + const oldReservations = db.prepare('SELECT * FROM reservations WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertReservation = db.prepare(` + INSERT INTO reservations (trip_id, day_id, place_id, assignment_id, accommodation_id, title, reservation_time, reservation_end_time, + location, confirmation_number, notes, status, type, metadata, day_plan_position) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const r of oldReservations) { + insertReservation.run(newTripId, + r.day_id ? (dayMap.get(r.day_id) ?? null) : null, + r.place_id ? (placeMap.get(r.place_id) ?? null) : null, + r.assignment_id ? (assignmentMap.get(r.assignment_id) ?? null) : null, + r.accommodation_id ? (accomMap.get(r.accommodation_id) ?? null) : null, + r.title, r.reservation_time, r.reservation_end_time, + r.location, r.confirmation_number, r.notes, r.status, r.type, + r.metadata, r.day_plan_position); + } + + const oldBudget = db.prepare('SELECT * FROM budget_items WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertBudget = db.prepare(` + INSERT INTO budget_items (trip_id, category, name, total_price, persons, days, note, sort_order) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `); + for (const b of oldBudget) { + insertBudget.run(newTripId, b.category, b.name, b.total_price, b.persons, b.days, b.note, b.sort_order); + } + + const oldBags = db.prepare('SELECT * FROM packing_bags WHERE trip_id = ?').all(sourceTripId) as any[]; + const bagMap = new Map(); + const insertBag = db.prepare(` + INSERT INTO packing_bags (trip_id, name, color, weight_limit_grams, sort_order) + VALUES (?, ?, ?, ?, ?) + `); + for (const bag of oldBags) { + const r = insertBag.run(newTripId, bag.name, bag.color, bag.weight_limit_grams, bag.sort_order); + bagMap.set(bag.id, r.lastInsertRowid); + } + + const oldPacking = db.prepare('SELECT * FROM packing_items WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertPacking = db.prepare(` + INSERT INTO packing_items (trip_id, name, checked, category, sort_order, weight_grams, bag_id) + VALUES (?, ?, 0, ?, ?, ?, ?) + `); + for (const p of oldPacking) { + insertPacking.run(newTripId, p.name, p.category, p.sort_order, p.weight_grams, + p.bag_id ? (bagMap.get(p.bag_id) ?? null) : null); + } + + const oldNotes = db.prepare('SELECT * FROM day_notes WHERE trip_id = ?').all(sourceTripId) as any[]; + const insertNote = db.prepare(` + INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) + VALUES (?, ?, ?, ?, ?, ?) + `); + for (const n of oldNotes) { + const newDayId = dayMap.get(n.day_id); + if (newDayId) insertNote.run(newDayId, newTripId, n.text, n.time, n.icon, n.sort_order); + } + + return Number(newTripId); + }); + + return fn(); +} + // ── Trip summary (used by MCP get_trip_summary tool) ────────────────────── export function getTripSummary(tripId: number) { diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts index 508d64c5..aeadb3d1 100644 --- a/server/tests/helpers/factories.ts +++ b/server/tests/helpers/factories.ts @@ -321,6 +321,32 @@ export function createCollabNote( return db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid) as TestCollabNote; } +// --------------------------------------------------------------------------- +// Todo Items +// --------------------------------------------------------------------------- + +export interface TestTodoItem { + id: number; + trip_id: number; + name: string; + checked: number; + category: string | null; + sort_order: number; +} + +export function createTodoItem( + db: Database.Database, + tripId: number, + overrides: Partial<{ name: string; category: string; checked: number }> = {} +): TestTodoItem { + const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM todo_items WHERE trip_id = ?').get(tripId) as { max: number | null }; + const sortOrder = (maxOrder.max !== null ? maxOrder.max : -1) + 1; + const result = db.prepare( + 'INSERT INTO todo_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)' + ).run(tripId, overrides.name ?? 'Test Todo', overrides.checked ?? 0, overrides.category ?? null, sortOrder); + return db.prepare('SELECT * FROM todo_items WHERE id = ?').get(result.lastInsertRowid) as TestTodoItem; +} + // --------------------------------------------------------------------------- // Day Assignments // --------------------------------------------------------------------------- diff --git a/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts b/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts new file mode 100644 index 00000000..7a1a497e --- /dev/null +++ b/server/tests/unit/mcp/tools-assignments-reservations-extra.test.ts @@ -0,0 +1,244 @@ +/** + * Unit tests for MCP extra assignment/reservation tools: + * move_assignment, get_assignment_participants, set_assignment_participants, reorder_reservations. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, createDay, createPlace, createDayAssignment, createReservation } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// move_assignment +// --------------------------------------------------------------------------- + +describe('Tool: move_assignment', () => { + it('moves assignment to a different day and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id); + const day2 = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, day1.id, place.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'move_assignment', + arguments: { tripId: trip.id, assignmentId: assignment.id, newDayId: day2.id, oldDayId: day1.id, orderIndex: 0 }, + }); + const data = parseToolResult(result) as any; + expect(data.assignment).toBeDefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:moved', expect.any(Object)); + // Verify the assignment was moved + const updated = testDb.prepare('SELECT day_id FROM day_assignments WHERE id = ?').get(assignment.id) as any; + expect(updated.day_id).toBe(day2.id); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'move_assignment', + arguments: { tripId: trip.id, assignmentId: 1, newDayId: day.id, oldDayId: day.id }, + }); + 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); + const day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'move_assignment', + arguments: { tripId: trip.id, assignmentId: 1, newDayId: day.id, oldDayId: day.id }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_assignment_participants +// --------------------------------------------------------------------------- + +describe('Tool: get_assignment_participants', () => { + it('returns empty participants array initially', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'get_assignment_participants', + arguments: { tripId: trip.id, assignmentId: assignment.id }, + }); + const data = parseToolResult(result) as any; + expect(Array.isArray(data.participants)).toBe(true); + expect(data.participants).toHaveLength(0); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_assignment_participants', arguments: { tripId: trip.id, assignmentId: 1 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// set_assignment_participants +// --------------------------------------------------------------------------- + +describe('Tool: set_assignment_participants', () => { + it('sets participants and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_assignment_participants', + arguments: { tripId: trip.id, assignmentId: assignment.id, userIds: [user.id] }, + }); + const data = parseToolResult(result) as any; + expect(Array.isArray(data.participants)).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:participants', expect.any(Object)); + }); + }); + + it('empty array clears participants', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + // First set + testDb.prepare('INSERT INTO assignment_participants (assignment_id, user_id) VALUES (?, ?)').run(assignment.id, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_assignment_participants', + arguments: { tripId: trip.id, assignmentId: assignment.id, userIds: [] }, + }); + const data = parseToolResult(result) as any; + expect(data.participants).toEqual([]); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_assignment_participants', + arguments: { tripId: trip.id, assignmentId: 1, userIds: [] }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// reorder_reservations +// --------------------------------------------------------------------------- + +describe('Tool: reorder_reservations', () => { + it('returns success and broadcasts reservation:positions', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const res1 = createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' }); + const res2 = createReservation(testDb, trip.id, { title: 'Hotel', type: 'hotel' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_reservations', + arguments: { + tripId: trip.id, + positions: [ + { id: res1.id, day_plan_position: 1 }, + { id: res2.id, day_plan_position: 0 }, + ], + }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:positions', expect.any(Object)); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_reservations', + arguments: { tripId: trip.id, positions: [{ id: 1, day_plan_position: 0 }] }, + }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-atlas-expanded.test.ts b/server/tests/unit/mcp/tools-atlas-expanded.test.ts new file mode 100644 index 00000000..84b44eb9 --- /dev/null +++ b/server/tests/unit/mcp/tools-atlas-expanded.test.ts @@ -0,0 +1,313 @@ +/** + * Unit tests for MCP atlas expanded tools (atlas addon-gated): + * get_atlas_stats, list_visited_regions, mark_region_visited, unmark_region_visited, + * get_country_atlas_places, update_bucket_list_item. + * Also covers resources trek://atlas/stats and trek://atlas/regions. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +vi.mock('../../../src/services/adminService', () => ({ + isAddonEnabled: vi.fn().mockReturnValue(true), +})); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withTools: false, withResources: true }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// get_atlas_stats +// --------------------------------------------------------------------------- + +describe('Tool: get_atlas_stats', () => { + it('returns stats object without error for empty data', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_atlas_stats', arguments: {} }); + expect(result.isError).toBeFalsy(); + const data = parseToolResult(result) as any; + expect(data.stats).toBeDefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// list_visited_regions +// --------------------------------------------------------------------------- + +describe('Tool: list_visited_regions', () => { + it('returns empty array initially', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.regions).toEqual([]); + }); + }); + + it('returns regions after they have been inserted', async () => { + const { user } = createUser(testDb); + testDb.prepare( + 'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)' + ).run(user.id, 'FR-75', 'Paris', 'FR'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_visited_regions', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.regions).toHaveLength(1); + expect(data.regions[0].region_code).toBe('FR-75'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// mark_region_visited +// --------------------------------------------------------------------------- + +describe('Tool: mark_region_visited', () => { + it('inserts region and returns region object', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'mark_region_visited', + arguments: { regionCode: 'US-CA', regionName: 'California', countryCode: 'US' }, + }); + const data = parseToolResult(result) as any; + expect(data.region).toBeDefined(); + expect(data.region.region_code).toBe('US-CA'); + expect(data.region.region_name).toBe('California'); + expect(data.region.country_code).toBe('US'); + const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'US-CA'); + expect(row).toBeTruthy(); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'mark_region_visited', + arguments: { regionCode: 'DE-BY', regionName: 'Bavaria', countryCode: 'DE' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// unmark_region_visited +// --------------------------------------------------------------------------- + +describe('Tool: unmark_region_visited', () => { + it('removes region and returns success', async () => { + const { user } = createUser(testDb); + testDb.prepare( + 'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)' + ).run(user.id, 'IT-LO', 'Lombardy', 'IT'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'unmark_region_visited', + arguments: { regionCode: 'IT-LO' }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + const row = testDb.prepare('SELECT * FROM visited_regions WHERE user_id = ? AND region_code = ?').get(user.id, 'IT-LO'); + expect(row).toBeUndefined(); + }); + }); + + it('succeeds even when region was not marked (no-op)', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'unmark_region_visited', + arguments: { regionCode: 'XX-YY' }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_country_atlas_places +// --------------------------------------------------------------------------- + +describe('Tool: get_country_atlas_places', () => { + it('returns empty places array for a new user', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'get_country_atlas_places', + arguments: { countryCode: 'JP' }, + }); + const data = parseToolResult(result) as any; + expect(data.places).toBeDefined(); + expect(Array.isArray(data.places)).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_bucket_list_item +// --------------------------------------------------------------------------- + +describe('Tool: update_bucket_list_item', () => { + it('updates notes and returns item', async () => { + const { user } = createUser(testDb); + const r = testDb.prepare( + 'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)' + ).run(user.id, 'Visit Tokyo'); + const itemId = r.lastInsertRowid as number; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_bucket_list_item', + arguments: { itemId, notes: 'Cherry blossom season preferred' }, + }); + const data = parseToolResult(result) as any; + expect(data.item).toBeDefined(); + expect(data.item.notes).toBe('Cherry blossom season preferred'); + }); + }); + + it('updates name of existing item', async () => { + const { user } = createUser(testDb); + const r = testDb.prepare( + 'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)' + ).run(user.id, 'Old Name'); + const itemId = r.lastInsertRowid as number; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_bucket_list_item', + arguments: { itemId, name: 'New Name' }, + }); + const data = parseToolResult(result) as any; + expect(data.item.name).toBe('New Name'); + }); + }); + + it('returns isError for non-existent item', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_bucket_list_item', + arguments: { itemId: 99999, notes: 'Will not work' }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const r = testDb.prepare( + 'INSERT INTO bucket_list (user_id, name, lat, lng) VALUES (?, ?, NULL, NULL)' + ).run(user.id, 'Bucket Item'); + const itemId = r.lastInsertRowid as number; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_bucket_list_item', + arguments: { itemId, notes: 'blocked' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Resource: trek://atlas/stats +// --------------------------------------------------------------------------- + +describe('Resource: trek://atlas/stats', () => { + it('returns stats object', async () => { + const { user } = createUser(testDb); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: 'trek://atlas/stats' }); + const data = parseResourceResult(result) as any; + expect(data).toBeDefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Resource: trek://atlas/regions +// --------------------------------------------------------------------------- + +describe('Resource: trek://atlas/regions', () => { + it('returns regions array', async () => { + const { user } = createUser(testDb); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: 'trek://atlas/regions' }); + const data = parseResourceResult(result) as any; + expect(Array.isArray(data)).toBe(true); + }); + }); + + it('returns inserted regions', async () => { + const { user } = createUser(testDb); + testDb.prepare( + 'INSERT INTO visited_regions (user_id, region_code, region_name, country_code) VALUES (?, ?, ?, ?)' + ).run(user.id, 'ES-CT', 'Catalonia', 'ES'); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: 'trek://atlas/regions' }); + const data = parseResourceResult(result) as any; + expect(data).toHaveLength(1); + expect(data[0].region_code).toBe('ES-CT'); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-budget-advanced.test.ts b/server/tests/unit/mcp/tools-budget-advanced.test.ts new file mode 100644 index 00000000..a45d95da --- /dev/null +++ b/server/tests/unit/mcp/tools-budget-advanced.test.ts @@ -0,0 +1,213 @@ +/** + * Unit tests for MCP budget advanced tools: + * set_budget_item_members, toggle_budget_member_paid. + * Resources: trek://trips/{tripId}/budget/per-person, trek://trips/{tripId}/budget/settlement. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, createBudgetItem } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: true }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// set_budget_item_members +// --------------------------------------------------------------------------- + +describe('Tool: set_budget_item_members', () => { + it('sets members and broadcasts budget:members-updated', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createBudgetItem(testDb, trip.id, { name: 'Flights', total_price: 500 }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_budget_item_members', + arguments: { tripId: trip.id, itemId: item.id, userIds: [user.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.item).toBeDefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:members-updated', expect.any(Object)); + }); + }); + + it('empty array clears members', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createBudgetItem(testDb, trip.id); + testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id) VALUES (?, ?)').run(item.id, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_budget_item_members', + arguments: { tripId: trip.id, itemId: item.id, userIds: [] }, + }); + const data = parseToolResult(result) as any; + expect(data.item).toBeDefined(); + const remaining = testDb.prepare('SELECT count(*) as cnt FROM budget_item_members WHERE budget_item_id = ?').get(item.id) as any; + expect(remaining.cnt).toBe(0); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const item = createBudgetItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_budget_item_members', + arguments: { tripId: trip.id, itemId: item.id, userIds: [] }, + }); + 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); + const item = createBudgetItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_budget_item_members', + arguments: { tripId: trip.id, itemId: item.id, userIds: [] }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// toggle_budget_member_paid +// --------------------------------------------------------------------------- + +describe('Tool: toggle_budget_member_paid', () => { + it('flips paid flag and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createBudgetItem(testDb, trip.id, { total_price: 200 }); + // Add member first + testDb.prepare('INSERT INTO budget_item_members (budget_item_id, user_id, paid) VALUES (?, ?, 0)').run(item.id, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'toggle_budget_member_paid', + arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true }, + }); + const data = parseToolResult(result) as any; + expect(data.member).toBeDefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget:member-paid-updated', expect.any(Object)); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const item = createBudgetItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'toggle_budget_member_paid', + arguments: { tripId: trip.id, itemId: item.id, memberId: user.id, paid: true }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Per-person resource +// --------------------------------------------------------------------------- + +describe('Resource: trek://trips/{tripId}/budget/per-person', () => { + it('returns array for trip with no items', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` }); + const data = JSON.parse(result.contents[0].text as string); + expect(Array.isArray(data)).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/per-person` }); + const data = JSON.parse(result.contents[0].text as string); + expect(data.error).toBeDefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Settlement resource +// --------------------------------------------------------------------------- + +describe('Resource: trek://trips/{tripId}/budget/settlement', () => { + it('returns settlement object for trip with no items', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/budget/settlement` }); + const data = JSON.parse(result.contents[0].text as string); + expect(data).toBeDefined(); + expect(Array.isArray(data.balances) || Array.isArray(data)).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-collab-polls-chat.test.ts b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts new file mode 100644 index 00000000..3d0f3651 --- /dev/null +++ b/server/tests/unit/mcp/tools-collab-polls-chat.test.ts @@ -0,0 +1,500 @@ +/** + * Unit tests for MCP collab polls and chat tools (collab addon-gated): + * list_collab_polls, create_collab_poll, vote_collab_poll, close_collab_poll, + * delete_collab_poll, list_collab_messages, send_collab_message, + * delete_collab_message, react_collab_message. + * Resources: trek://trips/{tripId}/collab/polls, trek://trips/{tripId}/collab/messages. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +vi.mock('../../../src/services/adminService', () => ({ + isAddonEnabled: vi.fn().mockReturnValue(true), +})); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: true }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// list_collab_polls +// --------------------------------------------------------------------------- + +describe('Tool: list_collab_polls', () => { + it('returns empty array initially', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'list_collab_polls', + arguments: { tripId: trip.id }, + }); + const data = parseToolResult(result) as any; + expect(Array.isArray(data.polls)).toBe(true); + expect(data.polls).toHaveLength(0); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_collab_polls', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// create_collab_poll +// --------------------------------------------------------------------------- + +describe('Tool: create_collab_poll', () => { + it('inserts poll with votes structure and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_collab_poll', + arguments: { + tripId: trip.id, + question: 'Where should we eat?', + options: ['Pizza', 'Sushi', 'Tacos'], + }, + }); + const data = parseToolResult(result) as any; + expect(data.poll).toBeDefined(); + expect(data.poll.question).toBe('Where should we eat?'); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:created', expect.any(Object)); + }); + }); + + 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_collab_poll', + arguments: { tripId: trip.id, question: 'Q?', options: ['A', 'B'] }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_collab_poll', + arguments: { tripId: trip.id, question: 'Q?', options: ['A', 'B'] }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// vote_collab_poll +// --------------------------------------------------------------------------- + +describe('Tool: vote_collab_poll', () => { + it('records vote and broadcasts collab:poll:voted', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // Create a poll directly in the DB + const pollId = (testDb.prepare( + `INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))` + ).run(trip.id, user.id, 'Best city?', JSON.stringify(['Paris', 'Rome'])) as any).lastInsertRowid; + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'vote_collab_poll', + arguments: { tripId: trip.id, pollId: Number(pollId), optionIndex: 0 }, + }); + const data = parseToolResult(result) as any; + expect(data.poll).toBeDefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:voted', expect.any(Object)); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'vote_collab_poll', + arguments: { tripId: trip.id, pollId: 1, optionIndex: 0 }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// close_collab_poll +// --------------------------------------------------------------------------- + +describe('Tool: close_collab_poll', () => { + it('sets closed flag and broadcasts collab:poll:closed', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const pollId = (testDb.prepare( + `INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))` + ).run(trip.id, user.id, 'Vote now?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid; + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'close_collab_poll', + arguments: { tripId: trip.id, pollId: Number(pollId) }, + }); + const data = parseToolResult(result) as any; + expect(data.poll).toBeDefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:closed', expect.any(Object)); + }); + }); + + it('returns error for non-existent poll', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'close_collab_poll', + arguments: { tripId: trip.id, pollId: 99999 }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'close_collab_poll', arguments: { tripId: trip.id, pollId: 1 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_collab_poll +// --------------------------------------------------------------------------- + +describe('Tool: delete_collab_poll', () => { + it('removes poll and broadcasts collab:poll:deleted', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const pollId = (testDb.prepare( + `INSERT INTO collab_polls (trip_id, user_id, question, options, created_at) VALUES (?, ?, ?, ?, datetime('now'))` + ).run(trip.id, user.id, 'Delete me?', JSON.stringify(['Yes', 'No'])) as any).lastInsertRowid; + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_collab_poll', + arguments: { tripId: trip.id, pollId: Number(pollId) }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:poll:deleted', expect.objectContaining({ pollId: Number(pollId) })); + expect(testDb.prepare('SELECT id FROM collab_polls WHERE id = ?').get(Number(pollId))).toBeUndefined(); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_collab_poll', arguments: { tripId: trip.id, pollId: 1 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// list_collab_messages +// --------------------------------------------------------------------------- + +describe('Tool: list_collab_messages', () => { + it('returns empty array initially', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'list_collab_messages', + arguments: { tripId: trip.id }, + }); + const data = parseToolResult(result) as any; + expect(Array.isArray(data.messages)).toBe(true); + expect(data.messages).toHaveLength(0); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_collab_messages', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// send_collab_message +// --------------------------------------------------------------------------- + +describe('Tool: send_collab_message', () => { + it('inserts message and broadcasts collab:message:created', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'send_collab_message', + arguments: { tripId: trip.id, text: 'Hello team!' }, + }); + const data = parseToolResult(result) as any; + expect(data.message).toBeDefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:created', expect.any(Object)); + }); + }); + + it('sends message with replyTo when parent exists', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const msgId = (testDb.prepare( + `INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))` + ).run(trip.id, user.id, 'Original message') as any).lastInsertRowid; + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'send_collab_message', + arguments: { tripId: trip.id, text: 'Reply here', replyTo: Number(msgId) }, + }); + const data = parseToolResult(result) as any; + expect(data.message).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: 'send_collab_message', + arguments: { tripId: trip.id, text: 'Hello!' }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'send_collab_message', arguments: { tripId: trip.id, text: 'Hi' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_collab_message +// --------------------------------------------------------------------------- + +describe('Tool: delete_collab_message', () => { + it('soft-deletes message and broadcasts collab:message:deleted', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const msgId = (testDb.prepare( + `INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))` + ).run(trip.id, user.id, 'To be deleted') as any).lastInsertRowid; + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_collab_message', + arguments: { tripId: trip.id, messageId: Number(msgId) }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:deleted', expect.any(Object)); + }); + }); + + it('returns error when message belongs to different user', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // Add other as trip member + testDb.prepare('INSERT INTO trip_members (trip_id, user_id) VALUES (?, ?)').run(trip.id, other.id); + const msgId = (testDb.prepare( + `INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))` + ).run(trip.id, user.id, 'Owner message') as any).lastInsertRowid; + + await withHarness(other.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_collab_message', + arguments: { tripId: trip.id, messageId: Number(msgId) }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_collab_message', arguments: { tripId: trip.id, messageId: 1 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// react_collab_message +// --------------------------------------------------------------------------- + +describe('Tool: react_collab_message', () => { + it('toggles reaction and broadcasts collab:message:reacted', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const msgId = (testDb.prepare( + `INSERT INTO collab_messages (trip_id, user_id, text, created_at) VALUES (?, ?, ?, datetime('now'))` + ).run(trip.id, user.id, 'React to me') as any).lastInsertRowid; + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'react_collab_message', + arguments: { tripId: trip.id, messageId: Number(msgId), emoji: '👍' }, + }); + const data = parseToolResult(result) as any; + expect(data.reactions).toBeDefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:message:reacted', expect.any(Object)); + }); + }); + + it('returns error for non-existent message', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'react_collab_message', + arguments: { tripId: trip.id, messageId: 99999, emoji: '👍' }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'react_collab_message', arguments: { tripId: trip.id, messageId: 1, emoji: '👍' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +describe('Resource: trek://trips/{tripId}/collab/polls', () => { + it('returns polls list', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/polls` }); + const data = parseResourceResult(result) as any; + expect(Array.isArray(data)).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/polls` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeDefined(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/collab/messages', () => { + it('returns messages list', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: `trek://trips/${trip.id}/collab/messages` }); + const data = parseResourceResult(result) as any; + expect(Array.isArray(data)).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-days-accommodations.test.ts b/server/tests/unit/mcp/tools-days-accommodations.test.ts new file mode 100644 index 00000000..5b8780fd --- /dev/null +++ b/server/tests/unit/mcp/tools-days-accommodations.test.ts @@ -0,0 +1,294 @@ +/** + * Unit tests for MCP day and accommodation tools: + * create_day, delete_day, + * create_accommodation, update_accommodation, delete_accommodation. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, createDay, createPlace, createDayAccommodation } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// create_day +// --------------------------------------------------------------------------- + +describe('Tool: create_day', () => { + it('creates a day with a date', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_day', + arguments: { tripId: trip.id, date: '2025-06-15', notes: 'Arrival day' }, + }); + const data = parseToolResult(result) as any; + expect(data.day).toBeDefined(); + expect(data.day.date).toBe('2025-06-15'); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:created', expect.any(Object)); + }); + }); + + it('creates a dateless day', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_day', + arguments: { tripId: trip.id }, + }); + const data = parseToolResult(result) as any; + expect(data.day).toBeDefined(); + expect(data.day.date).toBeNull(); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_day', arguments: { tripId: trip.id } }); + 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_day', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_day +// --------------------------------------------------------------------------- + +describe('Tool: delete_day', () => { + it('deletes a day and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_day', + arguments: { tripId: trip.id, dayId: day.id }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:deleted', { id: day.id }); + expect(testDb.prepare('SELECT id FROM days WHERE id = ?').get(day.id)).toBeUndefined(); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_day', arguments: { tripId: trip.id, dayId: day.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// create_accommodation +// --------------------------------------------------------------------------- + +describe('Tool: create_accommodation', () => { + it('creates an accommodation and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Hotel du Louvre' }); + const day1 = createDay(testDb, trip.id, { date: '2025-06-15' }); + const day2 = createDay(testDb, trip.id, { date: '2025-06-17' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_accommodation', + arguments: { + tripId: trip.id, + place_id: place.id, + start_day_id: day1.id, + end_day_id: day2.id, + check_in: '15:00', + check_out: '11:00', + confirmation: 'CONF123', + }, + }); + const data = parseToolResult(result) as any; + expect(data.accommodation).toBeDefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object)); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const place = createPlace(testDb, trip.id); + const day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_accommodation', + arguments: { tripId: trip.id, place_id: place.id, start_day_id: day.id, end_day_id: day.id }, + }); + 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); + const place = createPlace(testDb, trip.id); + const day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_accommodation', + arguments: { tripId: trip.id, place_id: place.id, start_day_id: day.id, end_day_id: day.id }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_accommodation +// --------------------------------------------------------------------------- + +describe('Tool: update_accommodation', () => { + it('updates accommodation fields and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + const day1 = createDay(testDb, trip.id); + const day2 = createDay(testDb, trip.id); + const acc = createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_accommodation', + arguments: { tripId: trip.id, accommodationId: acc.id, confirmation: 'NEW-CONF', check_in: '14:00' }, + }); + const data = parseToolResult(result) as any; + expect(data.accommodation).toBeDefined(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:updated', expect.any(Object)); + }); + }); + + it('returns error for non-existent accommodation', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_accommodation', + arguments: { tripId: trip.id, accommodationId: 99999, confirmation: 'X' }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_accommodation', + arguments: { tripId: trip.id, accommodationId: 1, confirmation: 'X' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_accommodation +// --------------------------------------------------------------------------- + +describe('Tool: delete_accommodation', () => { + it('deletes accommodation and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + const day1 = createDay(testDb, trip.id); + const day2 = createDay(testDb, trip.id); + const acc = createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_accommodation', + arguments: { tripId: trip.id, accommodationId: acc.id }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:deleted', expect.objectContaining({ id: acc.id })); + expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(acc.id)).toBeUndefined(); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_accommodation', arguments: { tripId: trip.id, accommodationId: 1 } }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-files.test.ts b/server/tests/unit/mcp/tools-files.test.ts new file mode 100644 index 00000000..5cdc46f5 --- /dev/null +++ b/server/tests/unit/mcp/tools-files.test.ts @@ -0,0 +1,456 @@ +/** + * Unit tests for MCP file tools: + * list_files, update_file_metadata, toggle_file_star, trash_file, restore_file, + * permanent_delete_file, empty_trash, link_file, unlink_file, list_file_links. + * Note: actual file-system deletion is not tested (files don't exist on disk in tests). + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +/** Helper: insert a fake file row directly (no actual file on disk needed) */ +function createFileRow(tripId: number, overrides: Partial<{ + filename: string; original_name: string; deleted_at: string | null; starred: number +}> = {}) { + const result = testDb.prepare(` + INSERT INTO trip_files (trip_id, filename, original_name, file_size, mime_type) + VALUES (?, ?, ?, ?, ?) + `).run( + tripId, + overrides.filename ?? `test-${Date.now()}.txt`, + overrides.original_name ?? 'test.txt', + 1024, + 'text/plain' + ); + const id = result.lastInsertRowid as number; + if (overrides.starred !== undefined) { + testDb.prepare('UPDATE trip_files SET starred = ? WHERE id = ?').run(overrides.starred, id); + } + if (overrides.deleted_at !== undefined) { + testDb.prepare('UPDATE trip_files SET deleted_at = ? WHERE id = ?').run(overrides.deleted_at, id); + } + return testDb.prepare('SELECT * FROM trip_files WHERE id = ?').get(id) as any; +} + +// --------------------------------------------------------------------------- +// list_files +// --------------------------------------------------------------------------- + +describe('Tool: list_files', () => { + it('returns empty list for a new trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_files', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.files).toEqual([]); + }); + }); + + it('returns active files', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createFileRow(trip.id, { original_name: 'doc.pdf' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_files', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.files).toHaveLength(1); + }); + }); + + it('returns trash when showTrash=true', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createFileRow(trip.id, { deleted_at: new Date().toISOString() }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_files', arguments: { tripId: trip.id, showTrash: true } }); + const data = parseToolResult(result) as any; + expect(data.files).toHaveLength(1); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_files', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_file_metadata +// --------------------------------------------------------------------------- + +describe('Tool: update_file_metadata', () => { + it('updates file description', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_file_metadata', + arguments: { tripId: trip.id, fileId: file.id, description: 'My document' }, + }); + const data = parseToolResult(result) as any; + expect(data.file.description).toBe('My document'); + }); + }); + + it('broadcasts file:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'update_file_metadata', + arguments: { tripId: trip.id, fileId: file.id, description: 'Updated' }, + }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:updated', expect.any(Object)); + }); + }); + + it('returns error for file not found', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_file_metadata', + arguments: { tripId: trip.id, fileId: 99999, description: 'X' }, + }); + 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); + const file = createFileRow(trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_file_metadata', + arguments: { tripId: trip.id, fileId: file.id, description: 'X' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// toggle_file_star +// --------------------------------------------------------------------------- + +describe('Tool: toggle_file_star', () => { + it('stars an unstarred file', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id, { starred: 0 }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_file_star', arguments: { tripId: trip.id, fileId: file.id } }); + const data = parseToolResult(result) as any; + expect(data.file.starred).toBe(1); + }); + }); + + it('unstars a starred file', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id, { starred: 1 }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_file_star', arguments: { tripId: trip.id, fileId: file.id } }); + const data = parseToolResult(result) as any; + expect(data.file.starred).toBe(0); + }); + }); + + it('broadcasts file:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'toggle_file_star', arguments: { tripId: trip.id, fileId: file.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:updated', expect.any(Object)); + }); + }); + + it('returns error for file not found', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_file_star', arguments: { tripId: trip.id, fileId: 99999 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// trash_file +// --------------------------------------------------------------------------- + +describe('Tool: trash_file', () => { + it('soft-deletes a file', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'trash_file', arguments: { tripId: trip.id, fileId: file.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + const dbFile = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file.id) as any; + expect(dbFile.deleted_at).toBeTruthy(); + }); + }); + + it('broadcasts file:deleted event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'trash_file', arguments: { tripId: trip.id, fileId: file.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:deleted', expect.any(Object)); + }); + }); + + it('returns error for file not found', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'trash_file', arguments: { tripId: trip.id, fileId: 99999 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// restore_file +// --------------------------------------------------------------------------- + +describe('Tool: restore_file', () => { + it('restores a trashed file', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id, { deleted_at: new Date().toISOString() }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'restore_file', arguments: { tripId: trip.id, fileId: file.id } }); + const data = parseToolResult(result) as any; + expect(data.file).toBeTruthy(); + const dbFile = testDb.prepare('SELECT deleted_at FROM trip_files WHERE id = ?').get(file.id) as any; + expect(dbFile.deleted_at).toBeNull(); + }); + }); + + it('broadcasts file:created event on restore', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id, { deleted_at: new Date().toISOString() }); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'restore_file', arguments: { tripId: trip.id, fileId: file.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'file:created', expect.any(Object)); + }); + }); + + it('returns error for file not in trash', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); // not in trash + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'restore_file', arguments: { tripId: trip.id, fileId: file.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// permanent_delete_file +// --------------------------------------------------------------------------- + +describe('Tool: permanent_delete_file', () => { + it('permanently removes a trashed file', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id, { deleted_at: new Date().toISOString() }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'permanent_delete_file', arguments: { tripId: trip.id, fileId: file.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM trip_files WHERE id = ?').get(file.id)).toBeUndefined(); + }); + }); + + it('returns error for file not in trash', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); // active file + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'permanent_delete_file', arguments: { tripId: trip.id, fileId: file.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// empty_trash +// --------------------------------------------------------------------------- + +describe('Tool: empty_trash', () => { + it('deletes all trashed files', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createFileRow(trip.id, { deleted_at: new Date().toISOString() }); + createFileRow(trip.id, { deleted_at: new Date().toISOString() }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'empty_trash', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(data.deleted).toBe(2); + }); + }); + + it('returns 0 when trash is already empty', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'empty_trash', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.deleted).toBe(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// link_file / unlink_file / list_file_links +// --------------------------------------------------------------------------- + +describe('Tool: link_file', () => { + it('creates a link to a place', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); + // Insert a fake place + const placeResult = testDb.prepare("INSERT INTO places (trip_id, name) VALUES (?, 'Test Place')").run(trip.id); + const placeId = placeResult.lastInsertRowid as number; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_file', + arguments: { tripId: trip.id, fileId: file.id, place_id: placeId }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(Array.isArray(data.links)).toBe(true); + }); + }); + + it('returns error for file not found', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'link_file', arguments: { tripId: trip.id, fileId: 99999, place_id: 1 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +describe('Tool: unlink_file', () => { + it('removes a file link', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); + // Insert a real place then a link + const placeRes = testDb.prepare("INSERT INTO places (trip_id, name) VALUES (?, 'P')").run(trip.id); + const placeId = placeRes.lastInsertRowid as number; + const linkResult = testDb.prepare( + 'INSERT INTO file_links (file_id, place_id) VALUES (?, ?)' + ).run(file.id, placeId); + const linkId = linkResult.lastInsertRowid as number; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'unlink_file', arguments: { tripId: trip.id, fileId: file.id, linkId } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM file_links WHERE id = ?').get(linkId)).toBeUndefined(); + }); + }); +}); + +describe('Tool: list_file_links', () => { + it('returns links for a file', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); + // Insert a real place then a link + const placeRes = testDb.prepare("INSERT INTO places (trip_id, name) VALUES (?, 'P')").run(trip.id); + const placeId = placeRes.lastInsertRowid as number; + testDb.prepare('INSERT INTO file_links (file_id, place_id) VALUES (?, ?)').run(file.id, placeId); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_file_links', arguments: { tripId: trip.id, fileId: file.id } }); + const data = parseToolResult(result) as any; + expect(data.links).toHaveLength(1); + }); + }); + + it('returns empty array for file with no links', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const file = createFileRow(trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_file_links', arguments: { tripId: trip.id, fileId: file.id } }); + const data = parseToolResult(result) as any; + expect(data.links).toHaveLength(0); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-notifications.test.ts b/server/tests/unit/mcp/tools-notifications.test.ts new file mode 100644 index 00000000..ede39cde --- /dev/null +++ b/server/tests/unit/mcp/tools-notifications.test.ts @@ -0,0 +1,338 @@ +/** + * Unit tests for MCP notification tools: + * list_notifications, get_unread_notification_count, mark_notification_read, + * mark_notification_unread, mark_all_notifications_read, delete_notification, + * delete_all_notifications. + * Also covers the resource trek://notifications/in-app. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +vi.mock('../../../src/websocket', () => ({ broadcast: vi.fn() })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +// --------------------------------------------------------------------------- +// Helper: insert a notification directly into the DB +// --------------------------------------------------------------------------- + +function createNotification(db: any, userId: number, overrides: any = {}) { + const r = db.prepare( + `INSERT INTO notifications (type, scope, target, recipient_id, title_key, text_key, is_read) + VALUES (?, ?, ?, ?, ?, ?, 0)` + ).run( + overrides.type ?? 'simple', + overrides.scope ?? 'user', + overrides.target ?? 0, + userId, + overrides.title_key ?? 'notification.test.title', + overrides.text_key ?? 'notification.test.body' + ); + return db.prepare('SELECT * FROM notifications WHERE id = ?').get(r.lastInsertRowid); +} + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withTools: false, withResources: true }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// list_notifications +// --------------------------------------------------------------------------- + +describe('Tool: list_notifications', () => { + it('returns empty array initially', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_notifications', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.notifications).toEqual([]); + }); + }); + + it('returns notifications when they exist', async () => { + const { user } = createUser(testDb); + createNotification(testDb, user.id, { title_key: 'notif.first' }); + createNotification(testDb, user.id, { title_key: 'notif.second' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_notifications', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.notifications).toHaveLength(2); + }); + }); + + it('returns only unread notifications when unread_only is true', async () => { + const { user } = createUser(testDb); + createNotification(testDb, user.id); + const read = createNotification(testDb, user.id) as any; + testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(read.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_notifications', arguments: { unread_only: true } }); + const data = parseToolResult(result) as any; + expect(data.notifications).toHaveLength(1); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_unread_notification_count +// --------------------------------------------------------------------------- + +describe('Tool: get_unread_notification_count', () => { + it('returns 0 initially', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.count).toBe(0); + }); + }); + + it('returns 1 after inserting one unread notification', async () => { + const { user } = createUser(testDb); + createNotification(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_unread_notification_count', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.count).toBe(1); + }); + }); +}); + +// --------------------------------------------------------------------------- +// mark_notification_read +// --------------------------------------------------------------------------- + +describe('Tool: mark_notification_read', () => { + it('flips is_read to 1 and returns success', async () => { + const { user } = createUser(testDb); + const notif = createNotification(testDb, user.id) as any; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'mark_notification_read', + arguments: { notificationId: notif.id }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any; + expect(row.is_read).toBe(1); + }); + }); + + it('returns isError for non-existent notification', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'mark_notification_read', + arguments: { notificationId: 99999 }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const notif = createNotification(testDb, user.id) as any; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'mark_notification_read', + arguments: { notificationId: notif.id }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// mark_notification_unread +// --------------------------------------------------------------------------- + +describe('Tool: mark_notification_unread', () => { + it('flips is_read to 0', async () => { + const { user } = createUser(testDb); + const notif = createNotification(testDb, user.id) as any; + testDb.prepare('UPDATE notifications SET is_read = 1 WHERE id = ?').run(notif.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'mark_notification_unread', + arguments: { notificationId: notif.id }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + const row = testDb.prepare('SELECT is_read FROM notifications WHERE id = ?').get(notif.id) as any; + expect(row.is_read).toBe(0); + }); + }); + + it('returns isError for non-existent notification', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'mark_notification_unread', + arguments: { notificationId: 99999 }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// mark_all_notifications_read +// --------------------------------------------------------------------------- + +describe('Tool: mark_all_notifications_read', () => { + it('marks all notifications read and returns count', async () => { + const { user } = createUser(testDb); + createNotification(testDb, user.id); + createNotification(testDb, user.id); + createNotification(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(data.count).toBe(3); + const unread = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ? AND is_read = 0').get(user.id) as any).c; + expect(unread).toBe(0); + }); + }); + + it('returns count 0 when nothing to mark', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'mark_all_notifications_read', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.count).toBe(0); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_notification +// --------------------------------------------------------------------------- + +describe('Tool: delete_notification', () => { + it('removes the notification row and returns success', async () => { + const { user } = createUser(testDb); + const notif = createNotification(testDb, user.id) as any; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_notification', + arguments: { notificationId: notif.id }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM notifications WHERE id = ?').get(notif.id)).toBeUndefined(); + }); + }); + + it('returns isError for non-existent notification', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_notification', + arguments: { notificationId: 99999 }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_all_notifications +// --------------------------------------------------------------------------- + +describe('Tool: delete_all_notifications', () => { + it('clears all notifications for user and returns count', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + createNotification(testDb, user.id); + createNotification(testDb, user.id); + createNotification(testDb, other.id); // should not be deleted + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_all_notifications', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(data.count).toBe(2); + const remaining = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ?').get(user.id) as any).c; + expect(remaining).toBe(0); + const otherRemaining = (testDb.prepare('SELECT COUNT(*) as c FROM notifications WHERE recipient_id = ?').get(other.id) as any).c; + expect(otherRemaining).toBe(1); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Resource: trek://notifications/in-app +// --------------------------------------------------------------------------- + +describe('Resource: trek://notifications/in-app', () => { + it('returns notifications list', async () => { + const { user } = createUser(testDb); + createNotification(testDb, user.id, { title_key: 'notif.test' }); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: 'trek://notifications/in-app' }); + const data = parseResourceResult(result) as any; + expect(data.notifications).toBeDefined(); + expect(Array.isArray(data.notifications)).toBe(true); + expect(data.notifications).toHaveLength(1); + }); + }); + + it('returns empty notifications for user with none', async () => { + const { user } = createUser(testDb); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: 'trek://notifications/in-app' }); + const data = parseResourceResult(result) as any; + expect(data.notifications).toEqual([]); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-packing-advanced.test.ts b/server/tests/unit/mcp/tools-packing-advanced.test.ts new file mode 100644 index 00000000..b47d6dc6 --- /dev/null +++ b/server/tests/unit/mcp/tools-packing-advanced.test.ts @@ -0,0 +1,459 @@ +/** + * Unit tests for MCP packing advanced tools: + * reorder_packing_items, list_packing_bags, create_packing_bag, update_packing_bag, + * delete_packing_bag, set_bag_members, get_packing_category_assignees, + * set_packing_category_assignees, apply_packing_template, save_packing_template, + * bulk_import_packing. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, createPackingItem } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// reorder_packing_items +// --------------------------------------------------------------------------- + +describe('Tool: reorder_packing_items', () => { + it('reorders packing items and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item1 = createPackingItem(testDb, trip.id, { name: 'Shirt' }); + const item2 = createPackingItem(testDb, trip.id, { name: 'Pants' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_packing_items', + arguments: { tripId: trip.id, orderedIds: [item2.id, item1.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:reordered', expect.any(Object)); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const item = createPackingItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_packing_items', + arguments: { tripId: trip.id, orderedIds: [item.id] }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// list_packing_bags +// --------------------------------------------------------------------------- + +describe('Tool: list_packing_bags', () => { + it('returns empty array initially', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'list_packing_bags', + arguments: { tripId: trip.id }, + }); + const data = parseToolResult(result) as any; + expect(data.bags).toEqual([]); + }); + }); + + it('returns bags that exist', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Carry-on', '#ff0000'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'list_packing_bags', + arguments: { tripId: trip.id }, + }); + const data = parseToolResult(result) as any; + expect(data.bags).toHaveLength(1); + expect(data.bags[0].name).toBe('Carry-on'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// create_packing_bag +// --------------------------------------------------------------------------- + +describe('Tool: create_packing_bag', () => { + it('creates a bag and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_packing_bag', + arguments: { tripId: trip.id, name: 'Checked bag', color: '#3b82f6' }, + }); + const data = parseToolResult(result) as any; + expect(data.bag).toBeDefined(); + expect(data.bag.name).toBe('Checked bag'); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-created', expect.any(Object)); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_packing_bag', + arguments: { tripId: trip.id, name: 'Bag' }, + }); + 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_packing_bag', + arguments: { tripId: trip.id, name: 'Bag' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_packing_bag +// --------------------------------------------------------------------------- + +describe('Tool: update_packing_bag', () => { + it('updates bag name and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Old Name', '#aabbcc'); + const bag = testDb.prepare('SELECT * FROM packing_bags WHERE id = ?').get(r.lastInsertRowid) as any; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_packing_bag', + arguments: { tripId: trip.id, bagId: bag.id, name: 'New Name' }, + }); + const data = parseToolResult(result) as any; + expect(data.bag).toBeDefined(); + expect(data.bag.name).toBe('New Name'); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-updated', expect.any(Object)); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_packing_bag', + arguments: { tripId: trip.id, bagId: 1, name: 'X' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_packing_bag +// --------------------------------------------------------------------------- + +describe('Tool: delete_packing_bag', () => { + it('deletes a bag and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'Delete Me', '#000000'); + const bagId = r.lastInsertRowid as number; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_packing_bag', + arguments: { tripId: trip.id, bagId }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-deleted', expect.any(Object)); + expect(testDb.prepare('SELECT id FROM packing_bags WHERE id = ?').get(bagId)).toBeUndefined(); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_packing_bag', + arguments: { tripId: trip.id, bagId: 1 }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// set_bag_members +// --------------------------------------------------------------------------- + +describe('Tool: set_bag_members', () => { + it('sets bag members and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456'); + const bagId = r.lastInsertRowid as number; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_bag_members', + arguments: { tripId: trip.id, bagId, userIds: [user.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:bag-members-updated', expect.any(Object)); + }); + }); + + it('clears bag members when passed empty array', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const r = testDb.prepare('INSERT INTO packing_bags (trip_id, name, color) VALUES (?, ?, ?)').run(trip.id, 'My Bag', '#123456'); + const bagId = r.lastInsertRowid as number; + testDb.prepare('INSERT OR IGNORE INTO packing_bag_members (bag_id, user_id) VALUES (?, ?)').run(bagId, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_bag_members', + arguments: { tripId: trip.id, bagId, userIds: [] }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_packing_category_assignees +// --------------------------------------------------------------------------- + +describe('Tool: get_packing_category_assignees', () => { + it('returns empty object initially', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'get_packing_category_assignees', + arguments: { tripId: trip.id }, + }); + const data = parseToolResult(result) as any; + expect(data.assignees).toEqual({}); + }); + }); +}); + +// --------------------------------------------------------------------------- +// set_packing_category_assignees +// --------------------------------------------------------------------------- + +describe('Tool: set_packing_category_assignees', () => { + it('sets category assignees and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_packing_category_assignees', + arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [user.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:assignees', expect.any(Object)); + }); + }); + + it('clears assignees when passed empty array', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + testDb.prepare('INSERT INTO packing_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Clothing', user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_packing_category_assignees', + arguments: { tripId: trip.id, categoryName: 'Clothing', userIds: [] }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_packing_category_assignees', + arguments: { tripId: trip.id, categoryName: 'Electronics', userIds: [] }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// apply_packing_template +// --------------------------------------------------------------------------- + +describe('Tool: apply_packing_template', () => { + it('returns error for non-existent template', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'apply_packing_template', + arguments: { tripId: trip.id, templateId: 99999 }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// save_packing_template +// --------------------------------------------------------------------------- + +describe('Tool: save_packing_template', () => { + it('saves the current packing list as a template', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createPackingItem(testDb, trip.id, { name: 'Toothbrush', category: 'Toiletries' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'save_packing_template', + arguments: { tripId: trip.id, templateName: 'Weekend Trip' }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'save_packing_template', + arguments: { tripId: trip.id, templateName: 'X' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// bulk_import_packing +// --------------------------------------------------------------------------- + +describe('Tool: bulk_import_packing', () => { + it('imports multiple packing items and count matches', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const items = [ + { name: 'Passport', category: 'Documents' }, + { name: 'Charger', category: 'Electronics' }, + { name: 'Sunscreen', category: 'Toiletries' }, + ]; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'bulk_import_packing', + arguments: { tripId: trip.id, items }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(data.count).toBe(items.length); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing:updated', expect.any(Object)); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'bulk_import_packing', + arguments: { tripId: trip.id, items: [{ name: 'Item' }] }, + }); + 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: 'bulk_import_packing', + arguments: { tripId: trip.id, items: [{ name: 'Item' }] }, + }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-tags-maps-weather.test.ts b/server/tests/unit/mcp/tools-tags-maps-weather.test.ts new file mode 100644 index 00000000..2238369d --- /dev/null +++ b/server/tests/unit/mcp/tools-tags-maps-weather.test.ts @@ -0,0 +1,312 @@ +/** + * Unit tests for MCP tag, maps extras, and weather tools: + * list_tags, create_tag, update_tag, delete_tag, + * get_place_details, reverse_geocode, resolve_maps_url, + * get_weather, get_detailed_weather. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +vi.mock('../../../src/services/mapsService', () => ({ + searchPlaces: vi.fn(), + getPlaceDetails: vi.fn().mockResolvedValue({ name: 'Eiffel Tower', address: 'Paris' }), + reverseGeocode: vi.fn().mockResolvedValue({ name: 'Paris', address: 'France' }), + resolveGoogleMapsUrl: vi.fn().mockResolvedValue({ lat: 48.8566, lng: 2.3522, name: 'Paris' }), +})); + +vi.mock('../../../src/services/weatherService', () => ({ + getWeather: vi.fn().mockResolvedValue({ temp: 20, condition: 'sunny' }), + getDetailedWeather: vi.fn().mockResolvedValue({ hourly: [] }), +})); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; +import * as mapsService from '../../../src/services/mapsService'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// list_tags +// --------------------------------------------------------------------------- + +describe('Tool: list_tags', () => { + it('returns empty array initially', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_tags', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.tags).toEqual([]); + }); + }); + + it('returns only tags belonging to the current user', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'My Tag', '#ff0000'); + testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(other.id, 'Other Tag', '#00ff00'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_tags', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.tags).toHaveLength(1); + expect(data.tags[0].name).toBe('My Tag'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// create_tag +// --------------------------------------------------------------------------- + +describe('Tool: create_tag', () => { + it('creates a tag and returns the tag object', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_tag', + arguments: { name: 'Adventure', color: '#ff5500' }, + }); + const data = parseToolResult(result) as any; + expect(data.tag).toBeDefined(); + expect(data.tag.name).toBe('Adventure'); + expect(data.tag.color).toBe('#ff5500'); + expect(data.tag.user_id).toBe(user.id); + }); + }); + + it('creates a tag with only a name', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_tag', + arguments: { name: 'Food' }, + }); + const data = parseToolResult(result) as any; + expect(data.tag.name).toBe('Food'); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_tag', + arguments: { name: 'Blocked' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_tag +// --------------------------------------------------------------------------- + +describe('Tool: update_tag', () => { + it('updates tag name and color', async () => { + const { user } = createUser(testDb); + const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'Old Name', '#aaaaaa'); + const tagId = r.lastInsertRowid as number; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_tag', + arguments: { tagId, name: 'New Name', color: '#bbbbbb' }, + }); + const data = parseToolResult(result) as any; + expect(data.tag).toBeDefined(); + expect(data.tag.name).toBe('New Name'); + expect(data.tag.color).toBe('#bbbbbb'); + }); + }); + + it('returns isError for non-existent tagId', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_tag', + arguments: { tagId: 99999, name: 'X' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_tag +// --------------------------------------------------------------------------- + +describe('Tool: delete_tag', () => { + it('removes the tag row', async () => { + const { user } = createUser(testDb); + const r = testDb.prepare('INSERT INTO tags (user_id, name, color) VALUES (?, ?, ?)').run(user.id, 'To Delete', '#cccccc'); + const tagId = r.lastInsertRowid as number; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'delete_tag', + arguments: { tagId }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM tags WHERE id = ?').get(tagId)).toBeUndefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_place_details +// --------------------------------------------------------------------------- + +describe('Tool: get_place_details', () => { + it('returns details from mocked service', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'get_place_details', + arguments: { placeId: 'ChIJD7fiBh9u5kcRYJSMaMOCCwQ' }, + }); + const data = parseToolResult(result) as any; + expect(data.details).toBeDefined(); + expect(data.details.name).toBe('Eiffel Tower'); + }); + }); + + it('returns isError when service returns null', async () => { + const { getPlaceDetails } = await import('../../../src/services/mapsService'); + (getPlaceDetails as ReturnType).mockResolvedValueOnce(null); + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'get_place_details', + arguments: { placeId: 'nonexistent-place-id' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// reverse_geocode +// --------------------------------------------------------------------------- + +describe('Tool: reverse_geocode', () => { + it('returns result from mocked service', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reverse_geocode', + arguments: { lat: 48.8566, lng: 2.3522 }, + }); + const data = parseToolResult(result) as any; + expect(data.name).toBe('Paris'); + expect(data.address).toBe('France'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// resolve_maps_url +// --------------------------------------------------------------------------- + +describe('Tool: resolve_maps_url', () => { + it('returns result from mocked service', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'resolve_maps_url', + arguments: { url: 'https://maps.app.goo.gl/example' }, + }); + const data = parseToolResult(result) as any; + expect(data.lat).toBe(48.8566); + expect(data.lng).toBe(2.3522); + expect(data.name).toBe('Paris'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_weather +// --------------------------------------------------------------------------- + +describe('Tool: get_weather', () => { + it('returns weather from mocked service', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'get_weather', + arguments: { lat: 48.8566, lng: 2.3522, date: '2025-07-01' }, + }); + const data = parseToolResult(result) as any; + expect(data.weather).toBeDefined(); + expect(data.weather.temp).toBe(20); + expect(data.weather.condition).toBe('sunny'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_detailed_weather +// --------------------------------------------------------------------------- + +describe('Tool: get_detailed_weather', () => { + it('returns detailed weather from mocked service', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'get_detailed_weather', + arguments: { lat: 48.8566, lng: 2.3522, date: '2025-07-01' }, + }); + const data = parseToolResult(result) as any; + expect(data.weather).toBeDefined(); + expect(Array.isArray(data.weather.hourly)).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-todos.test.ts b/server/tests/unit/mcp/tools-todos.test.ts new file mode 100644 index 00000000..79bd1ebd --- /dev/null +++ b/server/tests/unit/mcp/tools-todos.test.ts @@ -0,0 +1,438 @@ +/** + * Unit tests for MCP todo tools: + * create_todo, update_todo, toggle_todo, delete_todo, reorder_todos, + * list_todos, get_todo_category_assignees, set_todo_category_assignees. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, createTodoItem } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// list_todos +// --------------------------------------------------------------------------- + +describe('Tool: list_todos', () => { + it('returns empty list for a new trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.items).toEqual([]); + }); + }); + + it('returns todos ordered by sort_order', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createTodoItem(testDb, trip.id, { name: 'First' }); + createTodoItem(testDb, trip.id, { name: 'Second' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.items).toHaveLength(2); + expect(data.items[0].name).toBe('First'); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_todos', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// create_todo +// --------------------------------------------------------------------------- + +describe('Tool: create_todo', () => { + it('creates a todo item with all fields', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_todo', + arguments: { + tripId: trip.id, + name: 'Book hotel', + category: 'Booking', + due_date: '2025-06-01', + description: 'Find a good deal', + priority: 2, + }, + }); + const data = parseToolResult(result) as any; + expect(data.item.name).toBe('Book hotel'); + expect(data.item.category).toBe('Booking'); + expect(data.item.due_date).toBe('2025-06-01'); + expect(data.item.priority).toBe(2); + expect(data.item.checked).toBe(0); + }); + }); + + it('creates a minimal todo item', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_todo', + arguments: { tripId: trip.id, name: 'Pack bags' }, + }); + const data = parseToolResult(result) as any; + expect(data.item.name).toBe('Pack bags'); + expect(data.item.checked).toBe(0); + }); + }); + + it('broadcasts todo:created event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'Test' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:created', expect.any(Object)); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_todo', arguments: { tripId: trip.id, name: 'X' } }); + 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_todo', arguments: { tripId: trip.id, name: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_todo +// --------------------------------------------------------------------------- + +describe('Tool: update_todo', () => { + it('updates todo name and category', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createTodoItem(testDb, trip.id, { name: 'Old name', category: 'General' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_todo', + arguments: { tripId: trip.id, itemId: item.id, name: 'New name', category: 'Booking' }, + }); + const data = parseToolResult(result) as any; + expect(data.item.name).toBe('New name'); + expect(data.item.category).toBe('Booking'); + }); + }); + + it('clears due_date when passed null', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + testDb.prepare("INSERT INTO todo_items (trip_id, name, checked, sort_order, due_date) VALUES (?, 'Task', 0, 0, '2025-01-01')").run(trip.id); + const item = testDb.prepare('SELECT * FROM todo_items WHERE trip_id = ? ORDER BY id DESC LIMIT 1').get(trip.id) as any; + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_todo', + arguments: { tripId: trip.id, itemId: item.id, due_date: null }, + }); + const data = parseToolResult(result) as any; + expect(data.item.due_date).toBeNull(); + }); + }); + + it('broadcasts todo:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createTodoItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object)); + }); + }); + + it('returns error for item not found', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: 99999, name: 'X' } }); + expect(result.isError).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const item = createTodoItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_todo', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// toggle_todo +// --------------------------------------------------------------------------- + +describe('Tool: toggle_todo', () => { + it('marks a todo as done', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createTodoItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'toggle_todo', + arguments: { tripId: trip.id, itemId: item.id, checked: true }, + }); + const data = parseToolResult(result) as any; + expect(data.item.checked).toBe(1); + }); + }); + + it('unchecks a done todo', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createTodoItem(testDb, trip.id, { checked: 1 }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'toggle_todo', + arguments: { tripId: trip.id, itemId: item.id, checked: false }, + }); + const data = parseToolResult(result) as any; + expect(data.item.checked).toBe(0); + }); + }); + + it('broadcasts todo:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createTodoItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'toggle_todo', arguments: { tripId: trip.id, itemId: item.id, checked: true } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:updated', expect.any(Object)); + }); + }); + + it('returns error for item not found', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_todo', arguments: { tripId: trip.id, itemId: 99999, checked: true } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_todo +// --------------------------------------------------------------------------- + +describe('Tool: delete_todo', () => { + it('deletes an existing todo item', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createTodoItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM todo_items WHERE id = ?').get(item.id)).toBeUndefined(); + }); + }); + + it('broadcasts todo:deleted event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createTodoItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:deleted', expect.any(Object)); + }); + }); + + it('returns error for item not found', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: 99999 } }); + expect(result.isError).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + const item = createTodoItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_todo', arguments: { tripId: trip.id, itemId: item.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// reorder_todos +// --------------------------------------------------------------------------- + +describe('Tool: reorder_todos', () => { + it('reorders todo items', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item1 = createTodoItem(testDb, trip.id, { name: 'First' }); + const item2 = createTodoItem(testDb, trip.id, { name: 'Second' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_todos', + arguments: { tripId: trip.id, orderedIds: [item2.id, item1.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + // item2 should now have sort_order 0 + const updated = testDb.prepare('SELECT sort_order FROM todo_items WHERE id = ?').get(item2.id) as any; + expect(updated.sort_order).toBe(0); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'reorder_todos', arguments: { tripId: trip.id, orderedIds: [1] } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_todo_category_assignees +// --------------------------------------------------------------------------- + +describe('Tool: get_todo_category_assignees', () => { + it('returns empty object for a new trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_todo_category_assignees', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.assignees).toEqual({}); + }); + }); +}); + +// --------------------------------------------------------------------------- +// set_todo_category_assignees +// --------------------------------------------------------------------------- + +describe('Tool: set_todo_category_assignees', () => { + it('sets category assignees and broadcasts', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_todo_category_assignees', + arguments: { tripId: trip.id, categoryName: 'Booking', userIds: [user.id] }, + }); + const data = parseToolResult(result) as any; + expect(Array.isArray(data.assignees)).toBe(true); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'todo:assignees', expect.any(Object)); + }); + }); + + it('clears assignees when passed empty array', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // Set then clear + testDb.prepare('INSERT INTO todo_category_assignees (trip_id, category_name, user_id) VALUES (?, ?, ?)').run(trip.id, 'Booking', user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_todo_category_assignees', + arguments: { tripId: trip.id, categoryName: 'Booking', userIds: [] }, + }); + const data = parseToolResult(result) as any; + expect(data.assignees).toEqual([]); + }); + }); + + 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: 'set_todo_category_assignees', + arguments: { tripId: trip.id, categoryName: 'Test', userIds: [] }, + }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-trip-members.test.ts b/server/tests/unit/mcp/tools-trip-members.test.ts new file mode 100644 index 00000000..66de9f59 --- /dev/null +++ b/server/tests/unit/mcp/tools-trip-members.test.ts @@ -0,0 +1,378 @@ +/** + * Unit tests for MCP trip member, copy, ICS, and share-link tools: + * list_trip_members, add_trip_member, remove_trip_member, + * copy_trip, export_trip_ics, get_share_link, create_share_link, delete_share_link. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, addTripMember } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// list_trip_members +// --------------------------------------------------------------------------- + +describe('Tool: list_trip_members', () => { + it('returns owner and empty members list for own trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.owner.id).toBe(user.id); + expect(data.owner.role).toBe('owner'); + expect(Array.isArray(data.members)).toBe(true); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_trip_members', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// add_trip_member +// --------------------------------------------------------------------------- + +describe('Tool: add_trip_member', () => { + it('adds a member by username', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_trip_member', + arguments: { tripId: trip.id, identifier: member.username }, + }); + const data = parseToolResult(result) as any; + expect(data.member.username).toBe(member.username); + expect(data.member.role).toBe('member'); + }); + }); + + it('broadcasts member:added event', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + await withHarness(owner.id, async (h) => { + await h.client.callTool({ + name: 'add_trip_member', + arguments: { tripId: trip.id, identifier: member.email }, + }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:added', expect.any(Object)); + }); + }); + + it('returns error when user not found', 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: 'add_trip_member', + arguments: { tripId: trip.id, identifier: 'nonexistent@example.com' }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns error when non-owner tries to add', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const { user: outsider } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + await withHarness(member.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_trip_member', + arguments: { tripId: trip.id, identifier: outsider.username }, + }); + 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: 'add_trip_member', + arguments: { tripId: trip.id, identifier: 'someone@example.com' }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// remove_trip_member +// --------------------------------------------------------------------------- + +describe('Tool: remove_trip_member', () => { + it('removes a member', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + await withHarness(owner.id, async (h) => { + const result = await h.client.callTool({ + name: 'remove_trip_member', + arguments: { tripId: trip.id, memberId: member.id }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + const row = testDb.prepare('SELECT * FROM trip_members WHERE trip_id = ? AND user_id = ?').get(trip.id, member.id); + expect(row).toBeUndefined(); + }); + }); + + it('broadcasts member:removed event', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + await withHarness(owner.id, async (h) => { + await h.client.callTool({ name: 'remove_trip_member', arguments: { tripId: trip.id, memberId: member.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'member:removed', expect.any(Object)); + }); + }); + + it('returns error when non-owner tries to remove', async () => { + const { user: owner } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, member.id); + await withHarness(member.id, async (h) => { + const result = await h.client.callTool({ + name: 'remove_trip_member', + arguments: { tripId: trip.id, memberId: owner.id }, + }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// copy_trip +// --------------------------------------------------------------------------- + +describe('Tool: copy_trip', () => { + it('duplicates a trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Original', start_date: '2025-01-01', end_date: '2025-01-03' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.trip).toBeTruthy(); + // New trip should be a different row + const count = testDb.prepare('SELECT COUNT(*) as cnt FROM trips').get() as any; + expect(count.cnt).toBe(2); + }); + }); + + it('uses custom title when provided', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Original' }); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id, title: 'My Copy' } }); + const newTrip = testDb.prepare("SELECT * FROM trips WHERE title = 'My Copy'").get() as any; + expect(newTrip).toBeTruthy(); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'copy_trip', arguments: { tripId: trip.id } }); + 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: 'copy_trip', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// export_trip_ics +// --------------------------------------------------------------------------- + +describe('Tool: export_trip_ics', () => { + it('returns ICS content for a trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip', start_date: '2025-06-01', end_date: '2025-06-05' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.ics).toContain('BEGIN:VCALENDAR'); + expect(data.ics).toContain('Paris Trip'); + expect(data.filename).toMatch(/\.ics$/); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'export_trip_ics', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_share_link / create_share_link / delete_share_link +// --------------------------------------------------------------------------- + +describe('Tool: get_share_link', () => { + it('returns null when no share link exists', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.link).toBeNull(); + }); + }); + + it('returns share link info when it exists', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + // Create a share link directly + testDb.prepare( + 'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)' + ).run(trip.id, 'test-token-123', user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_share_link', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.link.token).toBe('test-token-123'); + expect(data.link.share_map).toBe(true); + }); + }); +}); + +describe('Tool: create_share_link', () => { + it('creates a new share link', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_share_link', + arguments: { tripId: trip.id, share_map: true, share_bookings: false, share_packing: false }, + }); + const data = parseToolResult(result) as any; + expect(data.token).toBeTruthy(); + expect(data.created).toBe(true); + }); + }); + + it('updates existing share link permissions', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + testDb.prepare( + 'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)' + ).run(trip.id, 'existing-token', user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_share_link', + arguments: { tripId: trip.id, share_packing: true }, + }); + const data = parseToolResult(result) as any; + expect(data.created).toBe(false); // updated, not created + }); + }); + + 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_share_link', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +describe('Tool: delete_share_link', () => { + it('revokes the share link', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + testDb.prepare( + 'INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, 1, 1, 0, 0, 0)' + ).run(trip.id, 'to-delete', user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_share_link', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + const row = testDb.prepare('SELECT token FROM share_tokens WHERE trip_id = ?').get(trip.id); + expect(row).toBeUndefined(); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-trips.test.ts b/server/tests/unit/mcp/tools-trips.test.ts index d97baf39..c1499426 100644 --- a/server/tests/unit/mcp/tools-trips.test.ts +++ b/server/tests/unit/mcp/tools-trips.test.ts @@ -337,4 +337,18 @@ describe('Tool: get_trip_summary', () => { expect(data.trip.title).toBe('Demo Trip'); }); }); + + it('includes todos, files, pollCount, messageCount in response', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Summary Test' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(Array.isArray(data.todos)).toBe(true); + expect(Array.isArray(data.files)).toBe(true); + expect(typeof data.pollCount).toBe('number'); + expect(typeof data.messageCount).toBe('number'); + }); + }); }); diff --git a/server/tests/unit/mcp/tools-vacay.test.ts b/server/tests/unit/mcp/tools-vacay.test.ts new file mode 100644 index 00000000..74dae706 --- /dev/null +++ b/server/tests/unit/mcp/tools-vacay.test.ts @@ -0,0 +1,477 @@ +/** + * Unit tests for MCP vacay tools (vacay addon-gated): + * get_vacay_plan, update_vacay_plan, set_vacay_color, + * list_vacay_years, add_vacay_year, delete_vacay_year, + * get_vacay_entries, toggle_vacay_entry, toggle_company_holiday, + * get_vacay_stats, update_vacay_stats, + * add_holiday_calendar, update_holiday_calendar, delete_holiday_calendar, + * list_holiday_countries, list_holidays. + * Resources: trek://vacay/plan, trek://vacay/entries/{year}. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; + +const { testDb, dbMock } = vi.hoisted(() => { + const Database = require('better-sqlite3'); + const db = new Database(':memory:'); + db.exec('PRAGMA journal_mode = WAL'); + db.exec('PRAGMA foreign_keys = ON'); + db.exec('PRAGMA busy_timeout = 5000'); + const mock = { + db, + closeDb: () => {}, + reinitialize: () => {}, + getPlaceWithTags: () => null, + canAccessTrip: (tripId: any, userId: number) => + db.prepare(`SELECT t.id, t.user_id FROM trips t LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = ? WHERE t.id = ? AND (t.user_id = ? OR m.user_id IS NOT NULL)`).get(userId, tripId, userId), + isOwner: (tripId: any, userId: number) => + !!db.prepare('SELECT id FROM trips WHERE id = ? AND user_id = ?').get(tripId, userId), + }; + return { testDb: db, dbMock: mock }; +}); + +vi.mock('../../../src/db/database', () => dbMock); +vi.mock('../../../src/config', () => ({ + JWT_SECRET: 'test-jwt-secret-for-trek-testing-only', + ENCRYPTION_KEY: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2a3b4c5d6a7b8c9d0e1f2', + updateJwtSecret: () => {}, +})); + +const { broadcastMock } = vi.hoisted(() => ({ broadcastMock: vi.fn() })); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); + +vi.mock('../../../src/services/adminService', () => ({ + isAddonEnabled: vi.fn().mockReturnValue(true), +})); + +// Mock async service functions that make external calls +vi.mock('../../../src/services/vacayService', async (importOriginal) => { + const original = await importOriginal() as Record; + return { + ...original, + updatePlan: vi.fn().mockResolvedValue(undefined), + getCountries: vi.fn().mockResolvedValue({ data: [{ code: 'US', name: 'United States' }] }), + getHolidays: vi.fn().mockResolvedValue({ data: [{ date: '2025-01-01', name: 'New Year' }] }), + }; +}); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + delete process.env.DEMO_MODE; +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: false }); + try { await fn(h); } finally { await h.cleanup(); } +} + +async function withResourceHarness(userId: number, fn: (h: McpHarness) => Promise) { + const h = await createMcpHarness({ userId, withResources: true }); + try { await fn(h); } finally { await h.cleanup(); } +} + +// --------------------------------------------------------------------------- +// get_vacay_plan +// --------------------------------------------------------------------------- + +describe('Tool: get_vacay_plan', () => { + it('returns plan data object', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_vacay_plan', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.plan).toBeDefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_vacay_plan +// --------------------------------------------------------------------------- + +describe('Tool: update_vacay_plan', () => { + it('calls updatePlan and returns success', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_vacay_plan', + arguments: { block_weekends: true, holidays_enabled: false }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_vacay_plan', arguments: { block_weekends: true } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// set_vacay_color +// --------------------------------------------------------------------------- + +describe('Tool: set_vacay_color', () => { + it('updates color and returns success', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#6366f1' } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'set_vacay_color', arguments: { color: '#ff0000' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// list_vacay_years +// --------------------------------------------------------------------------- + +describe('Tool: list_vacay_years', () => { + it('returns years array', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_vacay_years', arguments: {} }); + const data = parseToolResult(result) as any; + expect(Array.isArray(data.years)).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// add_vacay_year +// --------------------------------------------------------------------------- + +describe('Tool: add_vacay_year', () => { + it('adds year to list', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } }); + const data = parseToolResult(result) as any; + expect(Array.isArray(data.years)).toBe(true); + expect(data.years).toContain(2025); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_vacay_year +// --------------------------------------------------------------------------- + +describe('Tool: delete_vacay_year', () => { + it('removes year from list', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + // Add year first + await h.client.callTool({ name: 'add_vacay_year', arguments: { year: 2025 } }); + const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } }); + const data = parseToolResult(result) as any; + expect(Array.isArray(data.years)).toBe(true); + expect(data.years).not.toContain(2025); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_vacay_year', arguments: { year: 2025 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_vacay_entries +// --------------------------------------------------------------------------- + +describe('Tool: get_vacay_entries', () => { + it('returns entries array (empty initially)', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_vacay_entries', arguments: { year: 2025 } }); + const data = parseToolResult(result) as any; + expect(data.entries).toBeDefined(); + expect(Array.isArray(data.entries.entries)).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// toggle_vacay_entry +// --------------------------------------------------------------------------- + +describe('Tool: toggle_vacay_entry', () => { + it('toggles entry and returns action', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } }); + const data = parseToolResult(result) as any; + expect(data.action).toBeDefined(); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_vacay_entry', arguments: { date: '2025-06-15' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// toggle_company_holiday +// --------------------------------------------------------------------------- + +describe('Tool: toggle_company_holiday', () => { + it('toggles company holiday and returns action', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'toggle_company_holiday', + arguments: { date: '2025-12-25', note: 'Christmas' }, + }); + const data = parseToolResult(result) as any; + expect(data.action).toBeDefined(); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_company_holiday', arguments: { date: '2025-12-25' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_vacay_stats +// --------------------------------------------------------------------------- + +describe('Tool: get_vacay_stats', () => { + it('returns stats object', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_vacay_stats', arguments: { year: 2025 } }); + const data = parseToolResult(result) as any; + expect(data.stats).toBeDefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_vacay_stats +// --------------------------------------------------------------------------- + +describe('Tool: update_vacay_stats', () => { + it('updates vacation days allowance and returns success', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 25 } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_vacay_stats', arguments: { year: 2025, vacationDays: 20 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// add_holiday_calendar +// --------------------------------------------------------------------------- + +describe('Tool: add_holiday_calendar', () => { + it('inserts calendar row and returns calendar', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'add_holiday_calendar', + arguments: { region: 'US', label: 'US Holidays', color: '#ff0000' }, + }); + const data = parseToolResult(result) as any; + expect(data.calendar).toBeDefined(); + expect(data.calendar.region).toBe('US'); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'add_holiday_calendar', arguments: { region: 'US' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_holiday_calendar +// --------------------------------------------------------------------------- + +describe('Tool: update_holiday_calendar', () => { + it('updates label and color', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + // First add a calendar + const addResult = await h.client.callTool({ + name: 'add_holiday_calendar', + arguments: { region: 'DE', label: 'Germany' }, + }); + const added = parseToolResult(addResult) as any; + const calId = added.calendar.id; + + const result = await h.client.callTool({ + name: 'update_holiday_calendar', + arguments: { calendarId: calId, label: 'German Holidays', color: '#00ff00' }, + }); + const data = parseToolResult(result) as any; + expect(data.calendar).toBeDefined(); + expect(data.calendar.label).toBe('German Holidays'); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_holiday_calendar', arguments: { calendarId: 1, label: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_holiday_calendar +// --------------------------------------------------------------------------- + +describe('Tool: delete_holiday_calendar', () => { + it('removes calendar and returns success', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const addResult = await h.client.callTool({ + name: 'add_holiday_calendar', + arguments: { region: 'FR' }, + }); + const added = parseToolResult(addResult) as any; + const calId = added.calendar.id; + + const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: calId } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_holiday_calendar', arguments: { calendarId: 1 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// list_holiday_countries +// --------------------------------------------------------------------------- + +describe('Tool: list_holiday_countries', () => { + it('returns countries from mocked service', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_holiday_countries', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.countries).toBeDefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// list_holidays +// --------------------------------------------------------------------------- + +describe('Tool: list_holidays', () => { + it('returns holidays from mocked service', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_holidays', arguments: { country: 'US', year: 2025 } }); + const data = parseToolResult(result) as any; + expect(data.holidays).toBeDefined(); + }); + }); +}); + +// --------------------------------------------------------------------------- +// Resources +// --------------------------------------------------------------------------- + +describe('Resource: trek://vacay/plan', () => { + it('returns plan data', async () => { + const { user } = createUser(testDb); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: 'trek://vacay/plan' }); + const data = parseResourceResult(result) as any; + expect(data).toBeDefined(); + }); + }); +}); + +describe('Resource: trek://vacay/entries/{year}', () => { + it('returns entries for a year', async () => { + const { user } = createUser(testDb); + await withResourceHarness(user.id, async (h) => { + const result = await h.client.readResource({ uri: 'trek://vacay/entries/2025' }); + const data = parseResourceResult(result) as any; + expect(data).toBeDefined(); + expect(Array.isArray(data.entries)).toBe(true); + }); + }); +});