Merge pull request #535 from mauriceboe/pr/474-mcp-improvements

Pr/474 mcp improvements
This commit is contained in:
Julien G.
2026-04-09 13:52:25 +02:00
committed by GitHub
6 changed files with 408 additions and 64 deletions
+32 -4
View File
@@ -52,17 +52,22 @@ function countSessionsForUser(userId: number): number {
const sessionSweepInterval = setInterval(() => { const sessionSweepInterval = setInterval(() => {
const cutoff = Date.now() - SESSION_TTL_MS; const cutoff = Date.now() - SESSION_TTL_MS;
let cleaned = 0;
for (const [sid, session] of sessions) { for (const [sid, session] of sessions) {
if (session.lastActivity < cutoff) { if (session.lastActivity < cutoff) {
try { session.server.close(); } catch { /* ignore */ } try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ } try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid); sessions.delete(sid);
cleaned++;
} }
} }
const rateCutoff = Date.now() - RATE_LIMIT_WINDOW_MS; const rateCutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
for (const [uid, entry] of rateLimitMap) { for (const [uid, entry] of rateLimitMap) {
if (entry.windowStart < rateCutoff) rateLimitMap.delete(uid); if (entry.windowStart < rateCutoff) rateLimitMap.delete(uid);
} }
if (cleaned > 0 || sessions.size > 0) {
console.log(`[MCP] Session sweep: cleaned ${cleaned}, active ${sessions.size}`);
}
}, 10 * 60 * 1000); // sweep every 10 minutes }, 10 * 60 * 1000); // sweep every 10 minutes
// Prevent the interval from keeping the process alive if nothing else is running // Prevent the interval from keeping the process alive if nothing else is running
@@ -112,7 +117,14 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
return; return;
} }
session.lastActivity = Date.now(); session.lastActivity = Date.now();
await session.transport.handleRequest(req, res, req.body); try {
await session.transport.handleRequest(req, res, req.body);
} catch (err) {
console.error('[MCP] transport.handleRequest error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal MCP error' });
}
}
return; return;
} }
@@ -128,7 +140,15 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
} }
// Create a new per-user MCP server and session // Create a new per-user MCP server and session
const server = new McpServer({ name: 'trek', version: '1.0.0' }); const server = new McpServer({
name: 'TREK MCP',
version: '1.0.0',
capabilities: {
resources: { listChanged: true },
tools: { listChanged: true },
prompts: { listChanged: true },
},
});
registerResources(server, user.id); registerResources(server, user.id);
registerTools(server, user.id); registerTools(server, user.id);
@@ -136,14 +156,22 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
sessionIdGenerator: () => randomUUID(), sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => { onsessioninitialized: (sid) => {
sessions.set(sid, { server, transport, userId: user.id, lastActivity: Date.now() }); sessions.set(sid, { server, transport, userId: user.id, lastActivity: Date.now() });
console.log(`[MCP] Session ${sid} created for user ${user.id}. Active sessions: ${sessions.size}`);
}, },
onsessionclosed: (sid) => { onsessionclosed: (sid) => {
sessions.delete(sid); sessions.delete(sid);
}, },
}); });
await server.connect(transport); try {
await transport.handleRequest(req, res, req.body); await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (err) {
console.error('[MCP] transport.handleRequest error:', err);
if (!res.headersSent) {
res.status(500).json({ error: 'Internal MCP error', detail: String(err) });
}
}
} }
/** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */ /** Terminate all active MCP sessions for a specific user (e.g. on token revocation). */
+16 -15
View File
@@ -41,7 +41,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'trips', 'trips',
'trek://trips', 'trek://trips',
{ description: 'All trips the user owns or is a member of' }, { description: 'All trips the user owns or is a member of', mimeType: 'application/json' },
async (uri) => { async (uri) => {
const trips = listTrips(userId, 0); const trips = listTrips(userId, 0);
return jsonContent(uri.href, trips); return jsonContent(uri.href, trips);
@@ -52,7 +52,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'trip', 'trip',
new ResourceTemplate('trek://trips/{tripId}', { list: undefined }), new ResourceTemplate('trek://trips/{tripId}', { list: undefined }),
{ description: 'A single trip with metadata and member count' }, { description: 'A single trip with metadata and member count', mimeType: 'application/json' },
async (uri, { tripId }) => { async (uri, { tripId }) => {
const id = parseId(tripId); const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -65,7 +65,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'trip-days', 'trip-days',
new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }), new ResourceTemplate('trek://trips/{tripId}/days', { list: undefined }),
{ description: 'Days of a trip with their assigned places' }, { description: 'Days of a trip with their assigned places', mimeType: 'application/json' },
async (uri, { tripId }) => { async (uri, { tripId }) => {
const id = parseId(tripId); const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -79,11 +79,12 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'trip-places', 'trip-places',
new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }), new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }),
{ description: 'All places/POIs saved in a trip' }, { description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)', mimeType: 'application/json' },
async (uri, { tripId }) => { async (uri, { tripId }) => {
const id = parseId(tripId); const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
const places = listPlaces(String(id), {}); const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null;
const places = listPlaces(String(id), { assignment: assignment ?? undefined });
return jsonContent(uri.href, places); return jsonContent(uri.href, places);
} }
); );
@@ -92,7 +93,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'trip-budget', 'trip-budget',
new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }), new ResourceTemplate('trek://trips/{tripId}/budget', { list: undefined }),
{ description: 'Budget and expense items for a trip' }, { description: 'Budget and expense items for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => { async (uri, { tripId }) => {
const id = parseId(tripId); const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -105,7 +106,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'trip-packing', 'trip-packing',
new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }), new ResourceTemplate('trek://trips/{tripId}/packing', { list: undefined }),
{ description: 'Packing checklist for a trip' }, { description: 'Packing checklist for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => { async (uri, { tripId }) => {
const id = parseId(tripId); const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -118,7 +119,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'trip-reservations', 'trip-reservations',
new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }), new ResourceTemplate('trek://trips/{tripId}/reservations', { list: undefined }),
{ description: 'Reservations (flights, hotels, restaurants) for a trip' }, { description: 'Reservations (flights, hotels, restaurants) for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => { async (uri, { tripId }) => {
const id = parseId(tripId); const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -131,7 +132,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'day-notes', 'day-notes',
new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }), new ResourceTemplate('trek://trips/{tripId}/days/{dayId}/notes', { list: undefined }),
{ description: 'Notes for a specific day in a trip' }, { description: 'Notes for a specific day in a trip', mimeType: 'application/json' },
async (uri, { tripId, dayId }) => { async (uri, { tripId, dayId }) => {
const tId = parseId(tripId); const tId = parseId(tripId);
const dId = parseId(dayId); const dId = parseId(dayId);
@@ -145,7 +146,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'trip-accommodations', 'trip-accommodations',
new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }), new ResourceTemplate('trek://trips/{tripId}/accommodations', { list: undefined }),
{ description: 'Accommodations (hotels, rentals) for a trip with check-in/out details' }, { description: 'Accommodations (hotels, rentals) for a trip with check-in/out details', mimeType: 'application/json' },
async (uri, { tripId }) => { async (uri, { tripId }) => {
const id = parseId(tripId); const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -158,7 +159,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'trip-members', 'trip-members',
new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }), new ResourceTemplate('trek://trips/{tripId}/members', { list: undefined }),
{ description: 'Owner and collaborators of a trip' }, { description: 'Owner and collaborators of a trip', mimeType: 'application/json' },
async (uri, { tripId }) => { async (uri, { tripId }) => {
const id = parseId(tripId); const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -173,7 +174,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'trip-collab-notes', 'trip-collab-notes',
new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }), new ResourceTemplate('trek://trips/{tripId}/collab-notes', { list: undefined }),
{ description: 'Shared collaborative notes for a trip' }, { description: 'Shared collaborative notes for a trip', mimeType: 'application/json' },
async (uri, { tripId }) => { async (uri, { tripId }) => {
const id = parseId(tripId); const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -186,7 +187,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'categories', 'categories',
'trek://categories', 'trek://categories',
{ description: 'All available place categories (id, name, color, icon) for use when creating places' }, { description: 'All available place categories (id, name, color, icon) for use when creating places', mimeType: 'application/json' },
async (uri) => { async (uri) => {
const categories = listCategories(); const categories = listCategories();
return jsonContent(uri.href, categories); return jsonContent(uri.href, categories);
@@ -197,7 +198,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'bucket-list', 'bucket-list',
'trek://bucket-list', 'trek://bucket-list',
{ description: 'Your personal travel bucket list' }, { description: 'Your personal travel bucket list', mimeType: 'application/json' },
async (uri) => { async (uri) => {
const items = listBucketList(userId); const items = listBucketList(userId);
return jsonContent(uri.href, items); return jsonContent(uri.href, items);
@@ -208,7 +209,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource( server.registerResource(
'visited-countries', 'visited-countries',
'trek://visited-countries', 'trek://visited-countries',
{ description: 'Countries you have marked as visited in Atlas' }, { description: 'Countries you have marked as visited in Atlas', mimeType: 'application/json' },
async (uri) => { async (uri) => {
const countries = listVisitedCountries(userId); const countries = listVisitedCountries(userId);
return jsonContent(uri.href, countries); return jsonContent(uri.href, countries);
+248 -37
View File
@@ -14,7 +14,7 @@ import {
deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime, deleteAssignment, reorderAssignments, getAssignmentForTrip, updateTime,
} from '../services/assignmentService'; } from '../services/assignmentService';
import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService'; import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService';
import { createItem as createPackingItem, updateItem as updatePackingItem, deleteItem as deletePackingItem } from '../services/packingService'; import { createItem as createPackingItem, updateItem as updatePackingItem, deleteItem as deletePackingItem, listItems as listPackingItems } from '../services/packingService';
import { createReservation, getReservation, updateReservation, deleteReservation } from '../services/reservationService'; import { createReservation, getReservation, updateReservation, deleteReservation } from '../services/reservationService';
import { getDay, updateDay, validateAccommodationRefs } from '../services/dayService'; 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 createDayNote, getNote as getDayNote, updateNote as updateDayNote, deleteNote as deleteDayNote, dayExists as dayNoteExists } from '../services/dayNoteService';
@@ -24,8 +24,44 @@ import {
} from '../services/atlasService'; } from '../services/atlasService';
import { searchPlaces } from '../services/mapsService'; import { searchPlaces } from '../services/mapsService';
function safeBroadcast(tripId: number, event: string, payload: Record<string, unknown>): void {
try {
broadcast(tripId, event, payload);
} catch (err) {
console.error(`[MCP] broadcast failed for ${event}:`, err?.message ?? err);
}
}
const MAX_MCP_TRIP_DAYS = 90; const MAX_MCP_TRIP_DAYS = 90;
const TOOL_ANNOTATIONS_READONLY = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
const TOOL_ANNOTATIONS_WRITE = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
const TOOL_ANNOTATIONS_DELETE = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
} as const;
const TOOL_ANNOTATIONS_NON_IDEMPOTENT = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
} as const;
function demoDenied() { function demoDenied() {
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true }; return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
} }
@@ -52,6 +88,7 @@ export function registerTools(server: McpServer, userId: number): void {
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'), end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe('End date (YYYY-MM-DD)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'), currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ title, description, start_date, end_date, currency }) => { async ({ title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -85,6 +122,7 @@ export function registerTools(server: McpServer, userId: number): void {
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(), end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
currency: z.string().length(3).optional(), currency: z.string().length(3).optional(),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, title, description, start_date, end_date, currency }) => { async ({ tripId, title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -100,7 +138,7 @@ export function registerTools(server: McpServer, userId: number): void {
return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true }; return { content: [{ type: 'text' as const, text: 'end_date is not a valid calendar date.' }], isError: true };
} }
const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user'); const { updatedTrip } = updateTrip(tripId, userId, { title, description, start_date, end_date, currency }, 'user');
broadcast(tripId, 'trip:updated', { trip: updatedTrip }); safeBroadcast(tripId, 'trip:updated', { trip: updatedTrip });
return ok({ trip: updatedTrip }); return ok({ trip: updatedTrip });
} }
); );
@@ -112,6 +150,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
}, },
annotations: TOOL_ANNOTATIONS_DELETE,
}, },
async ({ tripId }) => { async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -128,6 +167,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: { inputSchema: {
include_archived: z.boolean().optional().describe('Include archived trips (default false)'), include_archived: z.boolean().optional().describe('Include archived trips (default false)'),
}, },
annotations: TOOL_ANNOTATIONS_READONLY,
}, },
async ({ include_archived }) => { async ({ include_archived }) => {
const trips = listTrips(userId, include_archived ? null : 0); const trips = listTrips(userId, include_archived ? null : 0);
@@ -155,12 +195,13 @@ export function registerTools(server: McpServer, userId: number): void {
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => { async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }); 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 }); safeBroadcast(tripId, 'place:created', { place });
return ok({ place }); return ok({ place });
} }
); );
@@ -177,17 +218,27 @@ export function registerTools(server: McpServer, userId: number): void {
lat: z.number().optional(), lat: z.number().optional(),
lng: z.number().optional(), lng: z.number().optional(),
address: z.string().max(500).optional(), address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
price: z.number().optional(),
currency: z.string().length(3).optional(),
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
duration_minutes: z.number().int().positive().optional(),
notes: z.string().max(2000).optional(), notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(), website: z.string().max(500).optional(),
phone: z.string().max(50).optional(), phone: z.string().max(50).optional(),
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, placeId, name, description, lat, lng, address, notes, website, phone }) => { async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, notes, website, phone }); const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id });
if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
broadcast(tripId, 'place:updated', { place }); safeBroadcast(tripId, 'place:updated', { place });
return ok({ place }); return ok({ place });
} }
); );
@@ -200,17 +251,38 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
placeId: z.number().int().positive(), placeId: z.number().int().positive(),
}, },
annotations: TOOL_ANNOTATIONS_DELETE,
}, },
async ({ tripId, placeId }) => { async ({ tripId, placeId }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePlace(String(tripId), String(placeId)); const deleted = deletePlace(String(tripId), String(placeId));
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
broadcast(tripId, 'place:deleted', { placeId }); safeBroadcast(tripId, 'place:deleted', { placeId });
return ok({ success: true }); return ok({ success: true });
} }
); );
server.registerTool(
'list_places',
{
description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
inputSchema: {
tripId: z.number().int().positive(),
search: z.string().optional(),
category: z.string().optional(),
tag: z.string().optional(),
assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all').describe('Filter by assignment status: "all" (default), "unassigned" (not on any day), or "assigned" (scheduled on a day)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, search, category, tag, assignment }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const places = listPlaces(String(tripId), { search, category, tag, assignment });
return ok({ places });
}
);
// --- CATEGORIES --- // --- CATEGORIES ---
server.registerTool( server.registerTool(
@@ -218,6 +290,7 @@ export function registerTools(server: McpServer, userId: number): void {
{ {
description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.', description: 'List all available place categories with their id, name, icon and color. Use category_id when creating or updating places.',
inputSchema: {}, inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
}, },
async () => { async () => {
const categories = listCategories(); const categories = listCategories();
@@ -234,6 +307,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: { inputSchema: {
query: z.string().min(1).max(500).describe('Place name or address to search for'), query: z.string().min(1).max(500).describe('Place name or address to search for'),
}, },
annotations: TOOL_ANNOTATIONS_READONLY,
}, },
async ({ query }) => { async ({ query }) => {
try { try {
@@ -257,6 +331,7 @@ export function registerTools(server: McpServer, userId: number): void {
placeId: z.number().int().positive(), placeId: z.number().int().positive(),
notes: z.string().max(500).optional(), notes: z.string().max(500).optional(),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, dayId, placeId, notes }) => { async ({ tripId, dayId, placeId, notes }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -264,7 +339,7 @@ export function registerTools(server: McpServer, userId: number): void {
if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; 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 }; if (!placeExists(placeId, tripId)) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
const assignment = createAssignment(dayId, placeId, notes || null); const assignment = createAssignment(dayId, placeId, notes || null);
broadcast(tripId, 'assignment:created', { assignment }); safeBroadcast(tripId, 'assignment:created', { assignment });
return ok({ assignment }); return ok({ assignment });
} }
); );
@@ -278,6 +353,7 @@ export function registerTools(server: McpServer, userId: number): void {
dayId: z.number().int().positive(), dayId: z.number().int().positive(),
assignmentId: z.number().int().positive(), assignmentId: z.number().int().positive(),
}, },
annotations: TOOL_ANNOTATIONS_DELETE,
}, },
async ({ tripId, dayId, assignmentId }) => { async ({ tripId, dayId, assignmentId }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -285,7 +361,7 @@ export function registerTools(server: McpServer, userId: number): void {
if (!assignmentExistsInDay(assignmentId, dayId, tripId)) if (!assignmentExistsInDay(assignmentId, dayId, tripId))
return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true }; return { content: [{ type: 'text' as const, text: 'Assignment not found.' }], isError: true };
deleteAssignment(assignmentId); deleteAssignment(assignmentId);
broadcast(tripId, 'assignment:deleted', { assignmentId, dayId }); safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
return ok({ success: true }); return ok({ success: true });
} }
); );
@@ -303,12 +379,13 @@ export function registerTools(server: McpServer, userId: number): void {
total_price: z.number().nonnegative(), total_price: z.number().nonnegative(),
note: z.string().max(500).optional(), note: z.string().max(500).optional(),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, name, category, total_price, note }) => { async ({ tripId, name, category, total_price, note }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createBudgetItem(tripId, { category, name, total_price, note }); const item = createBudgetItem(tripId, { category, name, total_price, note });
broadcast(tripId, 'budget:created', { item }); safeBroadcast(tripId, 'budget:created', { item });
return ok({ item }); return ok({ item });
} }
); );
@@ -321,13 +398,14 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
itemId: z.number().int().positive(), itemId: z.number().int().positive(),
}, },
annotations: TOOL_ANNOTATIONS_DELETE,
}, },
async ({ tripId, itemId }) => { async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deleteBudgetItem(itemId, tripId); const deleted = deleteBudgetItem(itemId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true }; if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
broadcast(tripId, 'budget:deleted', { itemId }); safeBroadcast(tripId, 'budget:deleted', { itemId });
return ok({ success: true }); return ok({ success: true });
} }
); );
@@ -343,12 +421,13 @@ export function registerTools(server: McpServer, userId: number): void {
name: z.string().min(1).max(200), name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'), category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, name, category }) => { async ({ tripId, name, category }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createPackingItem(tripId, { name, category: category || 'General' }); const item = createPackingItem(tripId, { name, category: category || 'General' });
broadcast(tripId, 'packing:created', { item }); safeBroadcast(tripId, 'packing:created', { item });
return ok({ item }); return ok({ item });
} }
); );
@@ -362,13 +441,14 @@ export function registerTools(server: McpServer, userId: number): void {
itemId: z.number().int().positive(), itemId: z.number().int().positive(),
checked: z.boolean(), checked: z.boolean(),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, itemId, checked }) => { async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']); 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 }; if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
broadcast(tripId, 'packing:updated', { item }); safeBroadcast(tripId, 'packing:updated', { item });
return ok({ item }); return ok({ item });
} }
); );
@@ -381,13 +461,14 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
itemId: z.number().int().positive(), itemId: z.number().int().positive(),
}, },
annotations: TOOL_ANNOTATIONS_DELETE,
}, },
async ({ tripId, itemId }) => { async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePackingItem(tripId, itemId); const deleted = deletePackingItem(tripId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
broadcast(tripId, 'packing:deleted', { itemId }); safeBroadcast(tripId, 'packing:deleted', { itemId });
return ok({ success: true }); return ok({ success: true });
} }
); );
@@ -401,7 +482,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
title: z.string().min(1).max(200), title: z.string().min(1).max(200),
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']), type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
location: z.string().max(500).optional(), location: z.string().max(500).optional(),
confirmation_number: z.string().max(100).optional(), confirmation_number: z.string().max(100).optional(),
@@ -414,6 +495,7 @@ export function registerTools(server: McpServer, userId: number): void {
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'), check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'),
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'), assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => { async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -442,9 +524,9 @@ export function registerTools(server: McpServer, userId: number): void {
}); });
if (accommodationCreated) { if (accommodationCreated) {
broadcast(tripId, 'accommodation:created', {}); safeBroadcast(tripId, 'accommodation:created', {});
} }
broadcast(tripId, 'reservation:created', { reservation }); safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation }); return ok({ reservation });
} }
); );
@@ -457,6 +539,7 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
reservationId: z.number().int().positive(), reservationId: z.number().int().positive(),
}, },
annotations: TOOL_ANNOTATIONS_DELETE,
}, },
async ({ tripId, reservationId }) => { async ({ tripId, reservationId }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -464,9 +547,9 @@ export function registerTools(server: McpServer, userId: number): void {
const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId); const { deleted, accommodationDeleted } = deleteReservation(reservationId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true }; if (!deleted) return { content: [{ type: 'text' as const, text: 'Reservation not found.' }], isError: true };
if (accommodationDeleted) { if (accommodationDeleted) {
broadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id }); safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
} }
broadcast(tripId, 'reservation:deleted', { reservationId }); safeBroadcast(tripId, 'reservation:deleted', { reservationId });
return ok({ success: true }); return ok({ success: true });
} }
); );
@@ -484,6 +567,7 @@ export function registerTools(server: McpServer, userId: number): void {
check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00")'), check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00")'),
check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'), check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => { async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -507,8 +591,8 @@ export function registerTools(server: McpServer, userId: number): void {
create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined }, create_accommodation: { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined },
}, current); }, current);
broadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {}); safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
broadcast(tripId, 'reservation:updated', { reservation }); safeBroadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation, accommodation_id: (reservation as any).accommodation_id }); return ok({ reservation, accommodation_id: (reservation as any).accommodation_id });
} }
); );
@@ -525,6 +609,7 @@ export function registerTools(server: McpServer, userId: number): void {
place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'), place_time: z.string().max(50).nullable().optional().describe('Start time (e.g. "09:00"), or null to clear'),
end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'), end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, assignmentId, place_time, end_time }) => { async ({ tripId, assignmentId, place_time, end_time }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -536,7 +621,7 @@ export function registerTools(server: McpServer, userId: number): void {
place_time !== undefined ? place_time : (existing as any).assignment_time, place_time !== undefined ? place_time : (existing as any).assignment_time,
end_time !== undefined ? end_time : (existing as any).assignment_end_time end_time !== undefined ? end_time : (existing as any).assignment_end_time
); );
broadcast(tripId, 'assignment:updated', { assignment }); safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment }); return ok({ assignment });
} }
); );
@@ -550,6 +635,7 @@ export function registerTools(server: McpServer, userId: number): void {
dayId: z.number().int().positive(), dayId: z.number().int().positive(),
title: z.string().max(200).nullable().describe('Day title, or null to clear it'), title: z.string().max(200).nullable().describe('Day title, or null to clear it'),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, dayId, title }) => { async ({ tripId, dayId, title }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -557,7 +643,7 @@ export function registerTools(server: McpServer, userId: number): void {
const current = getDay(dayId, tripId); const current = getDay(dayId, tripId);
if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; if (!current) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const updated = updateDay(dayId, current, title !== undefined ? { title } : {}); const updated = updateDay(dayId, current, title !== undefined ? { title } : {});
broadcast(tripId, 'day:updated', { day: updated }); safeBroadcast(tripId, 'day:updated', { day: updated });
return ok({ day: updated }); return ok({ day: updated });
} }
); );
@@ -572,15 +658,16 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
reservationId: z.number().int().positive(), reservationId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(), title: z.string().min(1).max(200).optional(),
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional(), type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'), reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
location: z.string().max(500).optional(), location: z.string().max(500).optional(),
confirmation_number: z.string().max(100).optional(), confirmation_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional(), notes: z.string().max(1000).optional(),
status: z.enum(['pending', 'confirmed', 'cancelled']).optional(), status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'),
place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'), place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'), assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => { async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -598,7 +685,7 @@ export function registerTools(server: McpServer, userId: number): void {
place_id: place_id !== undefined ? place_id ?? undefined : undefined, place_id: place_id !== undefined ? place_id ?? undefined : undefined,
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined, assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
}, existing); }, existing);
broadcast(tripId, 'reservation:updated', { reservation }); safeBroadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation }); return ok({ reservation });
} }
); );
@@ -619,13 +706,14 @@ export function registerTools(server: McpServer, userId: number): void {
days: z.number().int().positive().nullable().optional(), days: z.number().int().positive().nullable().optional(),
note: z.string().max(500).nullable().optional(), note: z.string().max(500).nullable().optional(),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => { async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note }); 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 }; if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
broadcast(tripId, 'budget:updated', { item }); safeBroadcast(tripId, 'budget:updated', { item });
return ok({ item }); return ok({ item });
} }
); );
@@ -642,6 +730,7 @@ export function registerTools(server: McpServer, userId: number): void {
name: z.string().min(1).max(200).optional(), name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(), category: z.string().max(100).optional(),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, itemId, name, category }) => { async ({ tripId, itemId, name, category }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -649,7 +738,7 @@ export function registerTools(server: McpServer, userId: number): void {
const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined); const bodyKeys = ['name', 'category'].filter(k => k === 'name' ? name !== undefined : category !== undefined);
const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys); const item = updatePackingItem(tripId, itemId, { name, category }, bodyKeys);
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true }; if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
broadcast(tripId, 'packing:updated', { item }); safeBroadcast(tripId, 'packing:updated', { item });
return ok({ item }); return ok({ item });
} }
); );
@@ -665,13 +754,14 @@ export function registerTools(server: McpServer, userId: number): void {
dayId: z.number().int().positive(), dayId: z.number().int().positive(),
assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'), assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, dayId, assignmentIds }) => { async ({ tripId, dayId, assignmentIds }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
reorderAssignments(dayId, assignmentIds); reorderAssignments(dayId, assignmentIds);
broadcast(tripId, 'assignment:reordered', { dayId, assignmentIds }); safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
return ok({ success: true, dayId, order: assignmentIds }); return ok({ success: true, dayId, order: assignmentIds });
} }
); );
@@ -685,6 +775,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: { inputSchema: {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
}, },
annotations: TOOL_ANNOTATIONS_READONLY,
}, },
async ({ tripId }) => { async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
@@ -707,6 +798,7 @@ export function registerTools(server: McpServer, userId: number): void {
country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'), country_code: z.string().length(2).toUpperCase().optional().describe('ISO 3166-1 alpha-2 country code'),
notes: z.string().max(1000).optional(), notes: z.string().max(1000).optional(),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ name, lat, lng, country_code, notes }) => { async ({ name, lat, lng, country_code, notes }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -722,6 +814,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: { inputSchema: {
itemId: z.number().int().positive(), itemId: z.number().int().positive(),
}, },
annotations: TOOL_ANNOTATIONS_DELETE,
}, },
async ({ itemId }) => { async ({ itemId }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -740,6 +833,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: { inputSchema: {
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'), country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ country_code }) => { async ({ country_code }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -755,6 +849,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: { inputSchema: {
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'), country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'),
}, },
annotations: TOOL_ANNOTATIONS_DELETE,
}, },
async ({ country_code }) => { async ({ country_code }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -775,13 +870,15 @@ export function registerTools(server: McpServer, userId: number): void {
content: z.string().max(10000).optional(), content: z.string().max(10000).optional(),
category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'), category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
pinned: z.boolean().optional().default(false).describe('Pin the note to the top'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, title, content, category, color }) => { async ({ tripId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const note = createCollabNote(tripId, userId, { title, content, category, color }); const note = createCollabNote(tripId, userId, { title, content, category, color, pinned });
broadcast(tripId, 'collab:note:created', { note }); safeBroadcast(tripId, 'collab:note:created', { note });
return ok({ note }); return ok({ note });
} }
); );
@@ -799,13 +896,14 @@ export function registerTools(server: McpServer, userId: number): void {
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'), color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
pinned: z.boolean().optional().describe('Pin the note to the top'), pinned: z.boolean().optional().describe('Pin the note to the top'),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, noteId, title, content, category, color, pinned }) => { async ({ tripId, noteId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned }); const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
broadcast(tripId, 'collab:note:updated', { note }); safeBroadcast(tripId, 'collab:note:updated', { note });
return ok({ note }); return ok({ note });
} }
); );
@@ -818,13 +916,14 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(), tripId: z.number().int().positive(),
noteId: z.number().int().positive(), noteId: z.number().int().positive(),
}, },
annotations: TOOL_ANNOTATIONS_DELETE,
}, },
async ({ tripId, noteId }) => { async ({ tripId, noteId }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deleteCollabNote(tripId, noteId); const deleted = deleteCollabNote(tripId, noteId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
broadcast(tripId, 'collab:note:deleted', { noteId }); safeBroadcast(tripId, 'collab:note:deleted', { noteId });
return ok({ success: true }); return ok({ success: true });
} }
); );
@@ -842,13 +941,14 @@ export function registerTools(server: McpServer, userId: number): void {
time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'), time: z.string().max(150).optional().describe('Time label (e.g. "09:00" or "Morning")'),
icon: z.string().optional().describe('Emoji icon for the note'), icon: z.string().optional().describe('Emoji icon for the note'),
}, },
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
}, },
async ({ tripId, dayId, text, time, icon }) => { async ({ tripId, dayId, text, time, icon }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess(); if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const note = createDayNote(dayId, tripId, text, time, icon); const note = createDayNote(dayId, tripId, text, time, icon);
broadcast(tripId, 'dayNote:created', { dayId, note }); safeBroadcast(tripId, 'dayNote:created', { dayId, note });
return ok({ note }); return ok({ note });
} }
); );
@@ -865,6 +965,7 @@ export function registerTools(server: McpServer, userId: number): void {
time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'), time: z.string().max(150).nullable().optional().describe('Time label (e.g. "09:00" or "Morning"), or null to clear'),
icon: z.string().optional().describe('Emoji icon for the note'), icon: z.string().optional().describe('Emoji icon for the note'),
}, },
annotations: TOOL_ANNOTATIONS_WRITE,
}, },
async ({ tripId, dayId, noteId, text, time, icon }) => { async ({ tripId, dayId, noteId, text, time, icon }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -872,7 +973,7 @@ export function registerTools(server: McpServer, userId: number): void {
const existing = getDayNote(noteId, dayId, tripId); const existing = getDayNote(noteId, dayId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon }); const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
broadcast(tripId, 'dayNote:updated', { dayId, note }); safeBroadcast(tripId, 'dayNote:updated', { dayId, note });
return ok({ note }); return ok({ note });
} }
); );
@@ -886,6 +987,7 @@ export function registerTools(server: McpServer, userId: number): void {
dayId: z.number().int().positive(), dayId: z.number().int().positive(),
noteId: z.number().int().positive(), noteId: z.number().int().positive(),
}, },
annotations: TOOL_ANNOTATIONS_DELETE,
}, },
async ({ tripId, dayId, noteId }) => { async ({ tripId, dayId, noteId }) => {
if (isDemoUser(userId)) return demoDenied(); if (isDemoUser(userId)) return demoDenied();
@@ -893,8 +995,117 @@ export function registerTools(server: McpServer, userId: number): void {
const note = getDayNote(noteId, dayId, tripId); const note = getDayNote(noteId, dayId, tripId);
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true }; if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
deleteDayNote(noteId); deleteDayNote(noteId);
broadcast(tripId, 'dayNote:deleted', { noteId, dayId }); safeBroadcast(tripId, 'dayNote:deleted', { noteId, dayId });
return ok({ success: true }); return ok({ success: true });
} }
); );
// --- PROMPTS ---
server.registerPrompt(
'trip-summary',
{
title: 'Trip Summary',
description: 'Load a full summary of a trip for context before planning or modifications',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID to summarize'),
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const summary = getTripSummary(tripId);
if (!summary) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, days, members, budget, packing, reservations, collabNotes } = summary;
const packingStats = packing ? { total: packing.length, packed: packing.filter((p: any) => p.checked).length } : { total: 0, packed: 0 };
const budgetTotal = budget?.reduce((sum: number, b: any) => sum + (b.total_price || 0), 0) || 0;
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
Members: ${members?.length || 0} (${members?.map((m: any) => m.name || m.email).join(', ') || 'none'})
Days: ${days?.length || 0}
Packing: ${packingStats.packed}/${packingStats.total} items packed
Budget: ${budgetTotal} ${trip?.currency || 'EUR'} total
Reservations: ${reservations?.length || 0}
Collab Notes: ${collabNotes?.length || 0}
${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`;
return {
description: `Summary of trip "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text } }],
};
}
);
server.registerPrompt(
'packing-list',
{
title: 'Packing List',
description: 'Get a formatted packing checklist for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const items = listPackingItems(tripId);
if (!items.length) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] };
}
const grouped = items.reduce((acc: Record<string, any[]>, item: any) => {
const cat = item.category || 'General';
if (!acc[cat]) acc[cat] = [];
acc[cat].push(item);
return acc;
}, {});
const lines = Object.entries(grouped).map(([cat, items]) =>
`## ${cat}\n${(items as any[]).map((i: any) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}`
).join('\n\n');
const { trip } = getTripSummary(tripId) || {};
return {
description: `Packing list for "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }],
};
}
);
server.registerPrompt(
'budget-overview',
{
title: 'Budget Overview',
description: 'Get a formatted budget summary for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const summary = getTripSummary(tripId);
if (!summary) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, budget } = summary;
const currency = trip?.currency || 'EUR';
const byCategory = (budget || []).reduce((acc: Record<string, number>, item: any) => {
const cat = item.category || 'Uncategorized';
acc[cat] = (acc[cat] || 0) + (item.total_price || 0);
return acc;
}, {} as Record<string, number>);
const total = Object.values(byCategory).reduce((s, v) => s + v, 0);
const lines = Object.entries(byCategory)
.sort(([, a], [, b]) => b - a)
.map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`)
.join('\n');
const perPerson = (summary.members?.length || 1) > 0 ? (total / (summary.members?.length || 1)).toFixed(2) : total.toFixed(2);
return {
description: `Budget overview for "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }],
};
}
);
} }
+5 -4
View File
@@ -117,11 +117,12 @@ export function listNotes(tripId: string | number) {
return notes.map(formatNote); return notes.map(formatNote);
} }
export function createNote(tripId: string | number, userId: number, data: { title: string; content?: string; category?: string; color?: string; website?: string }) { export function createNote(tripId: string | number, userId: number, data: { title: string; content?: string; category?: string; color?: string; website?: string; pinned?: boolean }) {
const pinned = data.pinned ? 1 : 0;
const result = db.prepare(` const result = db.prepare(`
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website) INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website, pinned)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.website || null); `).run(tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.website || null, pinned);
const note = db.prepare(` const note = db.prepare(`
SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ? SELECT n.*, u.username, u.avatar FROM collab_notes n JOIN users u ON n.user_id = u.id WHERE n.id = ?
+13 -3
View File
@@ -20,7 +20,7 @@ interface UnsplashSearchResponse {
export function listPlaces( export function listPlaces(
tripId: string, tripId: string,
filters: { search?: string; category?: string; tag?: string }, filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' },
) { ) {
let query = ` let query = `
SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon
@@ -46,6 +46,14 @@ export function listPlaces(
params.push(filters.tag); params.push(filters.tag);
} }
if (filters.assignment === 'unassigned') {
query += ` AND p.id NOT IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`;
params.push(tripId);
} else if (filters.assignment === 'assigned') {
query += ` AND p.id IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`;
params.push(tripId);
}
query += ' ORDER BY p.created_at DESC'; query += ' ORDER BY p.created_at DESC';
const places = db.prepare(query).all(...params) as PlaceWithCategory[]; const places = db.prepare(query).all(...params) as PlaceWithCategory[];
@@ -133,7 +141,7 @@ export function updatePlace(
category_id?: number; price?: number; currency?: string; category_id?: number; price?: number; currency?: string;
place_time?: string; end_time?: string; place_time?: string; end_time?: string;
duration_minutes?: number; notes?: string; image_url?: string; duration_minutes?: number; notes?: string; image_url?: string;
google_place_id?: string; website?: string; phone?: string; google_place_id?: string; osm_id?: string; website?: string; phone?: string;
transport_mode?: string; tags?: number[]; transport_mode?: string; tags?: number[];
}, },
) { ) {
@@ -143,7 +151,7 @@ export function updatePlace(
const { const {
name, description, lat, lng, address, category_id, price, currency, name, description, lat, lng, address, category_id, price, currency,
place_time, end_time, place_time, end_time,
duration_minutes, notes, image_url, google_place_id, website, phone, duration_minutes, notes, image_url, google_place_id, osm_id, website, phone,
transport_mode, tags, transport_mode, tags,
} = body; } = body;
@@ -163,6 +171,7 @@ export function updatePlace(
notes = ?, notes = ?,
image_url = ?, image_url = ?,
google_place_id = ?, google_place_id = ?,
osm_id = ?,
website = ?, website = ?,
phone = ?, phone = ?,
transport_mode = COALESCE(?, transport_mode), transport_mode = COALESCE(?, transport_mode),
@@ -183,6 +192,7 @@ export function updatePlace(
notes !== undefined ? notes : existingPlace.notes, notes !== undefined ? notes : existingPlace.notes,
image_url !== undefined ? image_url : existingPlace.image_url, image_url !== undefined ? image_url : existingPlace.image_url,
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id, google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
osm_id !== undefined ? osm_id : existingPlace.osm_id,
website !== undefined ? website : existingPlace.website, website !== undefined ? website : existingPlace.website,
phone !== undefined ? phone : existingPlace.phone, phone !== undefined ? phone : existingPlace.phone,
transport_mode || null, transport_mode || null,
+94 -1
View File
@@ -43,7 +43,7 @@ vi.mock('../../../src/services/mapsService', () => ({ searchPlaces: searchPlaces
import { createTables } from '../../../src/db/schema'; import { createTables } from '../../../src/db/schema';
import { runMigrations } from '../../../src/db/migrations'; import { runMigrations } from '../../../src/db/migrations';
import { resetTestDb } from '../../helpers/test-db'; import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPlace } from '../../helpers/factories'; import { createUser, createTrip, createPlace, createDay } from '../../helpers/factories';
import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness';
beforeAll(() => { beforeAll(() => {
@@ -321,3 +321,96 @@ describe('Tool: search_place', () => {
}); });
}); });
}); });
// ---------------------------------------------------------------------------
// list_places
// ---------------------------------------------------------------------------
describe('Tool: list_places', () => {
it('returns all places by default', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place1 = createPlace(testDb, trip.id, { name: 'Orphan Place' });
const place2 = createPlace(testDb, trip.id, { name: 'Assigned Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place2.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } });
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(2);
});
});
it('returns only unassigned places with assignment=unassigned', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' });
const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } });
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(1);
expect(data.places[0].name).toBe('Orphan Place');
});
});
it('returns only assigned places with assignment=assigned', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' });
const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'assigned' } });
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(1);
expect(data.places[0].name).toBe('Assigned Place');
});
});
it('returns empty array when all places are assigned', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const place = createPlace(testDb, trip.id, { name: 'Only Place' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } });
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(0);
});
});
it('composes with search filter', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const orphan = createPlace(testDb, trip.id, { name: 'Louvre Museum' });
const assigned = createPlace(testDb, trip.id, { name: 'Eiffel Tower' });
const day = createDay(testDb, trip.id);
testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned', search: 'Louvre' } });
const data = parseToolResult(result) as any;
expect(data.places).toHaveLength(1);
expect(data.places[0].name).toBe('Louvre Museum');
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } });
expect(result.isError).toBe(true);
});
});
});