diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index 154eac5..130aaa0 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -1,11 +1,10 @@ import { Request, Response } from 'express'; -import { randomUUID, createHash } from 'crypto'; -import jwt from 'jsonwebtoken'; +import { randomUUID } from 'crypto'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp'; -import { JWT_SECRET } from '../config'; -import { db } from '../db/database'; import { User } from '../types'; +import { verifyMcpToken, verifyJwtToken } from '../services/authService'; +import { isAddonEnabled } from '../services/adminService'; import { registerResources } from './resources'; import { registerTools } from './tools'; @@ -74,36 +73,15 @@ function verifyToken(authHeader: string | undefined): User | null { // Long-lived MCP API token (trek_...) if (token.startsWith('trek_')) { - const hash = createHash('sha256').update(token).digest('hex'); - const row = db.prepare(` - SELECT u.id, u.username, u.email, u.role - FROM mcp_tokens mt - JOIN users u ON mt.user_id = u.id - WHERE mt.token_hash = ? - `).get(hash) as User | undefined; - if (row) { - // Update last_used_at (fire-and-forget, non-blocking) - db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash); - return row; - } - return null; + return verifyMcpToken(token); } // Short-lived JWT - try { - const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; - const user = db.prepare( - 'SELECT id, username, email, role FROM users WHERE id = ?' - ).get(decoded.id) as User | undefined; - return user || null; - } catch { - return null; - } + return verifyJwtToken(token); } export async function mcpHandler(req: Request, res: Response): Promise { - const mcpAddon = db.prepare("SELECT enabled FROM addons WHERE id = 'mcp'").get() as { enabled: number } | undefined; - if (!mcpAddon || !mcpAddon.enabled) { + if (!isAddonEnabled('mcp')) { res.status(403).json({ error: 'MCP is not enabled' }); return; } diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index a077419..b339783 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -1,16 +1,15 @@ import { McpServer, ResourceTemplate } from '@modelcontextprotocol/sdk/server/mcp'; -import { db, canAccessTrip } from '../db/database'; - -const TRIP_SELECT = ` - SELECT t.*, - (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count, - (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count, - CASE WHEN t.user_id = :userId THEN 1 ELSE 0 END as is_owner, - u.username as owner_username, - (SELECT COUNT(*) FROM trip_members tm WHERE tm.trip_id = t.id) as shared_count - FROM trips t - JOIN users u ON u.id = t.user_id -`; +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 { listReservations } from '../services/reservationService'; +import { listNotes as listDayNotes } from '../services/dayNoteService'; +import { listNotes as listCollabNotes } from '../services/collabService'; +import { listCategories } from '../services/categoryService'; +import { listBucketList, listVisitedCountries } from '../services/atlasService'; function parseId(value: string | string[]): number | null { const n = Number(Array.isArray(value) ? value[0] : value); @@ -44,12 +43,7 @@ export function registerResources(server: McpServer, userId: number): void { 'trek://trips', { description: 'All trips the user owns or is a member of' }, async (uri) => { - const trips = db.prepare(` - ${TRIP_SELECT} - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId - WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) AND t.is_archived = 0 - ORDER BY t.created_at DESC - `).all({ userId }); + const trips = listTrips(userId, 0); return jsonContent(uri.href, trips); } ); @@ -62,11 +56,7 @@ export function registerResources(server: McpServer, userId: number): void { async (uri, { tripId }) => { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const trip = db.prepare(` - ${TRIP_SELECT} - LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId - WHERE t.id = :tripId AND (t.user_id = :userId OR m.user_id IS NOT NULL) - `).get({ userId, tripId: id }); + const trip = getTrip(id, userId); return jsonContent(uri.href, trip); } ); @@ -80,35 +70,8 @@ export function registerResources(server: McpServer, userId: number): void { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const days = db.prepare( - 'SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC' - ).all(id) as { id: number; day_number: number; date: string | null; title: string | null; notes: string | null }[]; - - const dayIds = days.map(d => d.id); - const assignmentsByDay: Record = {}; - - if (dayIds.length > 0) { - const placeholders = dayIds.map(() => '?').join(','); - const assignments = db.prepare(` - SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes, - p.id as place_id, p.name, p.address, p.lat, p.lng, p.category_id, - COALESCE(da.assignment_time, p.place_time) as place_time, - c.name as category_name, c.color as category_color, c.icon as category_icon - FROM day_assignments da - JOIN places p ON da.place_id = p.id - LEFT JOIN categories c ON p.category_id = c.id - WHERE da.day_id IN (${placeholders}) - ORDER BY da.order_index ASC, da.created_at ASC - `).all(...dayIds) as (Record & { day_id: number })[]; - - for (const a of assignments) { - if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = []; - assignmentsByDay[a.day_id].push(a); - } - } - - const result = days.map(d => ({ ...d, assignments: assignmentsByDay[d.id] || [] })); - return jsonContent(uri.href, result); + const { days } = listDays(id); + return jsonContent(uri.href, days); } ); @@ -120,13 +83,7 @@ export function registerResources(server: McpServer, userId: number): void { async (uri, { tripId }) => { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const places = db.prepare(` - SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon - FROM places p - LEFT JOIN categories c ON p.category_id = c.id - WHERE p.trip_id = ? - ORDER BY p.created_at DESC - `).all(id); + const places = listPlaces(String(id), {}); return jsonContent(uri.href, places); } ); @@ -139,9 +96,7 @@ export function registerResources(server: McpServer, userId: number): void { async (uri, { tripId }) => { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const items = db.prepare( - 'SELECT * FROM budget_items WHERE trip_id = ? ORDER BY category ASC, created_at ASC' - ).all(id); + const items = listBudgetItems(id); return jsonContent(uri.href, items); } ); @@ -154,9 +109,7 @@ export function registerResources(server: McpServer, userId: number): void { async (uri, { tripId }) => { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const items = db.prepare( - 'SELECT * FROM packing_items WHERE trip_id = ? ORDER BY sort_order ASC, created_at ASC' - ).all(id); + const items = listPackingItems(id); return jsonContent(uri.href, items); } ); @@ -169,14 +122,7 @@ export function registerResources(server: McpServer, userId: number): void { async (uri, { tripId }) => { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const reservations = db.prepare(` - SELECT r.*, d.day_number, p.name as place_name - FROM reservations r - LEFT JOIN days d ON r.day_id = d.id - LEFT JOIN places p ON r.place_id = p.id - WHERE r.trip_id = ? - ORDER BY r.reservation_time ASC, r.created_at ASC - `).all(id); + const reservations = listReservations(id); return jsonContent(uri.href, reservations); } ); @@ -190,9 +136,7 @@ export function registerResources(server: McpServer, userId: number): void { const tId = parseId(tripId); const dId = parseId(dayId); if (tId === null || dId === null || !canAccessTrip(tId, userId)) return accessDenied(uri.href); - const notes = db.prepare( - 'SELECT * FROM day_notes WHERE day_id = ? AND trip_id = ? ORDER BY sort_order ASC, created_at ASC' - ).all(dId, tId); + const notes = listDayNotes(dId, tId); return jsonContent(uri.href, notes); } ); @@ -205,16 +149,7 @@ export function registerResources(server: McpServer, userId: number): void { async (uri, { tripId }) => { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const accommodations = db.prepare(` - SELECT da.*, p.name as place_name, p.address as place_address, p.lat, p.lng, - ds.day_number as start_day_number, de.day_number as end_day_number - FROM day_accommodations da - JOIN places p ON da.place_id = p.id - LEFT JOIN days ds ON da.start_day_id = ds.id - LEFT JOIN days de ON da.end_day_id = de.id - WHERE da.trip_id = ? - ORDER BY ds.day_number ASC - `).all(id); + const accommodations = listAccommodations(id); return jsonContent(uri.href, accommodations); } ); @@ -227,20 +162,10 @@ export function registerResources(server: McpServer, userId: number): void { async (uri, { tripId }) => { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const trip = db.prepare('SELECT user_id FROM trips WHERE id = ?').get(id) as { user_id: number } | undefined; - if (!trip) return accessDenied(uri.href); - const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id) as Record | undefined; - const members = db.prepare(` - SELECT u.id, u.username, u.avatar, tm.added_at - FROM trip_members tm - JOIN users u ON tm.user_id = u.id - WHERE tm.trip_id = ? - ORDER BY tm.added_at ASC - `).all(id); - return jsonContent(uri.href, { - owner: owner ? { ...owner, role: 'owner' } : null, - members, - }); + const ownerRow = getTripOwner(id); + if (!ownerRow) return accessDenied(uri.href); + const { owner, members } = listMembers(id, ownerRow.user_id); + return jsonContent(uri.href, { owner, members }); } ); @@ -252,13 +177,7 @@ export function registerResources(server: McpServer, userId: number): void { async (uri, { tripId }) => { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const notes = db.prepare(` - SELECT cn.*, u.username - FROM collab_notes cn - JOIN users u ON cn.user_id = u.id - WHERE cn.trip_id = ? - ORDER BY cn.pinned DESC, cn.updated_at DESC - `).all(id); + const notes = listCollabNotes(id); return jsonContent(uri.href, notes); } ); @@ -269,9 +188,7 @@ export function registerResources(server: McpServer, userId: number): void { 'trek://categories', { description: 'All available place categories (id, name, color, icon) for use when creating places' }, async (uri) => { - const categories = db.prepare( - 'SELECT id, name, color, icon FROM categories ORDER BY name ASC' - ).all(); + const categories = listCategories(); return jsonContent(uri.href, categories); } ); @@ -282,9 +199,7 @@ export function registerResources(server: McpServer, userId: number): void { 'trek://bucket-list', { description: 'Your personal travel bucket list' }, async (uri) => { - const items = db.prepare( - 'SELECT * FROM bucket_list WHERE user_id = ? ORDER BY created_at DESC' - ).all(userId); + const items = listBucketList(userId); return jsonContent(uri.href, items); } ); @@ -295,9 +210,7 @@ export function registerResources(server: McpServer, userId: number): void { 'trek://visited-countries', { description: 'Countries you have marked as visited in Atlas' }, async (uri) => { - const countries = db.prepare( - 'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC' - ).all(userId); + const countries = listVisitedCountries(userId); return jsonContent(uri.href, countries); } ); diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index e147907..ab542be 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -1,18 +1,29 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; -import path from 'path'; -import fs from 'fs'; -import { db, canAccessTrip, isOwner } from '../db/database'; +import { canAccessTrip } from '../db/database'; import { broadcast } from '../websocket'; +import { isDemoUser } from '../services/authService'; +import { + listTrips, createTrip, updateTrip, deleteTrip, getTripSummary, + isOwner, verifyTripAccess, +} from '../services/tripService'; +import { listPlaces, createPlace, updatePlace, deletePlace } from '../services/placeService'; +import { listCategories } from '../services/categoryService'; +import { + dayExists, placeExists, createAssignment, assignmentExistsInDay, + deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime, +} from '../services/assignmentService'; +import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService'; +import { createItem as createPackingItem, updateItem as updatePackingItem, deleteItem as deletePackingItem } from '../services/packingService'; +import { createReservation, getReservation, updateReservation, deleteReservation } from '../services/reservationService'; +import { getDay, updateDay, validateAccommodationRefs } from '../services/dayService'; +import { createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote, deleteNote as deleteDayNote, dayExists as dayNoteExists } from '../services/dayNoteService'; +import { createNote as createCollabNote, updateNote as updateCollabNote, deleteNote as deleteCollabNote } from '../services/collabService'; +import { + markCountryVisited, unmarkCountryVisited, createBucketItem, deleteBucketItem, +} from '../services/atlasService'; -const MS_PER_DAY = 86400000; -const MAX_TRIP_DAYS = 90; - -function isDemoUser(userId: number): boolean { - if (process.env.DEMO_MODE !== 'true') return false; - const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined; - return user?.email === 'demo@nomad.app'; -} +const MAX_MCP_TRIP_DAYS = 90; function demoDenied() { return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true }; @@ -26,25 +37,6 @@ function ok(data: unknown) { return { content: [{ type: 'text' as const, text: JSON.stringify(data, null, 2) }] }; } -/** Create days for a newly created trip (fresh insert, no existing days). */ -function createDaysForNewTrip(tripId: number | bigint, startDate: string | null, endDate: string | null): void { - const insert = db.prepare('INSERT INTO days (trip_id, day_number, date) VALUES (?, ?, ?)'); - if (startDate && endDate) { - const [sy, sm, sd] = startDate.split('-').map(Number); - const [ey, em, ed] = endDate.split('-').map(Number); - const startMs = Date.UTC(sy, sm - 1, sd); - const endMs = Date.UTC(ey, em - 1, ed); - const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS); - for (let i = 0; i < numDays; i++) { - const d = new Date(startMs + i * MS_PER_DAY); - const date = `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}-${String(d.getUTCDate()).padStart(2, '0')}`; - insert.run(tripId, i + 1, date); - } - } else { - for (let i = 0; i < 7; i++) insert.run(tripId, i + 1, null); - } -} - export function registerTools(server: McpServer, userId: number): void { // --- TRIPS --- @@ -75,14 +67,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 = db.transaction(() => { - const result = db.prepare( - 'INSERT INTO trips (user_id, title, description, start_date, end_date, currency) VALUES (?, ?, ?, ?, ?, ?)' - ).run(userId, title, description || null, start_date || null, end_date || null, currency || 'EUR'); - const tripId = result.lastInsertRowid as number; - createDaysForNewTrip(tripId, start_date || null, end_date || null); - return db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId); - })(); + const { trip } = createTrip(userId, { title, description, start_date, end_date, currency }, MAX_MCP_TRIP_DAYS); return ok({ trip }); } ); @@ -113,21 +98,9 @@ export function registerTools(server: McpServer, userId: number): void { 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 existing = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record & { title: string; description: string; start_date: string; end_date: string; currency: string } | undefined; - if (!existing) return noAccess(); - db.prepare( - 'UPDATE trips SET title = ?, description = ?, start_date = ?, end_date = ?, currency = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?' - ).run( - title ?? existing.title, - description !== undefined ? description : existing.description, - start_date !== undefined ? start_date : existing.start_date, - end_date !== undefined ? end_date : existing.end_date, - currency ?? existing.currency, - tripId - ); - const updated = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId); - broadcast(tripId, 'trip:updated', { trip: updated }); - return ok({ trip: updated }); + const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user'); + broadcast(tripId, 'trip:updated', { trip: updatedTrip }); + return ok({ trip: updatedTrip }); } ); @@ -142,7 +115,7 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId }) => { if (isDemoUser(userId)) return demoDenied(); if (!isOwner(tripId, userId)) return noAccess(); - db.prepare('DELETE FROM trips WHERE id = ?').run(tripId); + deleteTrip(tripId, userId, 'user'); return ok({ success: true, tripId }); } ); @@ -156,18 +129,7 @@ export function registerTools(server: McpServer, userId: number): void { }, }, async ({ include_archived }) => { - const trips = db.prepare(` - SELECT t.*, u.username as owner_username, - (SELECT COUNT(*) FROM days d WHERE d.trip_id = t.id) as day_count, - (SELECT COUNT(*) FROM places p WHERE p.trip_id = t.id) as place_count, - CASE WHEN t.user_id = ? THEN 1 ELSE 0 END as is_owner - FROM trips t - JOIN users u ON u.id = t.user_id - LEFT JOIN trip_members tm ON tm.trip_id = t.id AND tm.user_id = ? - WHERE (t.user_id = ? OR tm.user_id IS NOT NULL) - AND (? = 1 OR t.is_archived = 0) - ORDER BY t.updated_at DESC - `).all(userId, userId, userId, include_archived ? 1 : 0); + const trips = listTrips(userId, include_archived ? null : 0); return ok({ trips }); } ); @@ -196,11 +158,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 result = db.prepare(` - INSERT INTO places (trip_id, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, transport_mode) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(tripId, name, description || null, lat ?? null, lng ?? null, address || null, category_id || null, google_place_id || null, osm_id || null, notes || null, website || null, phone || null, 'walking'); - const place = db.prepare('SELECT * FROM places WHERE id = ?').get(result.lastInsertRowid); + const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }); broadcast(tripId, 'place:created', { place }); return ok({ place }); } @@ -226,25 +184,8 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, placeId, name, description, lat, lng, address, notes, website, phone }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const existing = db.prepare('SELECT * FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId) as Record | undefined; - if (!existing) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; - db.prepare(` - UPDATE places SET - name = ?, description = ?, lat = ?, lng = ?, address = ?, notes = ?, website = ?, phone = ?, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `).run( - name ?? existing.name, - description !== undefined ? description : existing.description, - lat !== undefined ? lat : existing.lat, - lng !== undefined ? lng : existing.lng, - address !== undefined ? address : existing.address, - notes !== undefined ? notes : existing.notes, - website !== undefined ? website : existing.website, - phone !== undefined ? phone : existing.phone, - placeId - ); - const place = db.prepare('SELECT * FROM places WHERE id = ?').get(placeId); + const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, notes, website, phone }); + if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; broadcast(tripId, 'place:updated', { place }); return ok({ place }); } @@ -262,9 +203,8 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, placeId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); - if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; - db.prepare('DELETE FROM places WHERE id = ?').run(placeId); + const deleted = deletePlace(String(tripId), String(placeId)); + if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; broadcast(tripId, 'place:deleted', { placeId }); return ok({ success: true }); } @@ -279,7 +219,7 @@ export function registerTools(server: McpServer, userId: number): void { inputSchema: {}, }, async () => { - const categories = db.prepare('SELECT id, name, color, icon FROM categories ORDER BY name ASC').all(); + const categories = listCategories(); return ok({ categories }); } ); @@ -333,20 +273,9 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, dayId, placeId, notes }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); - if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; - const place = db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(placeId, tripId); - if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; - const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null }; - const orderIndex = (maxOrder.max !== null ? maxOrder.max : -1) + 1; - const result = db.prepare( - 'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)' - ).run(dayId, placeId, orderIndex, notes || null); - const assignment = db.prepare(` - SELECT da.*, p.name as place_name, p.address, p.lat, p.lng - FROM day_assignments da JOIN places p ON da.place_id = p.id - WHERE da.id = ? - `).get(result.lastInsertRowid); + if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; + const assignment = createAssignment(dayId, placeId, notes || null); broadcast(tripId, 'assignment:created', { assignment }); return ok({ assignment }); } @@ -365,11 +294,9 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, dayId, assignmentId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const assignment = db.prepare( - 'SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND da.day_id = ? AND d.trip_id = ?' - ).get(assignmentId, dayId, tripId); - if (!assignment) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true }; - db.prepare('DELETE FROM day_assignments WHERE id = ?').run(assignmentId); + if (!assignmentExistsInDay(assignmentId, dayId, tripId)) + return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true }; + deleteAssignment(assignmentId); broadcast(tripId, 'assignment:deleted', { assignmentId, dayId }); return ok({ success: true }); } @@ -392,12 +319,7 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, name, category, total_price, note }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM budget_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 budget_items (trip_id, category, name, total_price, note, sort_order) VALUES (?, ?, ?, ?, ?, ?)' - ).run(tripId, category || 'Other', name, total_price, note || null, sortOrder); - const item = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(result.lastInsertRowid); + const item = createBudgetItem(tripId, { category, name, total_price, note }); broadcast(tripId, 'budget:created', { item }); return ok({ item }); } @@ -415,9 +337,8 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, itemId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(itemId, tripId); - if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true }; - db.prepare('DELETE FROM budget_items WHERE id = ?').run(itemId); + const deleted = deleteBudgetItem(itemId, tripId); + if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true }; broadcast(tripId, 'budget:deleted', { itemId }); return ok({ success: true }); } @@ -438,12 +359,7 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, name, category }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM packing_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 packing_items (trip_id, name, checked, category, sort_order) VALUES (?, ?, ?, ?, ?)' - ).run(tripId, name, 0, category || 'General', sortOrder); - const item = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(result.lastInsertRowid); + const item = createPackingItem(tripId, { name, category: category || 'General' }); broadcast(tripId, 'packing:created', { item }); return ok({ item }); } @@ -462,12 +378,10 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, itemId, checked }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId); + 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 }; - db.prepare('UPDATE packing_items SET checked = ? WHERE id = ?').run(checked ? 1 : 0, itemId); - const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId); - broadcast(tripId, 'packing:updated', { item: updated }); - return ok({ item: updated }); + broadcast(tripId, 'packing:updated', { item }); + return ok({ item }); } ); @@ -483,9 +397,8 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, itemId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const item = db.prepare('SELECT id FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId); - if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; - db.prepare('DELETE FROM packing_items WHERE id = ?').run(itemId); + const deleted = deletePackingItem(tripId, itemId); + if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; broadcast(tripId, 'packing:deleted', { itemId }); return ok({ success: true }); } @@ -519,43 +432,28 @@ export function registerTools(server: McpServer, userId: number): void { if (!canAccessTrip(tripId, userId)) return noAccess(); // Validate that all referenced IDs belong to this trip - if (day_id) { - if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(day_id, tripId)) - return { content: [{ type: 'text' as const, text: 'day_id does not belong to this trip.' }], isError: true }; - } - if (place_id) { - if (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId)) - return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true }; - } - if (start_day_id) { - if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(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) { - if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(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) { - if (!db.prepare('SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?').get(assignment_id, tripId)) - return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], isError: true }; - } + 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 reservation = db.transaction(() => { - let accommodationId: number | null = null; - if (type === 'hotel' && place_id && start_day_id && end_day_id) { - const accResult = db.prepare( - 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, confirmation_number || null); - accommodationId = accResult.lastInsertRowid as number; - } - const result = db.prepare(` - INSERT INTO reservations (trip_id, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, accommodation_id, status) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) - `).run(tripId, title, type, reservation_time || null, location || null, confirmation_number || null, notes || null, day_id || null, place_id || null, assignment_id || null, accommodationId, 'pending'); - return db.prepare('SELECT * FROM reservations WHERE id = ?').get(result.lastInsertRowid); - })(); + 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; - if (type === 'hotel' && place_id && start_day_id && end_day_id) { + const { reservation, accommodationCreated } = createReservation(tripId, { + title, type, reservation_time, location, confirmation_number, + notes, day_id, place_id, assignment_id, + create_accommodation: createAccommodation, + }); + + if (accommodationCreated) { broadcast(tripId, 'accommodation:created', {}); } broadcast(tripId, 'reservation:created', { reservation }); @@ -575,16 +473,10 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, reservationId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const reservation = db.prepare('SELECT id, accommodation_id FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as { id: number; accommodation_id: number | null } | undefined; - if (!reservation) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true }; - db.transaction(() => { - if (reservation.accommodation_id) { - db.prepare('DELETE FROM day_accommodations WHERE id = ?').run(reservation.accommodation_id); - } - db.prepare('DELETE FROM reservations WHERE id = ?').run(reservationId); - })(); - if (reservation.accommodation_id) { - broadcast(tripId, 'accommodation:deleted', { accommodationId: reservation.accommodation_id }); + const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId); + if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true }; + if (accommodationDeleted) { + broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }); } broadcast(tripId, 'reservation:deleted', { reservationId }); return ok({ success: true }); @@ -608,36 +500,28 @@ export function registerTools(server: McpServer, userId: number): void { 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 reservation = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as Record | undefined; - if (!reservation) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true }; - if (reservation.type !== 'hotel') return { content: [{ type: 'text' as const, text: 'Reservation is not of type hotel.' }], isError: true }; + 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 (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId)) + if (!placeExists(place_id, tripId)) return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true }; - if (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(start_day_id, tripId)) + 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 (!db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(end_day_id, tripId)) + if (!getDay(end_day_id, tripId)) return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true }; - let accommodationId = reservation.accommodation_id as number | null; - const isNewAccommodation = !accommodationId; - db.transaction(() => { - if (accommodationId) { - db.prepare('UPDATE day_accommodations SET place_id = ?, start_day_id = ?, end_day_id = ?, check_in = COALESCE(?, check_in), check_out = COALESCE(?, check_out) WHERE id = ?') - .run(place_id, start_day_id, end_day_id, check_in || null, check_out || null, accommodationId); - } else { - const accResult = db.prepare( - 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' - ).run(tripId, place_id, start_day_id, end_day_id, check_in || null, check_out || null, reservation.confirmation_number || null); - accommodationId = accResult.lastInsertRowid as number; - } - db.prepare('UPDATE reservations SET place_id = ?, accommodation_id = ? WHERE id = ?') - .run(place_id, accommodationId, reservationId); - })(); + const isNewAccommodation = !current.accommodation_id; + const { reservation } = updateReservation(reservationId, tripId, { + place_id, + type: current.type, + status: current.status as string, + create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined }, + }, current); + broadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {}); - const updated = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservationId); - broadcast(tripId, 'reservation:updated', { reservation: updated }); - return ok({ reservation: updated, accommodation_id: accommodationId }); + broadcast(tripId, 'reservation:updated', { reservation }); + return ok({ reservation, accommodation_id: (reservation as any).accommodation_id }); } ); @@ -657,28 +541,15 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, assignmentId, place_time, end_time }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const assignment = db.prepare(` - SELECT da.* FROM day_assignments da - JOIN days d ON da.day_id = d.id - WHERE da.id = ? AND d.trip_id = ? - `).get(assignmentId, tripId) as Record | undefined; - if (!assignment) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true }; - db.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?') - .run( - place_time !== undefined ? place_time : assignment.assignment_time, - end_time !== undefined ? end_time : assignment.assignment_end_time, - assignmentId - ); - const updated = db.prepare(` - SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes, - da.assignment_time, da.assignment_end_time, - p.id as place_id, p.name, p.address - FROM day_assignments da - JOIN places p ON da.place_id = p.id - WHERE da.id = ? - `).get(assignmentId); - broadcast(tripId, 'assignment:updated', { assignment: updated }); - return ok({ assignment: updated }); + const existing = getAssignmentForTrip(assignmentId, tripId); + if (!existing) return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true }; + const assignment = updateTime( + assignmentId, + place_time !== undefined ? place_time : (existing as any).assignment_time, + end_time !== undefined ? end_time : (existing as any).assignment_end_time + ); + broadcast(tripId, 'assignment:updated', { assignment }); + return ok({ assignment }); } ); @@ -695,10 +566,9 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, dayId, title }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); - if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; - db.prepare('UPDATE days SET title = ? WHERE id = ?').run(title, dayId); - const updated = db.prepare('SELECT * FROM days WHERE id = ?').get(dayId); + const current = getDay(dayId, tripId); + if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + const updated = updateDay(dayId, current, title !== undefined ? { title } : {}); broadcast(tripId, 'day:updated', { day: updated }); return ok({ day: updated }); } @@ -727,39 +597,21 @@ export function registerTools(server: McpServer, userId: number): void { 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 = db.prepare('SELECT * FROM reservations WHERE id = ? AND trip_id = ?').get(reservationId, tripId) as Record | undefined; + const existing = getReservation(reservationId, tripId); if (!existing) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true }; - if (place_id != null) { - if (!db.prepare('SELECT id FROM places WHERE id = ? AND trip_id = ?').get(place_id, tripId)) - return { content: [{ type: 'text' as const, text: 'place_id does not belong to this trip.' }], isError: true }; - } - if (assignment_id != null) { - if (!db.prepare('SELECT da.id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE da.id = ? AND d.trip_id = ?').get(assignment_id, tripId)) - return { content: [{ type: 'text' as const, text: 'assignment_id does not belong to this trip.' }], 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 }; - db.prepare(` - UPDATE reservations SET - title = ?, type = ?, reservation_time = ?, location = ?, - confirmation_number = ?, notes = ?, status = ?, - place_id = ?, assignment_id = ? - WHERE id = ? - `).run( - title ?? existing.title, - type ?? existing.type, - reservation_time !== undefined ? reservation_time : existing.reservation_time, - location !== undefined ? location : existing.location, - confirmation_number !== undefined ? confirmation_number : existing.confirmation_number, - notes !== undefined ? notes : existing.notes, - status ?? existing.status, - place_id !== undefined ? place_id : existing.place_id, - assignment_id !== undefined ? assignment_id : existing.assignment_id, - reservationId - ); - const updated = db.prepare('SELECT * FROM reservations WHERE id = ?').get(reservationId); - broadcast(tripId, 'reservation:updated', { reservation: updated }); - return ok({ reservation: updated }); + const { reservation } = updateReservation(reservationId, tripId, { + title, type, reservation_time, location, confirmation_number, notes, status, + place_id: place_id !== undefined ? place_id ?? undefined : undefined, + assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined, + }, existing); + broadcast(tripId, 'reservation:updated', { reservation }); + return ok({ reservation }); } ); @@ -783,24 +635,10 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, itemId, name, category, total_price, persons, days, note }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const existing = db.prepare('SELECT * FROM budget_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as Record | undefined; - if (!existing) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true }; - db.prepare(` - UPDATE budget_items SET - name = ?, category = ?, total_price = ?, persons = ?, days = ?, note = ? - WHERE id = ? - `).run( - name ?? existing.name, - category ?? existing.category, - total_price !== undefined ? total_price : existing.total_price, - persons !== undefined ? persons : existing.persons, - days !== undefined ? days : existing.days, - note !== undefined ? note : existing.note, - itemId - ); - const updated = db.prepare('SELECT * FROM budget_items WHERE id = ?').get(itemId); - broadcast(tripId, 'budget:updated', { item: updated }); - return ok({ item: updated }); + const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note }); + if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true }; + broadcast(tripId, 'budget:updated', { item }); + return ok({ item }); } ); @@ -820,16 +658,11 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, itemId, name, category }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const existing = db.prepare('SELECT * FROM packing_items WHERE id = ? AND trip_id = ?').get(itemId, tripId) as Record | undefined; - if (!existing) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; - db.prepare('UPDATE packing_items SET name = ?, category = ? WHERE id = ?').run( - name ?? existing.name, - category ?? existing.category, - itemId - ); - const updated = db.prepare('SELECT * FROM packing_items WHERE id = ?').get(itemId); - broadcast(tripId, 'packing:updated', { item: updated }); - return ok({ item: updated }); + const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined); + const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys); + if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; + broadcast(tripId, 'packing:updated', { item }); + return ok({ item }); } ); @@ -848,13 +681,8 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, dayId, assignmentIds }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); - if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; - const update = db.prepare('UPDATE day_assignments SET order_index = ? WHERE id = ? AND day_id = ?'); - const updateMany = db.transaction((ids: number[]) => { - ids.forEach((id, index) => update.run(index, id, dayId)); - }); - updateMany(assignmentIds); + if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + reorderAssignments(dayId, assignmentIds); broadcast(tripId, 'assignment:reordered', { dayId, assignmentIds }); return ok({ success: true, dayId, order: assignmentIds }); } @@ -872,106 +700,9 @@ export function registerTools(server: McpServer, userId: number): void { }, async ({ tripId }) => { if (!canAccessTrip(tripId, userId)) return noAccess(); - - const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record | undefined; - if (!trip) return noAccess(); - - // Members - const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id as number); - const members = db.prepare(` - SELECT u.id, u.username, u.avatar, tm.added_at - FROM trip_members tm JOIN users u ON tm.user_id = u.id - WHERE tm.trip_id = ? - `).all(tripId); - - // Days with assignments - const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as (Record & { id: number })[]; - const dayIds = days.map(d => d.id); - const assignmentsByDay: Record = {}; - if (dayIds.length > 0) { - const placeholders = dayIds.map(() => '?').join(','); - const assignments = db.prepare(` - SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes, - p.id as place_id, p.name, p.address, p.lat, p.lng, - COALESCE(da.assignment_time, p.place_time) as place_time, - c.name as category_name, c.icon as category_icon - FROM day_assignments da - JOIN places p ON da.place_id = p.id - LEFT JOIN categories c ON p.category_id = c.id - WHERE da.day_id IN (${placeholders}) - ORDER BY da.order_index ASC - `).all(...dayIds) as (Record & { day_id: number })[]; - for (const a of assignments) { - if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = []; - assignmentsByDay[a.day_id].push(a); - } - } - // Day notes - const dayNotesByDay: Record = {}; - if (dayIds.length > 0) { - const placeholders = dayIds.map(() => '?').join(','); - const dayNotes = db.prepare(` - SELECT * FROM day_notes WHERE day_id IN (${placeholders}) ORDER BY sort_order ASC - `).all(...dayIds) as (Record & { day_id: number })[]; - for (const n of dayNotes) { - if (!dayNotesByDay[n.day_id]) dayNotesByDay[n.day_id] = []; - dayNotesByDay[n.day_id].push(n); - } - } - - const daysWithAssignments = days.map(d => ({ - ...d, - assignments: assignmentsByDay[d.id] || [], - notes: dayNotesByDay[d.id] || [], - })); - - // Accommodations - const accommodations = db.prepare(` - SELECT da.*, p.name as place_name, ds.day_number as start_day_number, de.day_number as end_day_number - FROM day_accommodations da - JOIN places p ON da.place_id = p.id - LEFT JOIN days ds ON da.start_day_id = ds.id - LEFT JOIN days de ON da.end_day_id = de.id - WHERE da.trip_id = ? - ORDER BY ds.day_number ASC - `).all(tripId); - - // Budget summary - const budgetStats = db.prepare(` - SELECT COUNT(*) as item_count, COALESCE(SUM(total_price), 0) as total - FROM budget_items WHERE trip_id = ? - `).get(tripId) as { item_count: number; total: number }; - - // Packing summary - const packingStats = db.prepare(` - SELECT COUNT(*) as total, SUM(CASE WHEN checked = 1 THEN 1 ELSE 0 END) as checked - FROM packing_items WHERE trip_id = ? - `).get(tripId) as { total: number; checked: number }; - - // Upcoming reservations (all, sorted by time) - const reservations = db.prepare(` - SELECT r.*, d.day_number - FROM reservations r - LEFT JOIN days d ON r.day_id = d.id - WHERE r.trip_id = ? - ORDER BY r.reservation_time ASC, r.created_at ASC - `).all(tripId); - - // Collab notes - const collabNotes = db.prepare( - 'SELECT * FROM collab_notes WHERE trip_id = ? ORDER BY pinned DESC, updated_at DESC' - ).all(tripId); - - return ok({ - trip, - members: { owner, collaborators: members }, - days: daysWithAssignments, - accommodations, - budget: { ...budgetStats, currency: trip.currency }, - packing: packingStats, - reservations, - collab_notes: collabNotes, - }); + const summary = getTripSummary(tripId); + if (!summary) return noAccess(); + return ok(summary); } ); @@ -991,10 +722,7 @@ export function registerTools(server: McpServer, userId: number): void { }, async ({ name, lat, lng, country_code, notes }) => { if (isDemoUser(userId)) return demoDenied(); - const result = db.prepare( - 'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)' - ).run(userId, name, lat ?? null, lng ?? null, country_code || null, notes || null); - const item = db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid); + const item = createBucketItem(userId, { name, lat, lng, country_code, notes }); return ok({ item }); } ); @@ -1009,9 +737,8 @@ export function registerTools(server: McpServer, userId: number): void { }, async ({ itemId }) => { if (isDemoUser(userId)) return demoDenied(); - const item = db.prepare('SELECT id FROM bucket_list WHERE id = ? AND user_id = ?').get(itemId, userId); - if (!item) return { content: [{ type: 'text' as const, text: 'Bucket list item not found.' }], isError: true }; - db.prepare('DELETE FROM bucket_list WHERE id = ?').run(itemId); + 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 }); } ); @@ -1028,7 +755,7 @@ export function registerTools(server: McpServer, userId: number): void { }, async ({ country_code }) => { if (isDemoUser(userId)) return demoDenied(); - db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, country_code.toUpperCase()); + markCountryVisited(userId, country_code.toUpperCase()); return ok({ success: true, country_code: country_code.toUpperCase() }); } ); @@ -1043,7 +770,7 @@ export function registerTools(server: McpServer, userId: number): void { }, async ({ country_code }) => { if (isDemoUser(userId)) return demoDenied(); - db.prepare('DELETE FROM visited_countries WHERE user_id = ? AND country_code = ?').run(userId, country_code.toUpperCase()); + unmarkCountryVisited(userId, country_code.toUpperCase()); return ok({ success: true, country_code: country_code.toUpperCase() }); } ); @@ -1065,11 +792,7 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, title, content, category, color }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const result = db.prepare(` - INSERT INTO collab_notes (trip_id, user_id, title, content, category, color) - VALUES (?, ?, ?, ?, ?, ?) - `).run(tripId, userId, title, content || null, category || 'General', color || '#6366f1'); - const note = db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid); + const note = createCollabNote(tripId, userId, { title, content, category, color }); broadcast(tripId, 'collab:note:created', { note }); return ok({ note }); } @@ -1092,26 +815,8 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, noteId, title, content, category, color, pinned }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const existing = db.prepare('SELECT * FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId); - if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; - db.prepare(` - UPDATE collab_notes SET - title = CASE WHEN ? THEN ? ELSE title END, - content = CASE WHEN ? THEN ? ELSE content END, - category = CASE WHEN ? THEN ? ELSE category END, - color = CASE WHEN ? THEN ? ELSE color END, - pinned = CASE WHEN ? THEN ? ELSE pinned END, - updated_at = CURRENT_TIMESTAMP - WHERE id = ? - `).run( - title !== undefined ? 1 : 0, title !== undefined ? title : null, - content !== undefined ? 1 : 0, content !== undefined ? content : null, - category !== undefined ? 1 : 0, category !== undefined ? category : null, - color !== undefined ? 1 : 0, color !== undefined ? color : null, - pinned !== undefined ? 1 : 0, pinned !== undefined ? (pinned ? 1 : 0) : null, - noteId - ); - const note = db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(noteId); + const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned }); + if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; broadcast(tripId, 'collab:note:updated', { note }); return ok({ note }); } @@ -1129,19 +834,8 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, noteId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const existing = db.prepare('SELECT id FROM collab_notes WHERE id = ? AND trip_id = ?').get(noteId, tripId); - if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; - const noteFiles = db.prepare('SELECT filename FROM trip_files WHERE note_id = ?').all(noteId) as { filename: string }[]; - const uploadsDir = path.resolve(__dirname, '../../uploads'); - for (const f of noteFiles) { - const resolved = path.resolve(path.join(uploadsDir, 'files', f.filename)); - if (!resolved.startsWith(uploadsDir)) continue; - try { fs.unlinkSync(resolved); } catch {} - } - db.transaction(() => { - db.prepare('DELETE FROM trip_files WHERE note_id = ?').run(noteId); - db.prepare('DELETE FROM collab_notes WHERE id = ?').run(noteId); - })(); + const deleted = deleteCollabNote(tripId, noteId); + if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; broadcast(tripId, 'collab:note:deleted', { noteId }); return ok({ success: true }); } @@ -1164,12 +858,8 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, dayId, text, time, icon }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const day = db.prepare('SELECT id FROM days WHERE id = ? AND trip_id = ?').get(dayId, tripId); - if (!day) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; - const result = db.prepare( - 'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, ?)' - ).run(dayId, tripId, text.trim(), time || null, icon || '📝', 9999); - const note = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid); + if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + const note = createDayNote(dayId, tripId, text, time, icon); broadcast(tripId, 'dayNote:created', { dayId, note }); return ok({ note }); } @@ -1191,17 +881,11 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, dayId, noteId, text, time, icon }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const existing = db.prepare('SELECT * FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(noteId, dayId, tripId) as Record | undefined; + const existing = getDayNote(noteId, dayId, tripId); if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; - db.prepare('UPDATE day_notes SET text = ?, time = ?, icon = ? WHERE id = ?').run( - text !== undefined ? text.trim() : existing.text, - time !== undefined ? time : existing.time, - icon ?? existing.icon, - noteId - ); - const updated = db.prepare('SELECT * FROM day_notes WHERE id = ?').get(noteId); - broadcast(tripId, 'dayNote:updated', { dayId, note: updated }); - return ok({ note: updated }); + const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon }); + broadcast(tripId, 'dayNote:updated', { dayId, note }); + return ok({ note }); } ); @@ -1218,9 +902,9 @@ export function registerTools(server: McpServer, userId: number): void { async ({ tripId, dayId, noteId }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const note = db.prepare('SELECT id FROM day_notes WHERE id = ? AND day_id = ? AND trip_id = ?').get(noteId, dayId, tripId); + const note = getDayNote(noteId, dayId, tripId); if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; - db.prepare('DELETE FROM day_notes WHERE id = ?').run(noteId); + deleteDayNote(noteId); broadcast(tripId, 'dayNote:deleted', { noteId, dayId }); return ok({ success: true }); } diff --git a/server/src/services/adminService.ts b/server/src/services/adminService.ts index ae42c92..3762c0d 100644 --- a/server/src/services/adminService.ts +++ b/server/src/services/adminService.ts @@ -463,6 +463,11 @@ export function deleteTemplateItem(itemId: string) { // ── Addons ───────────────────────────────────────────────────────────────── +export function isAddonEnabled(addonId: string): boolean { + const addon = db.prepare('SELECT enabled FROM addons WHERE id = ?').get(addonId) as { enabled: number } | undefined; + return !!addon?.enabled; +} + export function listAddons() { const addons = db.prepare('SELECT * FROM addons ORDER BY sort_order, id').all() as Addon[]; return addons.map(a => ({ ...a, enabled: !!a.enabled, config: JSON.parse(a.config || '{}') })); diff --git a/server/src/services/assignmentService.ts b/server/src/services/assignmentService.ts index 008553a..72ac764 100644 --- a/server/src/services/assignmentService.ts +++ b/server/src/services/assignmentService.ts @@ -35,8 +35,11 @@ export function getAssignmentWithPlace(assignmentId: number | bigint) { return { id: a.id, day_id: a.day_id, + place_id: a.place_id, order_index: a.order_index, notes: a.notes, + assignment_time: a.assignment_time ?? null, + assignment_end_time: a.assignment_end_time ?? null, participants, created_at: a.created_at, place: { diff --git a/server/src/services/atlasService.ts b/server/src/services/atlasService.ts index 1ef896b..11a317b 100644 --- a/server/src/services/atlasService.ts +++ b/server/src/services/atlasService.ts @@ -327,6 +327,12 @@ export function getCountryPlaces(userId: number, code: string) { // ── Mark / unmark country ─────────────────────────────────────────────────── +export function listVisitedCountries(userId: number): { country_code: string; created_at: string }[] { + return db.prepare( + 'SELECT country_code, created_at FROM visited_countries WHERE user_id = ? ORDER BY created_at DESC' + ).all(userId) as { country_code: string; created_at: string }[]; +} + export function markCountryVisited(userId: number, code: string): void { db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, code); } diff --git a/server/src/services/authService.ts b/server/src/services/authService.ts index c069645..c2e2dd1 100644 --- a/server/src/services/authService.ts +++ b/server/src/services/authService.ts @@ -988,3 +988,38 @@ export function createResourceToken(userId: number, purpose?: string): { error?: if (!token) return { error: 'Service unavailable', status: 503 }; return { token }; } + +// --------------------------------------------------------------------------- +// MCP auth helpers +// --------------------------------------------------------------------------- + +export function isDemoUser(userId: number): boolean { + if (process.env.DEMO_MODE !== 'true') return false; + const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined; + return user?.email === 'demo@nomad.app'; +} + +export function verifyMcpToken(rawToken: string): User | null { + const hash = createHash('sha256').update(rawToken).digest('hex'); + const row = db.prepare(` + SELECT u.id, u.username, u.email, u.role + FROM mcp_tokens mt + JOIN users u ON mt.user_id = u.id + WHERE mt.token_hash = ? + `).get(hash) as User | undefined; + if (row) { + db.prepare('UPDATE mcp_tokens SET last_used_at = CURRENT_TIMESTAMP WHERE token_hash = ?').run(hash); + return row; + } + return null; +} + +export function verifyJwtToken(token: string): User | null { + try { + const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number }; + const user = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(decoded.id) as User | undefined; + return user || null; + } catch { + return null; + } +} diff --git a/server/src/services/dayService.ts b/server/src/services/dayService.ts index 11edba0..705b364 100644 --- a/server/src/services/dayService.ts +++ b/server/src/services/dayService.ts @@ -145,10 +145,10 @@ export function getDay(id: string | number, tripId: string | number) { return db.prepare('SELECT * FROM days WHERE id = ? AND trip_id = ?').get(id, tripId) as Day | undefined; } -export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string }) { +export function updateDay(id: string | number, current: Day, fields: { notes?: string; title?: string | null }) { db.prepare('UPDATE days SET notes = ?, title = ? WHERE id = ?').run( fields.notes || null, - fields.title !== undefined ? fields.title : current.title, + 'title' in fields ? (fields.title ?? null) : current.title, id ); const updatedDay = db.prepare('SELECT * FROM days WHERE id = ?').get(id) as Day; diff --git a/server/src/services/queryHelpers.ts b/server/src/services/queryHelpers.ts index 1d316eb..7613518 100644 --- a/server/src/services/queryHelpers.ts +++ b/server/src/services/queryHelpers.ts @@ -56,8 +56,11 @@ function formatAssignmentWithPlace(a: AssignmentRow, tags: Partial[], parti return { id: a.id, day_id: a.day_id, + place_id: a.place_id, order_index: a.order_index, notes: a.notes, + assignment_time: a.assignment_time ?? null, + assignment_end_time: a.assignment_end_time ?? null, participants: participants || [], created_at: a.created_at, place: { diff --git a/server/src/services/tripService.ts b/server/src/services/tripService.ts index c89acbf..032628e 100644 --- a/server/src/services/tripService.ts +++ b/server/src/services/tripService.ts @@ -27,7 +27,7 @@ export { isOwner }; // ── Day generation ──────────────────────────────────────────────────────── -export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null) { +export function generateDays(tripId: number | bigint | string, startDate: string | null, endDate: string | null, maxDays?: number) { const existing = db.prepare('SELECT id, day_number, date FROM days WHERE trip_id = ?').all(tripId) as { id: number; day_number: number; date: string | null }[]; if (!startDate || !endDate) { @@ -56,7 +56,7 @@ export function generateDays(tripId: number | bigint | string, startDate: string const [ey, em, ed] = endDate.split('-').map(Number); const startMs = Date.UTC(sy, sm - 1, sd); const endMs = Date.UTC(ey, em - 1, ed); - const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, MAX_TRIP_DAYS); + const numDays = Math.min(Math.floor((endMs - startMs) / MS_PER_DAY) + 1, maxDays ?? MAX_TRIP_DAYS); const targetDates: string[] = []; for (let i = 0; i < numDays; i++) { @@ -99,7 +99,15 @@ export function generateDays(tripId: number | bigint | string, startDate: string // ── Trip CRUD ───────────────────────────────────────────────────────────── -export function listTrips(userId: number, archived: number) { +export function listTrips(userId: number, archived: number | null) { + if (archived === null) { + return db.prepare(` + ${TRIP_SELECT} + LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId + WHERE (t.user_id = :userId OR m.user_id IS NOT NULL) + ORDER BY t.created_at DESC + `).all({ userId }); + } return db.prepare(` ${TRIP_SELECT} LEFT JOIN trip_members m ON m.trip_id = t.id AND m.user_id = :userId @@ -117,7 +125,7 @@ interface CreateTripData { reminder_days?: number; } -export function createTrip(userId: number, data: CreateTripData) { +export function createTrip(userId: number, data: CreateTripData, maxDays?: number) { const rd = data.reminder_days !== undefined ? (Number(data.reminder_days) >= 0 && Number(data.reminder_days) <= 30 ? Number(data.reminder_days) : 3) : 3; @@ -128,7 +136,7 @@ export function createTrip(userId: number, data: CreateTripData) { `).run(userId, data.title, data.description || null, data.start_date || null, data.end_date || null, data.currency || 'EUR', rd); const tripId = result.lastInsertRowid; - generateDays(tripId, data.start_date || null, data.end_date || null); + generateDays(tripId, data.start_date || null, data.end_date || null, maxDays); const trip = db.prepare(`${TRIP_SELECT} WHERE t.id = :tripId`).get({ userId, tripId }); return { trip, tripId: Number(tripId), reminderDays: rd }; @@ -398,6 +406,101 @@ export function exportICS(tripId: string | number): { ics: string; filename: str return { ics, filename: `${safeFilename}.ics` }; } +// ── Trip summary (used by MCP get_trip_summary tool) ────────────────────── + +export function getTripSummary(tripId: number) { + const trip = db.prepare('SELECT * FROM trips WHERE id = ?').get(tripId) as Record | undefined; + if (!trip) return null; + + const owner = db.prepare('SELECT id, username, avatar FROM users WHERE id = ?').get(trip.user_id as number); + const members = db.prepare(` + SELECT u.id, u.username, u.avatar, tm.added_at + FROM trip_members tm JOIN users u ON tm.user_id = u.id + WHERE tm.trip_id = ? + `).all(tripId); + + const days = db.prepare('SELECT * FROM days WHERE trip_id = ? ORDER BY day_number ASC').all(tripId) as (Record & { id: number })[]; + const dayIds = days.map(d => d.id); + const assignmentsByDay: Record = {}; + const dayNotesByDay: Record = {}; + + if (dayIds.length > 0) { + const placeholders = dayIds.map(() => '?').join(','); + const assignments = db.prepare(` + SELECT da.id, da.day_id, da.order_index, da.notes as assignment_notes, + p.id as place_id, p.name, p.address, p.lat, p.lng, + COALESCE(da.assignment_time, p.place_time) as place_time, + c.name as category_name, c.icon as category_icon + FROM day_assignments da + JOIN places p ON da.place_id = p.id + LEFT JOIN categories c ON p.category_id = c.id + WHERE da.day_id IN (${placeholders}) + ORDER BY da.order_index ASC + `).all(...dayIds) as (Record & { day_id: number })[]; + for (const a of assignments) { + if (!assignmentsByDay[a.day_id]) assignmentsByDay[a.day_id] = []; + assignmentsByDay[a.day_id].push(a); + } + + const dayNotes = db.prepare(` + SELECT * FROM day_notes WHERE day_id IN (${placeholders}) ORDER BY sort_order ASC + `).all(...dayIds) as (Record & { day_id: number })[]; + for (const n of dayNotes) { + if (!dayNotesByDay[n.day_id]) dayNotesByDay[n.day_id] = []; + dayNotesByDay[n.day_id].push(n); + } + } + + const daysWithAssignments = days.map(d => ({ + ...d, + assignments: assignmentsByDay[d.id] || [], + notes: dayNotesByDay[d.id] || [], + })); + + const accommodations = db.prepare(` + SELECT da.*, p.name as place_name, ds.day_number as start_day_number, de.day_number as end_day_number + FROM day_accommodations da + JOIN places p ON da.place_id = p.id + LEFT JOIN days ds ON da.start_day_id = ds.id + LEFT JOIN days de ON da.end_day_id = de.id + WHERE da.trip_id = ? + ORDER BY ds.day_number ASC + `).all(tripId); + + const budgetStats = db.prepare(` + SELECT COUNT(*) as item_count, COALESCE(SUM(total_price), 0) as total + FROM budget_items WHERE trip_id = ? + `).get(tripId) as { item_count: number; total: number }; + + const packingStats = db.prepare(` + SELECT COUNT(*) as total, SUM(CASE WHEN checked = 1 THEN 1 ELSE 0 END) as checked + FROM packing_items WHERE trip_id = ? + `).get(tripId) as { total: number; checked: number }; + + const reservations = db.prepare(` + SELECT r.*, d.day_number + FROM reservations r + LEFT JOIN days d ON r.day_id = d.id + WHERE r.trip_id = ? + ORDER BY r.reservation_time ASC, r.created_at ASC + `).all(tripId); + + const collabNotes = db.prepare( + 'SELECT * FROM collab_notes WHERE trip_id = ? ORDER BY pinned DESC, updated_at DESC' + ).all(tripId); + + return { + trip, + members: { owner, collaborators: members }, + days: daysWithAssignments, + accommodations, + budget: { ...budgetStats, currency: trip.currency }, + packing: packingStats, + reservations, + collab_notes: collabNotes, + }; +} + // ── Custom error types ──────────────────────────────────────────────────── export class NotFoundError extends Error { diff --git a/server/tests/helpers/factories.ts b/server/tests/helpers/factories.ts index a9c1e1b..360467a 100644 --- a/server/tests/helpers/factories.ts +++ b/server/tests/helpers/factories.ts @@ -259,6 +259,201 @@ export interface TestInviteToken { expires_at: string | null; } +// --------------------------------------------------------------------------- +// Day Notes +// --------------------------------------------------------------------------- + +export interface TestDayNote { + id: number; + day_id: number; + trip_id: number; + text: string; + time: string | null; + icon: string; +} + +export function createDayNote( + db: Database.Database, + dayId: number, + tripId: number, + overrides: Partial<{ text: string; time: string; icon: string }> = {} +): TestDayNote { + const result = db.prepare( + 'INSERT INTO day_notes (day_id, trip_id, text, time, icon, sort_order) VALUES (?, ?, ?, ?, ?, 9999)' + ).run(dayId, tripId, overrides.text ?? 'Test note', overrides.time ?? null, overrides.icon ?? '📝'); + return db.prepare('SELECT * FROM day_notes WHERE id = ?').get(result.lastInsertRowid) as TestDayNote; +} + +// --------------------------------------------------------------------------- +// Collab Notes +// --------------------------------------------------------------------------- + +export interface TestCollabNote { + id: number; + trip_id: number; + user_id: number; + title: string; + content: string | null; + category: string; + color: string; + pinned: number; +} + +export function createCollabNote( + db: Database.Database, + tripId: number, + userId: number, + overrides: Partial<{ title: string; content: string; category: string; color: string }> = {} +): TestCollabNote { + const result = db.prepare( + 'INSERT INTO collab_notes (trip_id, user_id, title, content, category, color) VALUES (?, ?, ?, ?, ?, ?)' + ).run( + tripId, + userId, + overrides.title ?? 'Test Note', + overrides.content ?? null, + overrides.category ?? 'General', + overrides.color ?? '#6366f1' + ); + return db.prepare('SELECT * FROM collab_notes WHERE id = ?').get(result.lastInsertRowid) as TestCollabNote; +} + +// --------------------------------------------------------------------------- +// Day Assignments +// --------------------------------------------------------------------------- + +export interface TestDayAssignment { + id: number; + day_id: number; + place_id: number; + order_index: number; + notes: string | null; +} + +export function createDayAssignment( + db: Database.Database, + dayId: number, + placeId: number, + overrides: Partial<{ order_index: number; notes: string }> = {} +): TestDayAssignment { + const maxOrder = db.prepare('SELECT MAX(order_index) as max FROM day_assignments WHERE day_id = ?').get(dayId) as { max: number | null }; + const orderIndex = overrides.order_index ?? (maxOrder.max !== null ? maxOrder.max + 1 : 0); + const result = db.prepare( + 'INSERT INTO day_assignments (day_id, place_id, order_index, notes) VALUES (?, ?, ?, ?)' + ).run(dayId, placeId, orderIndex, overrides.notes ?? null); + return db.prepare('SELECT * FROM day_assignments WHERE id = ?').get(result.lastInsertRowid) as TestDayAssignment; +} + +// --------------------------------------------------------------------------- +// Bucket List +// --------------------------------------------------------------------------- + +export interface TestBucketListItem { + id: number; + user_id: number; + name: string; + lat: number | null; + lng: number | null; + country_code: string | null; + notes: string | null; +} + +export function createBucketListItem( + db: Database.Database, + userId: number, + overrides: Partial<{ name: string; lat: number; lng: number; country_code: string; notes: string }> = {} +): TestBucketListItem { + const result = db.prepare( + 'INSERT INTO bucket_list (user_id, name, lat, lng, country_code, notes) VALUES (?, ?, ?, ?, ?, ?)' + ).run( + userId, + overrides.name ?? 'Test Destination', + overrides.lat ?? null, + overrides.lng ?? null, + overrides.country_code ?? null, + overrides.notes ?? null + ); + return db.prepare('SELECT * FROM bucket_list WHERE id = ?').get(result.lastInsertRowid) as TestBucketListItem; +} + +// --------------------------------------------------------------------------- +// Visited Countries +// --------------------------------------------------------------------------- + +export function createVisitedCountry( + db: Database.Database, + userId: number, + countryCode: string +): void { + db.prepare('INSERT OR IGNORE INTO visited_countries (user_id, country_code) VALUES (?, ?)').run(userId, countryCode.toUpperCase()); +} + +// --------------------------------------------------------------------------- +// Day Accommodations +// --------------------------------------------------------------------------- + +export interface TestDayAccommodation { + id: number; + trip_id: number; + place_id: number; + start_day_id: number; + end_day_id: number; + check_in: string | null; + check_out: string | null; +} + +export function createDayAccommodation( + db: Database.Database, + tripId: number, + placeId: number, + startDayId: number, + endDayId: number, + overrides: Partial<{ check_in: string; check_out: string; confirmation: string }> = {} +): TestDayAccommodation { + const result = db.prepare( + 'INSERT INTO day_accommodations (trip_id, place_id, start_day_id, end_day_id, check_in, check_out, confirmation) VALUES (?, ?, ?, ?, ?, ?, ?)' + ).run( + tripId, + placeId, + startDayId, + endDayId, + overrides.check_in ?? null, + overrides.check_out ?? null, + overrides.confirmation ?? null + ); + return db.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(result.lastInsertRowid) as TestDayAccommodation; +} + +// --------------------------------------------------------------------------- +// MCP Tokens +// --------------------------------------------------------------------------- + +import { createHash } from 'crypto'; + +export interface TestMcpToken { + id: number; + tokenHash: string; + rawToken: string; +} + +export function createMcpToken( + db: Database.Database, + userId: number, + overrides: Partial<{ name: string; rawToken: string }> = {} +): TestMcpToken { + const rawToken = overrides.rawToken ?? `trek_test_${Date.now()}_${Math.random().toString(36).slice(2)}`; + const tokenHash = createHash('sha256').update(rawToken).digest('hex'); + const tokenPrefix = rawToken.slice(0, 12); + const result = db.prepare( + 'INSERT INTO mcp_tokens (user_id, token_hash, token_prefix, name) VALUES (?, ?, ?, ?)' + ).run(userId, tokenHash, tokenPrefix, overrides.name ?? 'Test Token'); + return { id: result.lastInsertRowid as number, tokenHash, rawToken }; +} + +// --------------------------------------------------------------------------- +// Invite Tokens +// --------------------------------------------------------------------------- + export function createInviteToken( db: Database.Database, overrides: Partial<{ token: string; max_uses: number; expires_at: string; created_by: number }> = {} diff --git a/server/tests/helpers/mcp-harness.ts b/server/tests/helpers/mcp-harness.ts new file mode 100644 index 0000000..3d556e1 --- /dev/null +++ b/server/tests/helpers/mcp-harness.ts @@ -0,0 +1,68 @@ +/** + * MCP test harness. + * + * Creates an McpServer + MCP Client connected via InMemoryTransport for unit testing + * tools and resources without HTTP overhead. + * + * Usage: + * const harness = await createMcpHarness({ userId, registerTools: true }); + * const result = await harness.client.callTool({ name: 'create_trip', arguments: { title: 'Test' } }); + * await harness.cleanup(); + */ + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; +import { Client } from '@modelcontextprotocol/sdk/client/index'; +import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory'; +import { registerResources } from '../../src/mcp/resources'; +import { registerTools } from '../../src/mcp/tools'; + +export interface McpHarness { + client: Client; + server: McpServer; + cleanup: () => Promise; +} + +export interface McpHarnessOptions { + userId: number; + /** Register read-only resources (default: true) */ + withResources?: boolean; + /** Register read-write tools (default: true) */ + withTools?: boolean; +} + +export async function createMcpHarness(options: McpHarnessOptions): Promise { + const { userId, withResources = true, withTools = true } = options; + + const server = new McpServer({ name: 'trek-test', version: '1.0.0' }); + + if (withResources) registerResources(server, userId); + if (withTools) registerTools(server, userId); + + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); + + const client = new Client({ name: 'test-client', version: '1.0.0' }); + + await server.connect(serverTransport); + await client.connect(clientTransport); + + const cleanup = async () => { + try { await client.close(); } catch { /* ignore */ } + try { await server.close(); } catch { /* ignore */ } + }; + + return { client, server, cleanup }; +} + +/** Parse JSON from a callTool result (first text content item). */ +export function parseToolResult(result: Awaited>): unknown { + const text = result.content.find((c: { type: string }) => c.type === 'text') as { type: 'text'; text: string } | undefined; + if (!text) throw new Error('No text content in tool result'); + return JSON.parse(text.text); +} + +/** Parse JSON from a readResource result (first content item). */ +export function parseResourceResult(result: Awaited>): unknown { + const item = result.contents[0] as { text?: string } | undefined; + if (!item?.text) throw new Error('No text content in resource result'); + return JSON.parse(item.text); +} diff --git a/server/tests/integration/mcp.test.ts b/server/tests/integration/mcp.test.ts index 772c857..0809659 100644 --- a/server/tests/integration/mcp.test.ts +++ b/server/tests/integration/mcp.test.ts @@ -3,7 +3,7 @@ * Covers MCP-001 to MCP-013. * * The MCP endpoint uses JWT auth and server-sent events / streaming HTTP. - * Tests focus on authentication and basic rejection behavior. + * Tests cover authentication, session management, rate limiting, and API token auth. */ import { describe, it, expect, vi, beforeAll, beforeEach, afterAll } from 'vitest'; import request from 'supertest'; @@ -47,6 +47,8 @@ import { resetTestDb } from '../helpers/test-db'; import { createUser } from '../helpers/factories'; import { generateToken } from '../helpers/auth'; import { loginAttempts, mfaAttempts } from '../../src/routes/auth'; +import { createMcpToken } from '../helpers/factories'; +import { closeMcpSessions } from '../../src/mcp/index'; const app: Application = createApp(); @@ -62,6 +64,7 @@ beforeEach(() => { }); afterAll(() => { + closeMcpSessions(); testDb.close(); }); @@ -130,3 +133,160 @@ describe('MCP session init', () => { expect(res.status).toBe(401); }); }); + +describe('MCP API token auth', () => { + it('MCP-002 — POST /mcp with valid trek_ API token authenticates successfully', async () => { + const { user } = createUser(testDb); + const { rawToken } = createMcpToken(testDb, user.id); + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${rawToken}`) + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } }); + expect(res.status).toBe(200); + }); + + it('MCP-002 — last_used_at is updated on token use', async () => { + const { user } = createUser(testDb); + const { rawToken, id: tokenId } = createMcpToken(testDb, user.id); + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + + const before = (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }).last_used_at; + + await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${rawToken}`) + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } }); + + const after = (testDb.prepare('SELECT last_used_at FROM mcp_tokens WHERE id = ?').get(tokenId) as { last_used_at: string | null }).last_used_at; + expect(after).not.toBeNull(); + expect(after).not.toBe(before); + }); + + it('MCP — POST /mcp with unknown trek_ token returns 401', async () => { + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + + const res = await request(app) + .post('/mcp') + .set('Authorization', 'Bearer trek_totally_fake_token_not_in_db') + .send({ jsonrpc: '2.0', method: 'initialize', id: 1 }); + expect(res.status).toBe(401); + }); + + it('MCP — POST /mcp with no Authorization header returns 401', async () => { + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + + const res = await request(app) + .post('/mcp') + .send({ jsonrpc: '2.0', method: 'initialize', id: 1 }); + expect(res.status).toBe(401); + }); +}); + +describe('MCP session management', () => { + async function createSession(userId: number): Promise { + const token = generateToken(userId); + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${token}`) + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } }); + expect(res.status).toBe(200); + const sessionId = res.headers['mcp-session-id']; + expect(sessionId).toBeTruthy(); + return sessionId as string; + } + + it('MCP-003 — session limit of 5 per user', async () => { + const { user } = createUser(testDb); + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + + // Create 5 sessions + for (let i = 0; i < 5; i++) { + await createSession(user.id); + } + + // 6th should fail + const token = generateToken(user.id); + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${token}`) + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'initialize', id: 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } }); + expect(res.status).toBe(429); + expect(res.body.error).toMatch(/session limit/i); + }); + + it('MCP — session resumption with valid mcp-session-id', async () => { + const { user } = createUser(testDb); + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + const sessionId = await createSession(user.id); + const token = generateToken(user.id); + + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${token}`) + .set('mcp-session-id', sessionId) + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'tools/list', id: 2, params: {} }); + expect(res.status).toBe(200); + }); + + it('MCP — session belongs to different user returns 403', async () => { + const { user: user1 } = createUser(testDb); + const { user: user2 } = createUser(testDb); + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + + const sessionId = await createSession(user1.id); + const token2 = generateToken(user2.id); + + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${token2}`) + .set('mcp-session-id', sessionId) + .send({ jsonrpc: '2.0', method: 'tools/list', id: 2 }); + expect(res.status).toBe(403); + }); + + it('MCP — GET without mcp-session-id returns 400', async () => { + const { user } = createUser(testDb); + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + const token = generateToken(user.id); + + const res = await request(app) + .get('/mcp') + .set('Authorization', `Bearer ${token}`); + expect(res.status).toBe(400); + }); +}); + +describe('MCP rate limiting', () => { + it('MCP-005 — requests below limit succeed', async () => { + const { user } = createUser(testDb); + testDb.prepare("UPDATE addons SET enabled = 1 WHERE id = 'mcp'").run(); + const token = generateToken(user.id); + + // Set a very low rate limit via env for this test + const originalLimit = process.env.MCP_RATE_LIMIT; + process.env.MCP_RATE_LIMIT = '3'; + + try { + for (let i = 0; i < 3; i++) { + const res = await request(app) + .post('/mcp') + .set('Authorization', `Bearer ${token}`) + .set('Accept', 'application/json, text/event-stream') + .send({ jsonrpc: '2.0', method: 'initialize', id: i + 1, params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 'test', version: '1' } } }); + // Each should pass (no rate limit hit yet since limit is read at module init, + // but we can verify that the responses are not 429) + expect(res.status).not.toBe(429); + } + } finally { + if (originalLimit === undefined) delete process.env.MCP_RATE_LIMIT; + else process.env.MCP_RATE_LIMIT = originalLimit; + } + }); +}); diff --git a/server/tests/unit/mcp/resources.test.ts b/server/tests/unit/mcp/resources.test.ts new file mode 100644 index 0000000..f968150 --- /dev/null +++ b/server/tests/unit/mcp/resources.test.ts @@ -0,0 +1,489 @@ +/** + * Unit tests for MCP resources (resources.ts). + * Tests all 14 resources via InMemoryTransport + Client. + */ +import { describe, it, expect, vi, beforeAll, beforeEach, afterAll, afterEach } 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: (placeId: number) => { + const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId); + if (!place) return null; + const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId); + return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags }; + }, + 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, createTrip, createDay, createPlace, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createBucketListItem, createVisitedCountry, createDayAssignment, createDayAccommodation } from '../../helpers/factories'; +import { createMcpHarness, parseResourceResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); +}); + +afterAll(() => { + testDb.close(); +}); + +async function withHarness(userId: number, fn: (harness: McpHarness) => Promise) { + const harness = await createMcpHarness({ userId, withTools: false, withResources: true }); + try { + await fn(harness); + } finally { + await harness.cleanup(); + } +} + +describe('Resource: trek://trips', () => { + it('returns all trips the user owns or is a member of', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + createTrip(testDb, user.id, { title: 'My Trip' }); + const sharedTrip = createTrip(testDb, other.id, { title: 'Shared Trip' }); + addTripMember(testDb, sharedTrip.id, user.id); + // Trip from another user (not accessible) + createTrip(testDb, other.id, { title: 'Other Trip' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://trips' }); + const trips = parseResourceResult(result) as any[]; + expect(trips).toHaveLength(2); + const titles = trips.map((t) => t.title); + expect(titles).toContain('My Trip'); + expect(titles).toContain('Shared Trip'); + expect(titles).not.toContain('Other Trip'); + }); + }); + + it('excludes archived trips', async () => { + const { user } = createUser(testDb); + createTrip(testDb, user.id, { title: 'Active Trip' }); + const archived = createTrip(testDb, user.id, { title: 'Archived Trip' }); + testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://trips' }); + const trips = parseResourceResult(result) as any[]; + expect(trips).toHaveLength(1); + expect(trips[0].title).toBe('Active Trip'); + }); + }); + + it('returns empty array when user has no trips', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://trips' }); + const trips = parseResourceResult(result) as any[]; + expect(trips).toEqual([]); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}', () => { + it('returns trip data for an accessible trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Paris Trip' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}` }); + const data = parseResourceResult(result) as any; + expect(data.title).toBe('Paris Trip'); + expect(data.id).toBe(trip.id); + }); + }); + + it('returns access denied for inaccessible trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const otherTrip = createTrip(testDb, other.id, { title: 'Private' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${otherTrip.id}` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); + + it('returns access denied for non-existent ID', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://trips/99999' }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/days', () => { + it('returns days with assignments in order', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const place = createPlace(testDb, trip.id); + createDayAssignment(testDb, day1.id, place.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days` }); + const days = parseResourceResult(result) as any[]; + expect(days).toHaveLength(2); + expect(days[0].day_number).toBe(1); + expect(days[0].assignments).toHaveLength(1); + expect(days[1].day_number).toBe(2); + expect(days[1].assignments).toHaveLength(0); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/places', () => { + it('returns all places for a trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); + createPlace(testDb, trip.id, { name: 'Louvre' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/places` }); + const places = parseResourceResult(result) as any[]; + expect(places).toHaveLength(2); + const names = places.map((p) => p.name); + expect(names).toContain('Eiffel Tower'); + expect(names).toContain('Louvre'); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/places` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/budget', () => { + it('returns budget items for a trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createBudgetItem(testDb, trip.id, { name: 'Hotel', category: 'Accommodation', total_price: 200 }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/budget` }); + const items = parseResourceResult(result) as any[]; + expect(items).toHaveLength(1); + expect(items[0].name).toBe('Hotel'); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/budget` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/packing', () => { + it('returns packing items for a trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createPackingItem(testDb, trip.id, { name: 'Passport' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/packing` }); + const items = parseResourceResult(result) as any[]; + expect(items).toHaveLength(1); + expect(items[0].name).toBe('Passport'); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/packing` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/reservations', () => { + it('returns reservations for a trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createReservation(testDb, trip.id, { title: 'Flight to Paris', type: 'flight' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/reservations` }); + const items = parseResourceResult(result) as any[]; + expect(items).toHaveLength(1); + expect(items[0].title).toBe('Flight to Paris'); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/reservations` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/days/{dayId}/notes', () => { + it('returns notes for a specific day', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + createDayNote(testDb, day.id, trip.id, { text: 'Check in at noon' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days/${day.id}/notes` }); + const notes = parseResourceResult(result) as any[]; + expect(notes).toHaveLength(1); + expect(notes[0].text).toBe('Check in at noon'); + }); + }); + + it('returns access denied for unauthorized trip', 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 (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days/${day.id}/notes` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); + + it('returns access denied for invalid dayId', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/days/abc/notes` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/accommodations', () => { + it('returns accommodations for a trip', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const place = createPlace(testDb, trip.id, { name: 'Grand Hotel' }); + createDayAccommodation(testDb, trip.id, place.id, day1.id, day2.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/accommodations` }); + const items = parseResourceResult(result) as any[]; + expect(items).toHaveLength(1); + expect(items[0].place_name).toBe('Grand Hotel'); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/accommodations` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/members', () => { + it('returns owner and collaborators', async () => { + const { user } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, user.id); + addTripMember(testDb, trip.id, member.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/members` }); + const data = parseResourceResult(result) as any; + expect(data.owner).toBeTruthy(); + expect(data.owner.id).toBe(user.id); + expect(data.members).toHaveLength(1); + expect(data.members[0].id).toBe(member.id); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/members` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://trips/{tripId}/collab-notes', () => { + it('returns collab notes with username', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + createCollabNote(testDb, trip.id, user.id, { title: 'Ideas' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` }); + const notes = parseResourceResult(result) as any[]; + expect(notes).toHaveLength(1); + expect(notes[0].title).toBe('Ideas'); + expect(notes[0].username).toBeTruthy(); + }); + }); + + it('returns access denied for unauthorized trip', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: `trek://trips/${trip.id}/collab-notes` }); + const data = parseResourceResult(result) as any; + expect(data.error).toBeTruthy(); + }); + }); +}); + +describe('Resource: trek://categories', () => { + it('returns all categories', async () => { + const { user } = createUser(testDb); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://categories' }); + const categories = parseResourceResult(result) as any[]; + expect(categories.length).toBeGreaterThan(0); + expect(categories[0]).toHaveProperty('id'); + expect(categories[0]).toHaveProperty('name'); + expect(categories[0]).toHaveProperty('color'); + expect(categories[0]).toHaveProperty('icon'); + }); + }); +}); + +describe('Resource: trek://bucket-list', () => { + it('returns only the current user\'s bucket list items', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + createBucketListItem(testDb, user.id, { name: 'Tokyo' }); + createBucketListItem(testDb, other.id, { name: 'Rome' }); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://bucket-list' }); + const items = parseResourceResult(result) as any[]; + expect(items).toHaveLength(1); + expect(items[0].name).toBe('Tokyo'); + }); + }); + + it('returns empty array for user with no items', async () => { + const { user } = createUser(testDb); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://bucket-list' }); + const items = parseResourceResult(result) as any[]; + expect(items).toEqual([]); + }); + }); +}); + +describe('Resource: trek://visited-countries', () => { + it('returns only the current user\'s visited countries', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + createVisitedCountry(testDb, user.id, 'FR'); + createVisitedCountry(testDb, user.id, 'JP'); + createVisitedCountry(testDb, other.id, 'DE'); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://visited-countries' }); + const countries = parseResourceResult(result) as any[]; + expect(countries).toHaveLength(2); + const codes = countries.map((c) => c.country_code); + expect(codes).toContain('FR'); + expect(codes).toContain('JP'); + expect(codes).not.toContain('DE'); + }); + }); + + it('returns empty array for user with no visited countries', async () => { + const { user } = createUser(testDb); + + await withHarness(user.id, async (harness) => { + const result = await harness.client.readResource({ uri: 'trek://visited-countries' }); + const countries = parseResourceResult(result) as any[]; + expect(countries).toEqual([]); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-assignments.test.ts b/server/tests/unit/mcp/tools-assignments.test.ts new file mode 100644 index 0000000..20221fc --- /dev/null +++ b/server/tests/unit/mcp/tools-assignments.test.ts @@ -0,0 +1,358 @@ +/** + * Unit tests for MCP assignment tools: assign_place_to_day, unassign_place, + * reorder_day_assignments, update_assignment_time. + */ +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 } 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(); } +} + +// --------------------------------------------------------------------------- +// assign_place_to_day +// --------------------------------------------------------------------------- + +describe('Tool: assign_place_to_day', () => { + it('assigns a place to a day', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'assign_place_to_day', + arguments: { tripId: trip.id, dayId: day.id, placeId: place.id }, + }); + const data = parseToolResult(result) as any; + expect(data.assignment).toBeTruthy(); + expect(data.assignment.day_id).toBe(day.id); + expect(data.assignment.place_id).toBe(place.id); + expect(data.assignment.order_index).toBe(0); + }); + }); + + it('auto-increments order_index for subsequent assignments', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place1 = createPlace(testDb, trip.id, { name: 'P1' }); + const place2 = createPlace(testDb, trip.id, { name: 'P2' }); + createDayAssignment(testDb, day.id, place1.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'assign_place_to_day', + arguments: { tripId: trip.id, dayId: day.id, placeId: place2.id }, + }); + const data = parseToolResult(result) as any; + expect(data.assignment.order_index).toBe(1); + }); + }); + + it('broadcasts assignment:created event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'assign_place_to_day', arguments: { tripId: trip.id, dayId: day.id, placeId: place.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:created', expect.any(Object)); + }); + }); + + it('returns error when day does not belong to trip', async () => { + const { user } = createUser(testDb); + const trip1 = createTrip(testDb, user.id); + const trip2 = createTrip(testDb, user.id); + const dayFromTrip2 = createDay(testDb, trip2.id); + const place = createPlace(testDb, trip1.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'assign_place_to_day', + arguments: { tripId: trip1.id, dayId: dayFromTrip2.id, placeId: place.id }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns error when place does not belong to trip', async () => { + const { user } = createUser(testDb); + const trip1 = createTrip(testDb, user.id); + const trip2 = createTrip(testDb, user.id); + const day = createDay(testDb, trip1.id); + const placeFromTrip2 = createPlace(testDb, trip2.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'assign_place_to_day', + arguments: { tripId: trip1.id, dayId: day.id, placeId: placeFromTrip2.id }, + }); + 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 day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'assign_place_to_day', arguments: { tripId: trip.id, dayId: day.id, placeId: place.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// unassign_place +// --------------------------------------------------------------------------- + +describe('Tool: unassign_place', () => { + it('removes a place assignment from a day', 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: 'unassign_place', + arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM day_assignments WHERE id = ?').get(assignment.id)).toBeUndefined(); + }); + }); + + it('broadcasts assignment:deleted event', 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) => { + await h.client.callTool({ name: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:deleted', expect.any(Object)); + }); + }); + + it('returns error when assignment is not found', 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: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: 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 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: 'unassign_place', arguments: { tripId: trip.id, dayId: day.id, assignmentId: assignment.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// reorder_day_assignments +// --------------------------------------------------------------------------- + +describe('Tool: reorder_day_assignments', () => { + it('reorders assignments by updating order_index', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place1 = createPlace(testDb, trip.id, { name: 'First' }); + const place2 = createPlace(testDb, trip.id, { name: 'Second' }); + const a1 = createDayAssignment(testDb, day.id, place1.id, { order_index: 0 }); + const a2 = createDayAssignment(testDb, day.id, place2.id, { order_index: 1 }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'reorder_day_assignments', + arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [a2.id, a1.id] }, + }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + + const a1Updated = testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a1.id) as { order_index: number }; + const a2Updated = testDb.prepare('SELECT order_index FROM day_assignments WHERE id = ?').get(a2.id) as { order_index: number }; + expect(a2Updated.order_index).toBe(0); + expect(a1Updated.order_index).toBe(1); + }); + }); + + it('broadcasts assignment:reordered event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const a = createDayAssignment(testDb, day.id, place.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [a.id] } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:reordered', expect.any(Object)); + }); + }); + + it('returns error when day does not belong to trip', async () => { + const { user } = createUser(testDb); + const trip1 = createTrip(testDb, user.id); + const trip2 = createTrip(testDb, user.id); + const day = createDay(testDb, trip2.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip1.id, dayId: day.id, assignmentIds: [1] } }); + 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 day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'reorder_day_assignments', arguments: { tripId: trip.id, dayId: day.id, assignmentIds: [1] } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_assignment_time +// --------------------------------------------------------------------------- + +describe('Tool: update_assignment_time', () => { + it('sets start and end times for an assignment', 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: 'update_assignment_time', + arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '09:00', end_time: '11:30' }, + }); + const data = parseToolResult(result) as any; + expect(data.assignment.assignment_time).toBe('09:00'); + expect(data.assignment.assignment_end_time).toBe('11:30'); + }); + }); + + it('clears times with null', 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); + testDb.prepare('UPDATE day_assignments SET assignment_time = ?, assignment_end_time = ? WHERE id = ?').run('09:00', '11:00', assignment.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_assignment_time', + arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: null, end_time: null }, + }); + const data = parseToolResult(result) as any; + expect(data.assignment.assignment_time).toBeNull(); + expect(data.assignment.assignment_end_time).toBeNull(); + }); + }); + + it('broadcasts assignment:updated event', 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) => { + await h.client.callTool({ name: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '10:00' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'assignment:updated', expect.any(Object)); + }); + }); + + it('returns error when assignment 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_assignment_time', arguments: { tripId: trip.id, assignmentId: 99999, place_time: '09:00' } }); + 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 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: 'update_assignment_time', arguments: { tripId: trip.id, assignmentId: assignment.id, place_time: '09:00' } }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-atlas.test.ts b/server/tests/unit/mcp/tools-atlas.test.ts new file mode 100644 index 0000000..b4d08d2 --- /dev/null +++ b/server/tests/unit/mcp/tools-atlas.test.ts @@ -0,0 +1,218 @@ +/** + * Unit tests for MCP atlas and bucket list tools: + * mark_country_visited, unmark_country_visited, create_bucket_list_item, delete_bucket_list_item. + */ +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, createBucketListItem, createVisitedCountry } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + 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(); } +} + +// --------------------------------------------------------------------------- +// mark_country_visited +// --------------------------------------------------------------------------- + +describe('Tool: mark_country_visited', () => { + it('marks a country as visited', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'mark_country_visited', arguments: { country_code: 'FR' } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(data.country_code).toBe('FR'); + const row = testDb.prepare('SELECT country_code FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'FR'); + expect(row).toBeTruthy(); + }); + }); + + it('is idempotent — marking twice does not error', async () => { + const { user } = createUser(testDb); + createVisitedCountry(testDb, user.id, 'JP'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'mark_country_visited', arguments: { country_code: 'JP' } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + const count = (testDb.prepare('SELECT COUNT(*) as c FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'JP') as { c: number }).c; + expect(count).toBe(1); + }); + }); + + 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_country_visited', arguments: { country_code: 'DE' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// unmark_country_visited +// --------------------------------------------------------------------------- + +describe('Tool: unmark_country_visited', () => { + it('removes a visited country', async () => { + const { user } = createUser(testDb); + createVisitedCountry(testDb, user.id, 'ES'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'ES' } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + const row = testDb.prepare('SELECT country_code FROM visited_countries WHERE user_id = ? AND country_code = ?').get(user.id, 'ES'); + expect(row).toBeUndefined(); + }); + }); + + it('succeeds even when country was not marked (no-op)', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'AU' } }); + 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' }); + createVisitedCountry(testDb, user.id, 'IT'); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'unmark_country_visited', arguments: { country_code: 'IT' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// create_bucket_list_item +// --------------------------------------------------------------------------- + +describe('Tool: create_bucket_list_item', () => { + it('creates a bucket list item with all fields', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_bucket_list_item', + arguments: { name: 'Kyoto', lat: 35.0116, lng: 135.7681, country_code: 'JP', notes: 'Cherry blossom season' }, + }); + const data = parseToolResult(result) as any; + expect(data.item.name).toBe('Kyoto'); + expect(data.item.country_code).toBe('JP'); + expect(data.item.notes).toBe('Cherry blossom season'); + }); + }); + + it('creates a minimal item (name only)', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_bucket_list_item', arguments: { name: 'Antarctica' } }); + const data = parseToolResult(result) as any; + expect(data.item.name).toBe('Antarctica'); + expect(data.item.user_id).toBe(user.id); + }); + }); + + 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_bucket_list_item', arguments: { name: 'Nowhere' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_bucket_list_item +// --------------------------------------------------------------------------- + +describe('Tool: delete_bucket_list_item', () => { + it('deletes a bucket list item owned by the user', async () => { + const { user } = createUser(testDb); + const item = createBucketListItem(testDb, user.id, { name: 'Machu Picchu' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_bucket_list_item', arguments: { itemId: item.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM bucket_list WHERE id = ?').get(item.id)).toBeUndefined(); + }); + }); + + it('returns error for item not found (wrong user)', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const item = createBucketListItem(testDb, other.id, { name: "Other's Wish" }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_bucket_list_item', arguments: { itemId: item.id } }); + expect(result.isError).toBe(true); + }); + }); + + it('returns error for non-existent item', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_bucket_list_item', arguments: { itemId: 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 item = createBucketListItem(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_bucket_list_item', arguments: { itemId: item.id } }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-budget.test.ts b/server/tests/unit/mcp/tools-budget.test.ts new file mode 100644 index 0000000..38e33b4 --- /dev/null +++ b/server/tests/unit/mcp/tools-budget.test.ts @@ -0,0 +1,223 @@ +/** + * Unit tests for MCP budget tools: create_budget_item, update_budget_item, delete_budget_item. + */ +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(); } +} + +// --------------------------------------------------------------------------- +// create_budget_item +// --------------------------------------------------------------------------- + +describe('Tool: create_budget_item', () => { + it('creates a budget 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_budget_item', + arguments: { tripId: trip.id, name: 'Hotel Paris', category: 'Accommodation', total_price: 500, note: 'Prepaid' }, + }); + const data = parseToolResult(result) as any; + expect(data.item.name).toBe('Hotel Paris'); + expect(data.item.category).toBe('Accommodation'); + expect(data.item.total_price).toBe(500); + expect(data.item.note).toBe('Prepaid'); + }); + }); + + it('defaults category to "Other" when not specified', 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_budget_item', + arguments: { tripId: trip.id, name: 'Misc', total_price: 10 }, + }); + const data = parseToolResult(result) as any; + expect(data.item.category).toBe('Other'); + }); + }); + + it('broadcasts budget: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_budget_item', arguments: { tripId: trip.id, name: 'Taxi', total_price: 25 } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget: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_budget_item', arguments: { tripId: trip.id, name: 'Hack', total_price: 0 } }); + 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_budget_item', arguments: { tripId: trip.id, name: 'X', total_price: 0 } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_budget_item +// --------------------------------------------------------------------------- + +describe('Tool: update_budget_item', () => { + it('updates budget item fields', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createBudgetItem(testDb, trip.id, { name: 'Old', category: 'Food', total_price: 50 }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_budget_item', + arguments: { tripId: trip.id, itemId: item.id, name: 'New Name', total_price: 75 }, + }); + const data = parseToolResult(result) as any; + expect(data.item.name).toBe('New Name'); + expect(data.item.total_price).toBe(75); + expect(data.item.category).toBe('Food'); // preserved + }); + }); + + it('broadcasts budget:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createBudgetItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_budget_item', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget: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_budget_item', 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 = createBudgetItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_budget_item', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_budget_item +// --------------------------------------------------------------------------- + +describe('Tool: delete_budget_item', () => { + it('deletes an existing budget item', async () => { + const { user } = createUser(testDb); + 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: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM budget_items WHERE id = ?').get(item.id)).toBeUndefined(); + }); + }); + + it('broadcasts budget:deleted event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createBudgetItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'budget: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_budget_item', 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 = createBudgetItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_budget_item', arguments: { tripId: trip.id, itemId: item.id } }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-days.test.ts b/server/tests/unit/mcp/tools-days.test.ts new file mode 100644 index 0000000..23e9dfe --- /dev/null +++ b/server/tests/unit/mcp/tools-days.test.ts @@ -0,0 +1,142 @@ +/** + * Unit tests for MCP day tools: update_day. + */ +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 } 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(); } +} + +// --------------------------------------------------------------------------- +// update_day +// --------------------------------------------------------------------------- + +describe('Tool: update_day', () => { + it('sets a day title', 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: 'update_day', + arguments: { tripId: trip.id, dayId: day.id, title: 'Arrival in Paris' }, + }); + const data = parseToolResult(result) as any; + expect(data.day.title).toBe('Arrival in Paris'); + }); + }); + + it('clears a day title with null', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id, { title: 'Old Title' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_day', + arguments: { tripId: trip.id, dayId: day.id, title: null }, + }); + const data = parseToolResult(result) as any; + expect(data.day.title).toBeNull(); + }); + }); + + it('broadcasts day:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_day', arguments: { tripId: trip.id, dayId: day.id, title: 'Day 1' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:updated', expect.any(Object)); + }); + }); + + it('returns error when day does not belong to trip', async () => { + const { user } = createUser(testDb); + const trip1 = createTrip(testDb, user.id); + const trip2 = createTrip(testDb, user.id); + const dayFromTrip2 = createDay(testDb, trip2.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_day', + arguments: { tripId: trip1.id, dayId: dayFromTrip2.id, title: '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 day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_day', arguments: { tripId: trip.id, dayId: day.id, title: '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 day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_day', arguments: { tripId: trip.id, dayId: day.id, title: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-notes.test.ts b/server/tests/unit/mcp/tools-notes.test.ts new file mode 100644 index 0000000..a3927b4 --- /dev/null +++ b/server/tests/unit/mcp/tools-notes.test.ts @@ -0,0 +1,431 @@ +/** + * Unit tests for MCP note tools: create_day_note, update_day_note, delete_day_note, + * create_collab_note, update_collab_note, delete_collab_note. + */ +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, unlinkSyncMock } = vi.hoisted(() => ({ + broadcastMock: vi.fn(), + unlinkSyncMock: vi.fn(), +})); +vi.mock('../../../src/websocket', () => ({ broadcast: broadcastMock })); +vi.mock('fs', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, unlinkSync: unlinkSyncMock }; +}); + +import { createTables } from '../../../src/db/schema'; +import { runMigrations } from '../../../src/db/migrations'; +import { resetTestDb } from '../../helpers/test-db'; +import { createUser, createTrip, createDay, createDayNote, createCollabNote } from '../../helpers/factories'; +import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; + +beforeAll(() => { + createTables(testDb); + runMigrations(testDb); +}); + +beforeEach(() => { + resetTestDb(testDb); + broadcastMock.mockClear(); + unlinkSyncMock.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_note +// --------------------------------------------------------------------------- + +describe('Tool: create_day_note', () => { + it('creates a note on a day', 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: 'create_day_note', + arguments: { tripId: trip.id, dayId: day.id, text: 'Check in at noon', time: '12:00', icon: '🏨' }, + }); + const data = parseToolResult(result) as any; + expect(data.note.text).toBe('Check in at noon'); + expect(data.note.time).toBe('12:00'); + expect(data.note.icon).toBe('🏨'); + }); + }); + + it('defaults icon to 📝', 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: 'create_day_note', + arguments: { tripId: trip.id, dayId: day.id, text: 'A note' }, + }); + const data = parseToolResult(result) as any; + expect(data.note.icon).toBe('📝'); + }); + }); + + it('broadcasts dayNote:created event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'create_day_note', arguments: { tripId: trip.id, dayId: day.id, text: 'Note' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:created', expect.any(Object)); + }); + }); + + it('returns error when day does not belong to trip', async () => { + const { user } = createUser(testDb); + const trip1 = createTrip(testDb, user.id); + const trip2 = createTrip(testDb, user.id); + const dayFromTrip2 = createDay(testDb, trip2.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_day_note', arguments: { tripId: trip1.id, dayId: dayFromTrip2.id, text: 'Note' } }); + 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 day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_day_note', arguments: { tripId: trip.id, dayId: day.id, text: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_day_note +// --------------------------------------------------------------------------- + +describe('Tool: update_day_note', () => { + it('updates note text, time, icon', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const note = createDayNote(testDb, day.id, trip.id, { text: 'Old text', time: '09:00', icon: '📝' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_day_note', + arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'New text', time: '14:00', icon: '🍽️' }, + }); + const data = parseToolResult(result) as any; + expect(data.note.text).toBe('New text'); + expect(data.note.time).toBe('14:00'); + expect(data.note.icon).toBe('🍽️'); + }); + }); + + it('trims text whitespace', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const note = createDayNote(testDb, day.id, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_day_note', + arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: ' Trimmed ' }, + }); + const data = parseToolResult(result) as any; + expect(data.note.text).toBe('Trimmed'); + }); + }); + + it('broadcasts dayNote:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const note = createDayNote(testDb, day.id, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'Updated' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:updated', expect.any(Object)); + }); + }); + + it('returns error when note not found', 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: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: 99999, text: '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 day = createDay(testDb, trip.id); + const note = createDayNote(testDb, day.id, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id, text: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_day_note +// --------------------------------------------------------------------------- + +describe('Tool: delete_day_note', () => { + it('deletes a day note', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const note = createDayNote(testDb, day.id, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM day_notes WHERE id = ?').get(note.id)).toBeUndefined(); + }); + }); + + it('broadcasts dayNote:deleted event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const note = createDayNote(testDb, day.id, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'dayNote:deleted', expect.any(Object)); + }); + }); + + it('returns error when note not found', 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_note', arguments: { tripId: trip.id, dayId: day.id, noteId: 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 day = createDay(testDb, trip.id); + const note = createDayNote(testDb, day.id, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_day_note', arguments: { tripId: trip.id, dayId: day.id, noteId: note.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// create_collab_note +// --------------------------------------------------------------------------- + +describe('Tool: create_collab_note', () => { + it('creates a collab note 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_collab_note', + arguments: { tripId: trip.id, title: 'Ideas', content: 'Visit museums', category: 'Culture', color: '#3b82f6' }, + }); + const data = parseToolResult(result) as any; + expect(data.note.title).toBe('Ideas'); + expect(data.note.content).toBe('Visit museums'); + expect(data.note.category).toBe('Culture'); + expect(data.note.color).toBe('#3b82f6'); + }); + }); + + it('defaults category to "General" and color to "#6366f1"', 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_note', arguments: { tripId: trip.id, title: 'Quick note' } }); + const data = parseToolResult(result) as any; + expect(data.note.category).toBe('General'); + expect(data.note.color).toBe('#6366f1'); + }); + }); + + it('broadcasts collab:note: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_collab_note', arguments: { tripId: trip.id, title: 'Note' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:note: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_collab_note', arguments: { tripId: trip.id, title: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_collab_note +// --------------------------------------------------------------------------- + +describe('Tool: update_collab_note', () => { + it('updates collab note fields', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const note = createCollabNote(testDb, trip.id, user.id, { title: 'Old', color: '#6366f1' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_collab_note', + arguments: { tripId: trip.id, noteId: note.id, title: 'New Title', pinned: true, color: '#3b82f6' }, + }); + const data = parseToolResult(result) as any; + expect(data.note.title).toBe('New Title'); + expect(data.note.pinned).toBe(1); + expect(data.note.color).toBe('#3b82f6'); + }); + }); + + it('broadcasts collab:note:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const note = createCollabNote(testDb, trip.id, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'Updated' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:note:updated', expect.any(Object)); + }); + }); + + it('returns error when note 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_collab_note', arguments: { tripId: trip.id, noteId: 99999, title: '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 note = createCollabNote(testDb, trip.id, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_collab_note', arguments: { tripId: trip.id, noteId: note.id, title: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_collab_note +// --------------------------------------------------------------------------- + +describe('Tool: delete_collab_note', () => { + it('deletes a collab note', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const note = createCollabNote(testDb, trip.id, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined(); + }); + }); + + it('deletes associated trip_files records from the database', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const note = createCollabNote(testDb, trip.id, user.id); + // Insert a trip_file linked to this note + testDb.prepare( + `INSERT INTO trip_files (trip_id, note_id, filename, original_name, mime_type, file_size) VALUES (?, ?, ?, ?, ?, ?)` + ).run(trip.id, note.id, 'test-file.pdf', 'document.pdf', 'application/pdf', 1024); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } }); + expect((parseToolResult(result) as any).success).toBe(true); + }); + + // trip_files rows are deleted as part of the transaction + expect(testDb.prepare('SELECT id FROM trip_files WHERE note_id = ?').all(note.id)).toHaveLength(0); + // note itself is deleted + expect(testDb.prepare('SELECT id FROM collab_notes WHERE id = ?').get(note.id)).toBeUndefined(); + }); + + it('broadcasts collab:note:deleted event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const note = createCollabNote(testDb, trip.id, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'collab:note:deleted', expect.any(Object)); + }); + }); + + it('returns error when note 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_collab_note', arguments: { tripId: trip.id, noteId: 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 note = createCollabNote(testDb, trip.id, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_collab_note', arguments: { tripId: trip.id, noteId: note.id } }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-packing.test.ts b/server/tests/unit/mcp/tools-packing.test.ts new file mode 100644 index 0000000..edfa120 --- /dev/null +++ b/server/tests/unit/mcp/tools-packing.test.ts @@ -0,0 +1,287 @@ +/** + * Unit tests for MCP packing tools: create_packing_item, update_packing_item, + * toggle_packing_item, delete_packing_item. + */ +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(); } +} + +// --------------------------------------------------------------------------- +// create_packing_item +// --------------------------------------------------------------------------- + +describe('Tool: create_packing_item', () => { + it('creates a packing 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_packing_item', + arguments: { tripId: trip.id, name: 'Passport', category: 'Documents' }, + }); + const data = parseToolResult(result) as any; + expect(data.item.name).toBe('Passport'); + expect(data.item.category).toBe('Documents'); + expect(data.item.checked).toBe(0); + }); + }); + + it('defaults category to "General"', 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_item', + arguments: { tripId: trip.id, name: 'Sunscreen' }, + }); + const data = parseToolResult(result) as any; + expect(data.item.category).toBe('General'); + }); + }); + + it('broadcasts packing: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_packing_item', arguments: { tripId: trip.id, name: 'Hat' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing: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_item', 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_packing_item', arguments: { tripId: trip.id, name: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_packing_item +// --------------------------------------------------------------------------- + +describe('Tool: update_packing_item', () => { + it('updates packing item name and category', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createPackingItem(testDb, trip.id, { name: 'Old', category: 'Clothes' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_packing_item', + arguments: { tripId: trip.id, itemId: item.id, name: 'New Name', category: 'Electronics' }, + }); + const data = parseToolResult(result) as any; + expect(data.item.name).toBe('New Name'); + expect(data.item.category).toBe('Electronics'); + }); + }); + + it('broadcasts packing:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createPackingItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, name: 'Updated' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing: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_packing_item', 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 = createPackingItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_packing_item', arguments: { tripId: trip.id, itemId: item.id, name: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// toggle_packing_item +// --------------------------------------------------------------------------- + +describe('Tool: toggle_packing_item', () => { + it('checks a packing item', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createPackingItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'toggle_packing_item', + arguments: { tripId: trip.id, itemId: item.id, checked: true }, + }); + const data = parseToolResult(result) as any; + expect(data.item.checked).toBe(1); + }); + }); + + it('unchecks a packing item', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createPackingItem(testDb, trip.id); + testDb.prepare('UPDATE packing_items SET checked = 1 WHERE id = ?').run(item.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'toggle_packing_item', + arguments: { tripId: trip.id, itemId: item.id, checked: false }, + }); + const data = parseToolResult(result) as any; + expect(data.item.checked).toBe(0); + }); + }); + + it('broadcasts packing:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createPackingItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: item.id, checked: true } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing: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_packing_item', arguments: { tripId: trip.id, itemId: 99999, checked: true } }); + 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 = createPackingItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'toggle_packing_item', arguments: { tripId: trip.id, itemId: item.id, checked: true } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_packing_item +// --------------------------------------------------------------------------- + +describe('Tool: delete_packing_item', () => { + it('deletes an existing packing item', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createPackingItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM packing_items WHERE id = ?').get(item.id)).toBeUndefined(); + }); + }); + + it('broadcasts packing:deleted event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const item = createPackingItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'packing: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_packing_item', 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 = createPackingItem(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_packing_item', arguments: { tripId: trip.id, itemId: item.id } }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts new file mode 100644 index 0000000..aa05833 --- /dev/null +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -0,0 +1,310 @@ +/** + * Unit tests for MCP place tools: create_place, update_place, delete_place, list_categories, search_place. + */ +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: (placeId: number) => { + const place: any = db.prepare(`SELECT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon FROM places p LEFT JOIN categories c ON p.category_id = c.id WHERE p.id = ?`).get(placeId); + if (!place) return null; + const tags = db.prepare(`SELECT t.* FROM tags t JOIN place_tags pt ON t.id = pt.tag_id WHERE pt.place_id = ?`).all(placeId); + return { ...place, category: place.category_id ? { id: place.category_id, name: place.category_name, color: place.category_color, icon: place.category_icon } : null, tags }; + }, + 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, createPlace } 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_place +// --------------------------------------------------------------------------- + +describe('Tool: create_place', () => { + it('creates a place with all fields', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const cat = testDb.prepare('SELECT id FROM categories LIMIT 1').get() as { id: number }; + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_place', + arguments: { + tripId: trip.id, + name: 'Eiffel Tower', + lat: 48.8584, + lng: 2.2945, + address: 'Champ de Mars, Paris', + category_id: cat.id, + notes: 'Must visit', + website: 'https://toureiffel.paris', + phone: '+33 892 70 12 39', + }, + }); + const data = parseToolResult(result) as any; + expect(data.place.name).toBe('Eiffel Tower'); + expect(data.place.lat).toBeCloseTo(48.8584); + expect(data.place.category_id).toBe(cat.id); + }); + }); + + it('creates a place with minimal 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_place', + arguments: { tripId: trip.id, name: 'Mystery Spot' }, + }); + const data = parseToolResult(result) as any; + expect(data.place.name).toBe('Mystery Spot'); + expect(data.place.trip_id).toBe(trip.id); + }); + }); + + it('broadcasts place: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_place', arguments: { tripId: trip.id, name: 'Cafe' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place: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_place', arguments: { tripId: trip.id, name: 'Hack' } }); + 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_place', arguments: { tripId: trip.id, name: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_place +// --------------------------------------------------------------------------- + +describe('Tool: update_place', () => { + it('updates specific fields and preserves others', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Old Name' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_place', + arguments: { tripId: trip.id, placeId: place.id, name: 'New Name' }, + }); + const data = parseToolResult(result) as any; + expect(data.place.name).toBe('New Name'); + // lat/lng preserved from original + expect(data.place.lat).toBeCloseTo(place.lat ?? 48.8566); + }); + }); + + it('broadcasts place:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'Updated' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:updated', expect.any(Object)); + }); + }); + + it('returns error for place not found in 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: 'update_place', arguments: { tripId: trip.id, placeId: 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 place = createPlace(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_place', arguments: { tripId: trip.id, placeId: place.id, name: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_place +// --------------------------------------------------------------------------- + +describe('Tool: delete_place', () => { + it('deletes an existing place', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM places WHERE id = ?').get(place.id)).toBeUndefined(); + }); + }); + + it('broadcasts place:deleted event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'place:deleted', expect.any(Object)); + }); + }); + + it('returns error for place 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_place', arguments: { tripId: trip.id, placeId: 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 place = createPlace(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_place', arguments: { tripId: trip.id, placeId: place.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// list_categories +// --------------------------------------------------------------------------- + +describe('Tool: list_categories', () => { + it('returns all categories with id, name, color, icon', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_categories', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.categories).toBeDefined(); + expect(data.categories.length).toBeGreaterThan(0); + const cat = data.categories[0]; + expect(cat).toHaveProperty('id'); + expect(cat).toHaveProperty('name'); + expect(cat).toHaveProperty('color'); + expect(cat).toHaveProperty('icon'); + }); + }); +}); + +// --------------------------------------------------------------------------- +// search_place +// --------------------------------------------------------------------------- + +describe('Tool: search_place', () => { + it('returns formatted results from Nominatim', async () => { + const { user } = createUser(testDb); + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + json: async () => [ + { + osm_type: 'node', + osm_id: 12345, + name: 'Eiffel Tower', + display_name: 'Eiffel Tower, Paris, France', + lat: '48.8584', + lon: '2.2945', + }, + ], + }); + vi.stubGlobal('fetch', mockFetch); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'Eiffel Tower' } }); + const data = parseToolResult(result) as any; + expect(data.places).toHaveLength(1); + expect(data.places[0].name).toBe('Eiffel Tower'); + expect(data.places[0].osm_id).toBe('node:12345'); + expect(data.places[0].lat).toBeCloseTo(48.8584); + }); + + vi.unstubAllGlobals(); + }); + + it('returns error when Nominatim API fails', async () => { + const { user } = createUser(testDb); + vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false })); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'search_place', arguments: { query: 'something' } }); + expect(result.isError).toBe(true); + }); + + vi.unstubAllGlobals(); + }); +}); diff --git a/server/tests/unit/mcp/tools-reservations.test.ts b/server/tests/unit/mcp/tools-reservations.test.ts new file mode 100644 index 0000000..30a813b --- /dev/null +++ b/server/tests/unit/mcp/tools-reservations.test.ts @@ -0,0 +1,434 @@ +/** + * Unit tests for MCP reservation tools: create_reservation, update_reservation, + * delete_reservation, link_hotel_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, createReservation, createDayAssignment } 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_reservation +// --------------------------------------------------------------------------- + +describe('Tool: create_reservation', () => { + it('creates a basic flight reservation', 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_reservation', + arguments: { tripId: trip.id, title: 'Flight to Rome', type: 'flight' }, + }); + const data = parseToolResult(result) as any; + expect(data.reservation.title).toBe('Flight to Rome'); + expect(data.reservation.type).toBe('flight'); + expect(data.reservation.status).toBe('pending'); + }); + }); + + it('creates a hotel reservation and links accommodation', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const hotel = createPlace(testDb, trip.id, { name: 'Grand Hotel' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_reservation', + arguments: { + tripId: trip.id, + title: 'Grand Hotel Stay', + type: 'hotel', + place_id: hotel.id, + start_day_id: day1.id, + end_day_id: day2.id, + check_in: '15:00', + check_out: '11:00', + }, + }); + const data = parseToolResult(result) as any; + expect(data.reservation.type).toBe('hotel'); + expect(data.reservation.accommodation_id).not.toBeNull(); + // accommodation was created + const acc = testDb.prepare('SELECT * FROM day_accommodations WHERE id = ?').get(data.reservation.accommodation_id) as any; + expect(acc.place_id).toBe(hotel.id); + expect(acc.check_in).toBe('15:00'); + }); + }); + + it('validates day_id belongs to trip', async () => { + const { user } = createUser(testDb); + const trip1 = createTrip(testDb, user.id); + const trip2 = createTrip(testDb, user.id); + const dayFromTrip2 = createDay(testDb, trip2.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_reservation', + arguments: { tripId: trip1.id, title: 'Flight', type: 'flight', day_id: dayFromTrip2.id }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('validates assignment_id belongs to trip', async () => { + const { user } = createUser(testDb); + const trip1 = createTrip(testDb, user.id); + const trip2 = createTrip(testDb, user.id); + const day2 = createDay(testDb, trip2.id); + const place2 = createPlace(testDb, trip2.id); + const assignment = createDayAssignment(testDb, day2.id, place2.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_reservation', + arguments: { tripId: trip1.id, title: 'Dinner', type: 'restaurant', assignment_id: assignment.id }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('broadcasts reservation: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_reservation', arguments: { tripId: trip.id, title: 'Bus', type: 'other' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:created', expect.any(Object)); + }); + }); + + it('broadcasts accommodation:created for hotel type', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const hotel = createPlace(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'create_reservation', + arguments: { tripId: trip.id, title: 'Hotel', type: 'hotel', place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id }, + }); + 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); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_reservation', arguments: { tripId: trip.id, title: 'X', type: 'flight' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_reservation +// --------------------------------------------------------------------------- + +describe('Tool: update_reservation', () => { + it('updates reservation fields', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id, { title: 'Old Title', type: 'flight' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_reservation', + arguments: { tripId: trip.id, reservationId: reservation.id, title: 'New Title' }, + }); + const data = parseToolResult(result) as any; + expect(data.reservation.title).toBe('New Title'); + expect(data.reservation.type).toBe('flight'); // preserved + }); + }); + + it('updates reservation status to confirmed', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_reservation', + arguments: { tripId: trip.id, reservationId: reservation.id, status: 'confirmed' }, + }); + const data = parseToolResult(result) as any; + expect(data.reservation.status).toBe('confirmed'); + }); + }); + + it('broadcasts reservation:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_reservation', arguments: { tripId: trip.id, reservationId: reservation.id, title: 'Updated' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:updated', expect.any(Object)); + }); + }); + + it('returns error for reservation 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_reservation', arguments: { tripId: trip.id, reservationId: 99999, title: 'X' } }); + expect(result.isError).toBe(true); + }); + }); + + it('validates place_id belongs to trip', async () => { + const { user } = createUser(testDb); + const trip1 = createTrip(testDb, user.id); + const trip2 = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip1.id); + const placeFromTrip2 = createPlace(testDb, trip2.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'update_reservation', + arguments: { tripId: trip1.id, reservationId: reservation.id, place_id: placeFromTrip2.id }, + }); + 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 reservation = createReservation(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_reservation', arguments: { tripId: trip.id, reservationId: reservation.id, title: 'X' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_reservation +// --------------------------------------------------------------------------- + +describe('Tool: delete_reservation', () => { + it('deletes a reservation', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + expect(testDb.prepare('SELECT id FROM reservations WHERE id = ?').get(reservation.id)).toBeUndefined(); + }); + }); + + it('cascades to accommodation when linked', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const hotel = createPlace(testDb, trip.id); + // Create reservation via tool so accommodation is linked + let reservationId: number; + await withHarness(user.id, async (h) => { + const r = await h.client.callTool({ + name: 'create_reservation', + arguments: { tripId: trip.id, title: 'Hotel', type: 'hotel', place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id }, + }); + reservationId = (parseToolResult(r) as any).reservation.id; + }); + + const accId = (testDb.prepare('SELECT accommodation_id FROM reservations WHERE id = ?').get(reservationId!) as any).accommodation_id; + expect(accId).not.toBeNull(); + + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId } }); + }); + + expect(testDb.prepare('SELECT id FROM day_accommodations WHERE id = ?').get(accId)).toBeUndefined(); + }); + + it('broadcasts reservation:deleted event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const reservation = createReservation(testDb, trip.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'reservation:deleted', expect.any(Object)); + }); + }); + + it('returns error for reservation 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_reservation', arguments: { tripId: trip.id, reservationId: 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 reservation = createReservation(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_reservation', arguments: { tripId: trip.id, reservationId: reservation.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// link_hotel_accommodation +// --------------------------------------------------------------------------- + +describe('Tool: link_hotel_accommodation', () => { + it('creates new accommodation link for a hotel reservation', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const hotel = createPlace(testDb, trip.id, { name: 'Ritz' }); + const reservation = createReservation(testDb, trip.id, { type: 'hotel' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_hotel_accommodation', + arguments: { tripId: trip.id, reservationId: reservation.id, place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id, check_in: '14:00', check_out: '12:00' }, + }); + const data = parseToolResult(result) as any; + expect(data.reservation.accommodation_id).not.toBeNull(); + expect(data.accommodation_id).not.toBeNull(); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:created', expect.any(Object)); + }); + }); + + it('updates existing accommodation link', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const day3 = createDay(testDb, trip.id, { day_number: 3 }); + const hotel = createPlace(testDb, trip.id, { name: 'Hotel A' }); + const hotel2 = createPlace(testDb, trip.id, { name: 'Hotel B' }); + const reservation = createReservation(testDb, trip.id, { type: 'hotel' }); + + // First link + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'link_hotel_accommodation', + arguments: { tripId: trip.id, reservationId: reservation.id, place_id: hotel.id, start_day_id: day1.id, end_day_id: day2.id }, + }); + }); + + // Update link + await withHarness(user.id, async (h) => { + await h.client.callTool({ + name: 'link_hotel_accommodation', + arguments: { tripId: trip.id, reservationId: reservation.id, place_id: hotel2.id, start_day_id: day2.id, end_day_id: day3.id }, + }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'accommodation:updated', expect.any(Object)); + }); + }); + + it('returns error for non-hotel reservation', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const place = createPlace(testDb, trip.id); + const reservation = createReservation(testDb, trip.id, { type: 'flight' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_hotel_accommodation', + arguments: { tripId: trip.id, reservationId: reservation.id, place_id: place.id, start_day_id: day1.id, end_day_id: day2.id }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('validates place_id belongs to trip', async () => { + const { user } = createUser(testDb); + const trip1 = createTrip(testDb, user.id); + const trip2 = createTrip(testDb, user.id); + const day1 = createDay(testDb, trip1.id, { day_number: 1 }); + const day2 = createDay(testDb, trip1.id, { day_number: 2 }); + const placeFromTrip2 = createPlace(testDb, trip2.id); + const reservation = createReservation(testDb, trip1.id, { type: 'hotel' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_hotel_accommodation', + arguments: { tripId: trip1.id, reservationId: reservation.id, place_id: placeFromTrip2.id, start_day_id: day1.id, end_day_id: day2.id }, + }); + 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 day1 = createDay(testDb, trip.id, { day_number: 1 }); + const day2 = createDay(testDb, trip.id, { day_number: 2 }); + const place = createPlace(testDb, trip.id); + const reservation = createReservation(testDb, trip.id, { type: 'hotel' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'link_hotel_accommodation', + arguments: { tripId: trip.id, reservationId: reservation.id, place_id: place.id, start_day_id: day1.id, end_day_id: day2.id }, + }); + expect(result.isError).toBe(true); + }); + }); +}); diff --git a/server/tests/unit/mcp/tools-trips.test.ts b/server/tests/unit/mcp/tools-trips.test.ts new file mode 100644 index 0000000..d97baf3 --- /dev/null +++ b/server/tests/unit/mcp/tools-trips.test.ts @@ -0,0 +1,340 @@ +/** + * Unit tests for MCP trip tools: create_trip, update_trip, delete_trip, list_trips, get_trip_summary. + */ +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, addTripMember, createBudgetItem, createPackingItem, createReservation, createDayNote, createCollabNote, createDayAssignment, 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_trip +// --------------------------------------------------------------------------- + +describe('Tool: create_trip', () => { + it('creates a trip with title only and generates 7 default days', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Summer Escape' } }); + const data = parseToolResult(result) as any; + expect(data.trip).toBeTruthy(); + expect(data.trip.title).toBe('Summer Escape'); + const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number }; + expect(days.c).toBe(7); + }); + }); + + it('creates a trip with dates and auto-generates correct number of days', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_trip', + arguments: { title: 'Paris Trip', start_date: '2026-07-01', end_date: '2026-07-05' }, + }); + const data = parseToolResult(result) as any; + const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number }; + expect(days.c).toBe(5); + }); + }); + + it('caps days at 90 for very long trips', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_trip', + arguments: { title: 'Long Trip', start_date: '2026-01-01', end_date: '2027-12-31' }, + }); + const data = parseToolResult(result) as any; + const days = testDb.prepare('SELECT COUNT(*) as c FROM days WHERE trip_id = ?').get(data.trip.id) as { c: number }; + expect(days.c).toBe(90); + }); + }); + + it('returns error for invalid start_date format', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'create_trip', arguments: { title: 'Trip', start_date: 'not-a-date' } }); + expect(result.isError).toBe(true); + }); + }); + + it('returns error when end_date is before start_date', async () => { + const { user } = createUser(testDb); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'create_trip', + arguments: { title: 'Trip', start_date: '2026-07-05', end_date: '2026-07-01' }, + }); + expect(result.isError).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: 'create_trip', arguments: { title: 'Demo Trip' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// update_trip +// --------------------------------------------------------------------------- + +describe('Tool: update_trip', () => { + it('updates trip title', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Old Title' }); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, title: 'New Title' } }); + const data = parseToolResult(result) as any; + expect(data.trip.title).toBe('New Title'); + }); + }); + + it('partial update preserves unspecified fields', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'My Trip', description: 'A great trip' }); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, title: 'Renamed' } }); + const updated = testDb.prepare('SELECT * FROM trips WHERE id = ?').get(trip.id) as any; + expect(updated.title).toBe('Renamed'); + expect(updated.description).toBe('A great trip'); + }); + }); + + it('broadcasts trip:updated event', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + await h.client.callTool({ name: 'update_trip', arguments: { tripId: trip.id, title: 'Updated' } }); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'trip: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_trip', arguments: { tripId: trip.id, title: 'Hack' } }); + 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: 'update_trip', arguments: { tripId: trip.id, title: 'New' } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// delete_trip +// --------------------------------------------------------------------------- + +describe('Tool: delete_trip', () => { + it('owner can delete 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: 'delete_trip', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.success).toBe(true); + const gone = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id); + expect(gone).toBeUndefined(); + }); + }); + + it('non-owner member cannot delete trip', async () => { + const { user } = createUser(testDb); + const { user: owner } = createUser(testDb); + const trip = createTrip(testDb, owner.id); + addTripMember(testDb, trip.id, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_trip', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + const stillExists = testDb.prepare('SELECT id FROM trips WHERE id = ?').get(trip.id); + expect(stillExists).toBeTruthy(); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'delete_trip', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +}); + +// --------------------------------------------------------------------------- +// list_trips +// --------------------------------------------------------------------------- + +describe('Tool: list_trips', () => { + it('returns owned and member trips', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + createTrip(testDb, user.id, { title: 'My Trip' }); + const shared = createTrip(testDb, other.id, { title: 'Shared' }); + addTripMember(testDb, shared.id, user.id); + createTrip(testDb, other.id, { title: 'Inaccessible' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_trips', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.trips).toHaveLength(2); + const titles = data.trips.map((t: any) => t.title); + expect(titles).toContain('My Trip'); + expect(titles).toContain('Shared'); + }); + }); + + it('excludes archived trips by default', async () => { + const { user } = createUser(testDb); + createTrip(testDb, user.id, { title: 'Active' }); + const archived = createTrip(testDb, user.id, { title: 'Archived' }); + testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_trips', arguments: {} }); + const data = parseToolResult(result) as any; + expect(data.trips).toHaveLength(1); + expect(data.trips[0].title).toBe('Active'); + }); + }); + + it('includes archived trips when include_archived is true', async () => { + const { user } = createUser(testDb); + createTrip(testDb, user.id, { title: 'Active' }); + const archived = createTrip(testDb, user.id, { title: 'Archived' }); + testDb.prepare('UPDATE trips SET is_archived = 1 WHERE id = ?').run(archived.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_trips', arguments: { include_archived: true } }); + const data = parseToolResult(result) as any; + expect(data.trips).toHaveLength(2); + }); + }); +}); + +// --------------------------------------------------------------------------- +// get_trip_summary +// --------------------------------------------------------------------------- + +describe('Tool: get_trip_summary', () => { + it('returns full denormalized trip snapshot', async () => { + const { user } = createUser(testDb); + const { user: member } = createUser(testDb); + const trip = createTrip(testDb, user.id, { title: 'Full Trip' }); + addTripMember(testDb, trip.id, member.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id, { name: 'Colosseum' }); + const assignment = createDayAssignment(testDb, day.id, place.id); + createDayNote(testDb, day.id, trip.id, { text: 'Check in' }); + createBudgetItem(testDb, trip.id, { name: 'Hotel', total_price: 300 }); + createPackingItem(testDb, trip.id, { name: 'Passport' }); + createReservation(testDb, trip.id, { title: 'Flight', type: 'flight' }); + createCollabNote(testDb, trip.id, user.id, { title: 'Plan' }); + + 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(data.trip.title).toBe('Full Trip'); + expect(data.members.owner.id).toBe(user.id); + expect(data.members.collaborators).toHaveLength(1); + expect(data.days).toHaveLength(1); + expect(data.days[0].assignments).toHaveLength(1); + expect(data.days[0].notes).toHaveLength(1); + expect(data.budget.item_count).toBe(1); + expect(data.budget.total).toBe(300); + expect(data.packing.total).toBe(1); + expect(data.reservations).toHaveLength(1); + expect(data.collab_notes).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: 'get_trip_summary', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); + + it('is not blocked for demo user (read-only tool)', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const trip = createTrip(testDb, user.id, { title: 'Demo Trip' }); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'get_trip_summary', arguments: { tripId: trip.id } }); + expect(result.isError).toBeFalsy(); + const data = parseToolResult(result) as any; + expect(data.trip.title).toBe('Demo Trip'); + }); + }); +}); diff --git a/server/vitest.config.ts b/server/vitest.config.ts index 60cfdf2..f70827c 100644 --- a/server/vitest.config.ts +++ b/server/vitest.config.ts @@ -28,6 +28,14 @@ export default defineConfig({ './node_modules/@modelcontextprotocol/sdk/dist/cjs/server/streamableHttp.js', import.meta.url ).pathname, + '@modelcontextprotocol/sdk/inMemory': new URL( + './node_modules/@modelcontextprotocol/sdk/dist/cjs/inMemory.js', + import.meta.url + ).pathname, + '@modelcontextprotocol/sdk/client/index': new URL( + './node_modules/@modelcontextprotocol/sdk/dist/cjs/client/index.js', + import.meta.url + ).pathname, }, }, }); \ No newline at end of file