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 cutoff = Date.now() - SESSION_TTL_MS;
let cleaned = 0;
for (const [sid, session] of sessions) {
if (session.lastActivity < cutoff) {
try { session.server.close(); } catch { /* ignore */ }
try { session.transport.close(); } catch { /* ignore */ }
sessions.delete(sid);
cleaned++;
}
}
const rateCutoff = Date.now() - RATE_LIMIT_WINDOW_MS;
for (const [uid, entry] of rateLimitMap) {
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
// 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;
}
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;
}
@@ -128,7 +140,15 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
}
// 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);
registerTools(server, user.id);
@@ -136,14 +156,22 @@ export async function mcpHandler(req: Request, res: Response): Promise<void> {
sessionIdGenerator: () => randomUUID(),
onsessioninitialized: (sid) => {
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) => {
sessions.delete(sid);
},
});
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
try {
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). */
+16 -15
View File
@@ -41,7 +41,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'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) => {
const trips = listTrips(userId, 0);
return jsonContent(uri.href, trips);
@@ -52,7 +52,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'trip',
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 }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -65,7 +65,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'trip-days',
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 }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -79,11 +79,12 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'trip-places',
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 }) => {
const id = parseId(tripId);
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);
}
);
@@ -92,7 +93,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'trip-budget',
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 }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -105,7 +106,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'trip-packing',
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 }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -118,7 +119,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'trip-reservations',
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 }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -131,7 +132,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'day-notes',
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 }) => {
const tId = parseId(tripId);
const dId = parseId(dayId);
@@ -145,7 +146,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'trip-accommodations',
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 }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -158,7 +159,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'trip-members',
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 }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -173,7 +174,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'trip-collab-notes',
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 }) => {
const id = parseId(tripId);
if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href);
@@ -186,7 +187,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'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) => {
const categories = listCategories();
return jsonContent(uri.href, categories);
@@ -197,7 +198,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'bucket-list',
'trek://bucket-list',
{ description: 'Your personal travel bucket list' },
{ description: 'Your personal travel bucket list', mimeType: 'application/json' },
async (uri) => {
const items = listBucketList(userId);
return jsonContent(uri.href, items);
@@ -208,7 +209,7 @@ export function registerResources(server: McpServer, userId: number): void {
server.registerResource(
'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) => {
const countries = listVisitedCountries(userId);
return jsonContent(uri.href, countries);
+248 -37
View File
@@ -14,7 +14,7 @@ import {
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 { createItem as createPackingItem, updateItem as updatePackingItem, deleteItem as deletePackingItem, listItems as listPackingItems } from '../services/packingService';
import { createReservation, getReservation, updateReservation, deleteReservation } from '../services/reservationService';
import { getDay, updateDay, validateAccommodationRefs } from '../services/dayService';
import { createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote, deleteNote as deleteDayNote, dayExists as dayNoteExists } from '../services/dayNoteService';
@@ -24,8 +24,44 @@ import {
} from '../services/atlasService';
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 TOOL_ANNOTATIONS_READONLY = {
readOnlyHint: true,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
const TOOL_ANNOTATIONS_WRITE = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: true,
openWorldHint: false,
} as const;
const TOOL_ANNOTATIONS_DELETE = {
readOnlyHint: false,
destructiveHint: true,
idempotentHint: true,
openWorldHint: false,
} as const;
const TOOL_ANNOTATIONS_NON_IDEMPOTENT = {
readOnlyHint: false,
destructiveHint: false,
idempotentHint: false,
openWorldHint: false,
} as const;
function demoDenied() {
return { content: [{ type: 'text' as const, text: 'Write operations are disabled in demo mode.' }], isError: true };
}
@@ -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)'),
currency: z.string().length(3).optional().describe('Currency code (e.g. EUR, USD)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -85,6 +122,7 @@ export function registerTools(server: McpServer, userId: number): void {
end_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional(),
currency: z.string().length(3).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, title, description, start_date, end_date, currency }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -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 };
}
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 });
}
);
@@ -112,6 +150,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -128,6 +167,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: {
include_archived: z.boolean().optional().describe('Include archived trips (default false)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ include_archived }) => {
const trips = listTrips(userId, include_archived ? null : 0);
@@ -155,12 +195,13 @@ export function registerTools(server: McpServer, userId: number): void {
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone });
broadcast(tripId, 'place:created', { place });
safeBroadcast(tripId, 'place:created', { place });
return ok({ place });
}
);
@@ -177,17 +218,27 @@ export function registerTools(server: McpServer, userId: number): void {
lat: z.number().optional(),
lng: z.number().optional(),
address: z.string().max(500).optional(),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
price: z.number().optional(),
currency: z.string().length(3).optional(),
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
duration_minutes: z.number().int().positive().optional(),
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'),
google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, placeId, name, description, lat, lng, address, 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 (!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 };
broadcast(tripId, 'place:updated', { place });
safeBroadcast(tripId, 'place:updated', { place });
return ok({ place });
}
);
@@ -200,17 +251,38 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(),
placeId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, placeId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePlace(String(tripId), String(placeId));
if (!deleted) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true };
broadcast(tripId, 'place:deleted', { placeId });
safeBroadcast(tripId, 'place:deleted', { placeId });
return ok({ success: true });
}
);
server.registerTool(
'list_places',
{
description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.',
inputSchema: {
tripId: z.number().int().positive(),
search: z.string().optional(),
category: z.string().optional(),
tag: z.string().optional(),
assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all').describe('Filter by assignment status: "all" (default), "unassigned" (not on any day), or "assigned" (scheduled on a day)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId, search, category, tag, assignment }) => {
if (!canAccessTrip(tripId, userId)) return noAccess();
const places = listPlaces(String(tripId), { search, category, tag, assignment });
return ok({ places });
}
);
// --- CATEGORIES ---
server.registerTool(
@@ -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.',
inputSchema: {},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async () => {
const categories = listCategories();
@@ -234,6 +307,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: {
query: z.string().min(1).max(500).describe('Place name or address to search for'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ query }) => {
try {
@@ -257,6 +331,7 @@ export function registerTools(server: McpServer, userId: number): void {
placeId: z.number().int().positive(),
notes: z.string().max(500).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, dayId, placeId, notes }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -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 (!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 });
safeBroadcast(tripId, 'assignment:created', { assignment });
return ok({ assignment });
}
);
@@ -278,6 +353,7 @@ export function registerTools(server: McpServer, userId: number): void {
dayId: z.number().int().positive(),
assignmentId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, dayId, assignmentId }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -285,7 +361,7 @@ export function registerTools(server: McpServer, userId: number): void {
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 });
safeBroadcast(tripId, 'assignment:deleted', { assignmentId, dayId });
return ok({ success: true });
}
);
@@ -303,12 +379,13 @@ export function registerTools(server: McpServer, userId: number): void {
total_price: z.number().nonnegative(),
note: z.string().max(500).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category, total_price, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createBudgetItem(tripId, { category, name, total_price, note });
broadcast(tripId, 'budget:created', { item });
safeBroadcast(tripId, 'budget:created', { item });
return ok({ item });
}
);
@@ -321,13 +398,14 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deleteBudgetItem(itemId, tripId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
broadcast(tripId, 'budget:deleted', { itemId });
safeBroadcast(tripId, 'budget:deleted', { itemId });
return ok({ success: true });
}
);
@@ -343,12 +421,13 @@ export function registerTools(server: McpServer, userId: number): void {
name: z.string().min(1).max(200),
category: z.string().max(100).optional().describe('Packing category (e.g. Clothes, Electronics)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, name, category }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = createPackingItem(tripId, { name, category: category || 'General' });
broadcast(tripId, 'packing:created', { item });
safeBroadcast(tripId, 'packing:created', { item });
return ok({ item });
}
);
@@ -362,13 +441,14 @@ export function registerTools(server: McpServer, userId: number): void {
itemId: z.number().int().positive(),
checked: z.boolean(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, checked }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updatePackingItem(tripId, itemId, { checked: checked ? 1 : 0 }, ['checked']);
if (!item) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
broadcast(tripId, 'packing:updated', { item });
safeBroadcast(tripId, 'packing:updated', { item });
return ok({ item });
}
);
@@ -381,13 +461,14 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(),
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, itemId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deletePackingItem(tripId, itemId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Packing item not found.' }], isError: true };
broadcast(tripId, 'packing:deleted', { itemId });
safeBroadcast(tripId, 'packing:deleted', { itemId });
return ok({ success: true });
}
);
@@ -401,7 +482,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: {
tripId: z.number().int().positive(),
title: z.string().min(1).max(200),
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']),
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
location: z.string().max(500).optional(),
confirmation_number: z.string().max(100).optional(),
@@ -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)'),
assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -442,9 +524,9 @@ export function registerTools(server: McpServer, userId: number): void {
});
if (accommodationCreated) {
broadcast(tripId, 'accommodation:created', {});
safeBroadcast(tripId, 'accommodation:created', {});
}
broadcast(tripId, 'reservation:created', { reservation });
safeBroadcast(tripId, 'reservation:created', { reservation });
return ok({ reservation });
}
);
@@ -457,6 +539,7 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, reservationId }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -464,9 +547,9 @@ export function registerTools(server: McpServer, userId: number): void {
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 });
safeBroadcast(tripId, 'accommodation:deleted', { accommodationId: deleted.accommodation_id });
}
broadcast(tripId, 'reservation:deleted', { reservationId });
safeBroadcast(tripId, 'reservation:deleted', { reservationId });
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_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00")'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, reservationId, place_id, start_day_id, end_day_id, check_in, check_out }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -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 },
}, current);
broadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
broadcast(tripId, 'reservation:updated', { reservation });
safeBroadcast(tripId, isNewAccommodation ? 'accommodation:created' : 'accommodation:updated', {});
safeBroadcast(tripId, 'reservation:updated', { reservation });
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'),
end_time: z.string().max(50).nullable().optional().describe('End time (e.g. "11:00"), or null to clear'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, assignmentId, place_time, end_time }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -536,7 +621,7 @@ export function registerTools(server: McpServer, userId: number): void {
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 });
safeBroadcast(tripId, 'assignment:updated', { assignment });
return ok({ assignment });
}
);
@@ -550,6 +635,7 @@ export function registerTools(server: McpServer, userId: number): void {
dayId: z.number().int().positive(),
title: z.string().max(200).nullable().describe('Day title, or null to clear it'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, dayId, title }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -557,7 +643,7 @@ export function registerTools(server: McpServer, userId: number): void {
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 });
safeBroadcast(tripId, 'day:updated', { day: updated });
return ok({ day: updated });
}
);
@@ -572,15 +658,16 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(),
reservationId: z.number().int().positive(),
title: z.string().min(1).max(200).optional(),
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional(),
type: z.enum(['flight', 'hotel', 'restaurant', 'train', 'car', 'cruise', 'event', 'tour', 'activity', 'other']).optional().describe('Reservation type: "flight", "hotel", "restaurant", "train", "car", "cruise", "event", "tour", "activity", or "other"'),
reservation_time: z.string().optional().describe('ISO 8601 datetime or time string'),
location: z.string().max(500).optional(),
confirmation_number: z.string().max(100).optional(),
notes: z.string().max(1000).optional(),
status: z.enum(['pending', 'confirmed', 'cancelled']).optional(),
status: z.enum(['pending', 'confirmed', 'cancelled']).optional().describe('Reservation status: "pending", "confirmed", or "cancelled"'),
place_id: z.number().int().positive().nullable().optional().describe('Link to a place (use for hotel type), or null to unlink'),
assignment_id: z.number().int().positive().nullable().optional().describe('Link to a day assignment (use for restaurant, train, car, cruise, event, tour, activity, other), or null to unlink'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, reservationId, title, type, reservation_time, location, confirmation_number, notes, status, place_id, assignment_id }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -598,7 +685,7 @@ export function registerTools(server: McpServer, userId: number): void {
place_id: place_id !== undefined ? place_id ?? undefined : undefined,
assignment_id: assignment_id !== undefined ? assignment_id ?? undefined : undefined,
}, existing);
broadcast(tripId, 'reservation:updated', { reservation });
safeBroadcast(tripId, 'reservation:updated', { reservation });
return ok({ reservation });
}
);
@@ -619,13 +706,14 @@ export function registerTools(server: McpServer, userId: number): void {
days: z.number().int().positive().nullable().optional(),
note: z.string().max(500).nullable().optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category, total_price, persons, days, note }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const item = updateBudgetItem(itemId, tripId, { name, category, total_price, persons, days, note });
if (!item) return { content: [{ type: 'text' as const, text: 'Budget item not found.' }], isError: true };
broadcast(tripId, 'budget:updated', { item });
safeBroadcast(tripId, 'budget:updated', { item });
return ok({ item });
}
);
@@ -642,6 +730,7 @@ export function registerTools(server: McpServer, userId: number): void {
name: z.string().min(1).max(200).optional(),
category: z.string().max(100).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, itemId, name, category }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -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 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 });
safeBroadcast(tripId, 'packing:updated', { item });
return ok({ item });
}
);
@@ -665,13 +754,14 @@ export function registerTools(server: McpServer, userId: number): void {
dayId: z.number().int().positive(),
assignmentIds: z.array(z.number().int().positive()).min(1).max(200).describe('Assignment IDs in desired display order'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, dayId, assignmentIds }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
reorderAssignments(dayId, assignmentIds);
broadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
safeBroadcast(tripId, 'assignment:reordered', { dayId, assignmentIds });
return ok({ success: true, dayId, order: assignmentIds });
}
);
@@ -685,6 +775,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: {
tripId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_READONLY,
},
async ({ tripId }) => {
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'),
notes: z.string().max(1000).optional(),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ name, lat, lng, country_code, notes }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -722,6 +814,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: {
itemId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ itemId }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -740,6 +833,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: {
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code (e.g. "FR", "JP")'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ country_code }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -755,6 +849,7 @@ export function registerTools(server: McpServer, userId: number): void {
inputSchema: {
country_code: z.string().length(2).toUpperCase().describe('ISO 3166-1 alpha-2 country code'),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ country_code }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -775,13 +870,15 @@ export function registerTools(server: McpServer, userId: number): void {
content: z.string().max(10000).optional(),
category: z.string().max(100).optional().describe('Note category (e.g. "Ideas", "To-do", "General")'),
color: z.string().regex(/^#[0-9a-fA-F]{6}$/).optional().describe('Hex color for the note card'),
pinned: z.boolean().optional().default(false).describe('Pin the note to the top'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, title, content, category, color }) => {
async ({ tripId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = createCollabNote(tripId, userId, { title, content, category, color });
broadcast(tripId, 'collab:note:created', { note });
const note = createCollabNote(tripId, userId, { title, content, category, color, pinned });
safeBroadcast(tripId, 'collab:note:created', { 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'),
pinned: z.boolean().optional().describe('Pin the note to the top'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, noteId, title, content, category, color, pinned }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const note = updateCollabNote(tripId, noteId, { title, content, category, color, pinned });
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
broadcast(tripId, 'collab:note:updated', { note });
safeBroadcast(tripId, 'collab:note:updated', { note });
return ok({ note });
}
);
@@ -818,13 +916,14 @@ export function registerTools(server: McpServer, userId: number): void {
tripId: z.number().int().positive(),
noteId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, noteId }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
const deleted = deleteCollabNote(tripId, noteId);
if (!deleted) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
broadcast(tripId, 'collab:note:deleted', { noteId });
safeBroadcast(tripId, 'collab:note:deleted', { noteId });
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")'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT,
},
async ({ tripId, dayId, text, time, icon }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!dayNoteExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true };
const note = createDayNote(dayId, tripId, text, time, icon);
broadcast(tripId, 'dayNote:created', { dayId, note });
safeBroadcast(tripId, 'dayNote:created', { dayId, 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'),
icon: z.string().optional().describe('Emoji icon for the note'),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, dayId, noteId, text, time, icon }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -872,7 +973,7 @@ export function registerTools(server: McpServer, userId: number): void {
const existing = getDayNote(noteId, dayId, tripId);
if (!existing) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
const note = updateDayNote(noteId, existing, { text, time: time !== undefined ? time : undefined, icon });
broadcast(tripId, 'dayNote:updated', { dayId, note });
safeBroadcast(tripId, 'dayNote:updated', { dayId, note });
return ok({ note });
}
);
@@ -886,6 +987,7 @@ export function registerTools(server: McpServer, userId: number): void {
dayId: z.number().int().positive(),
noteId: z.number().int().positive(),
},
annotations: TOOL_ANNOTATIONS_DELETE,
},
async ({ tripId, dayId, noteId }) => {
if (isDemoUser(userId)) return demoDenied();
@@ -893,8 +995,117 @@ export function registerTools(server: McpServer, userId: number): void {
const note = getDayNote(noteId, dayId, tripId);
if (!note) return { content: [{ type: 'text' as const, text: 'Note not found.' }], isError: true };
deleteDayNote(noteId);
broadcast(tripId, 'dayNote:deleted', { noteId, dayId });
safeBroadcast(tripId, 'dayNote:deleted', { noteId, dayId });
return ok({ success: true });
}
);
// --- PROMPTS ---
server.registerPrompt(
'trip-summary',
{
title: 'Trip Summary',
description: 'Load a full summary of a trip for context before planning or modifications',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID to summarize'),
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const summary = getTripSummary(tripId);
if (!summary) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] };
}
const { trip, days, members, budget, packing, reservations, collabNotes } = summary;
const packingStats = packing ? { total: packing.length, packed: packing.filter((p: any) => p.checked).length } : { total: 0, packed: 0 };
const budgetTotal = budget?.reduce((sum: number, b: any) => sum + (b.total_price || 0), 0) || 0;
const text = `Trip: ${trip?.title || 'Untitled'}${trip?.description ? `\n${trip.description}` : ''}
Dates: ${trip?.start_date || '?'} to ${trip?.end_date || '?'}
Members: ${members?.length || 0} (${members?.map((m: any) => m.name || m.email).join(', ') || 'none'})
Days: ${days?.length || 0}
Packing: ${packingStats.packed}/${packingStats.total} items packed
Budget: ${budgetTotal} ${trip?.currency || 'EUR'} total
Reservations: ${reservations?.length || 0}
Collab Notes: ${collabNotes?.length || 0}
${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.length || 0} places${d.title ? ` - ${d.title}` : ''}`).join('\n') || 'No days yet'}`;
return {
description: `Summary of trip "${trip?.title || tripId}"`,
messages: [{ role: 'user', content: { type: 'text', text } }],
};
}
);
server.registerPrompt(
'packing-list',
{
title: 'Packing List',
description: 'Get a formatted packing checklist for a trip',
argsSchema: {
tripId: z.number().int().positive().describe('Trip ID'),
},
},
async ({ tripId }) => {
if (!canAccessTrip(tripId, userId)) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] };
}
const items = listPackingItems(tripId);
if (!items.length) {
return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] };
}
const grouped = items.reduce((acc: Record<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);
}
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(`
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.website || null);
INSERT INTO collab_notes (trip_id, user_id, title, content, category, color, website, pinned)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(tripId, userId, data.title, data.content || null, data.category || 'General', data.color || '#6366f1', data.website || null, pinned);
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 = ?
+13 -3
View File
@@ -20,7 +20,7 @@ interface UnsplashSearchResponse {
export function listPlaces(
tripId: string,
filters: { search?: string; category?: string; tag?: string },
filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' },
) {
let query = `
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);
}
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';
const places = db.prepare(query).all(...params) as PlaceWithCategory[];
@@ -133,7 +141,7 @@ export function updatePlace(
category_id?: number; price?: number; currency?: string;
place_time?: string; end_time?: 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[];
},
) {
@@ -143,7 +151,7 @@ export function updatePlace(
const {
name, description, lat, lng, address, category_id, price, currency,
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,
} = body;
@@ -163,6 +171,7 @@ export function updatePlace(
notes = ?,
image_url = ?,
google_place_id = ?,
osm_id = ?,
website = ?,
phone = ?,
transport_mode = COALESCE(?, transport_mode),
@@ -183,6 +192,7 @@ export function updatePlace(
notes !== undefined ? notes : existingPlace.notes,
image_url !== undefined ? image_url : existingPlace.image_url,
google_place_id !== undefined ? google_place_id : existingPlace.google_place_id,
osm_id !== undefined ? osm_id : existingPlace.osm_id,
website !== undefined ? website : existingPlace.website,
phone !== undefined ? phone : existingPlace.phone,
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 { runMigrations } from '../../../src/db/migrations';
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';
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);
});
});
});