From a012dffa223053cc5eb9f1742987beec1799b72d Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 10:43:31 +0200 Subject: [PATCH 01/12] MCP: add tool annotations, prompts, mimeType, and capabilities - Add tool annotations (readOnlyHint, destructiveHint, idempotentHint, openWorldHint) to all 40+ tools - Register 3 MCP prompts: trip-summary, packing-list, budget-overview - Add explicit mimeType: application/json to all resource registrations - Announce capabilities with listChanged on resources, tools, prompts - Update server name to 'TREK MCP' in MCP initialization --- server/src/mcp/index.ts | 10 ++- server/src/mcp/resources.ts | 28 +++--- server/src/mcp/tools.ts | 174 ++++++++++++++++++++++++++++++++++++ 3 files changed, 197 insertions(+), 15 deletions(-) diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index 86714a05..d647dd92 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -128,7 +128,15 @@ export async function mcpHandler(req: Request, res: Response): Promise { } // 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); diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index b3397839..84582fc3 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -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,7 +79,7 @@ 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 saved in a trip', mimeType: 'application/json' }, async (uri, { tripId }) => { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); @@ -92,7 +92,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 +105,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 +118,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 +131,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 +145,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 +158,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 +173,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 +186,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 +197,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 +208,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); diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index b2dbd8aa..547fbcf4 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -26,6 +26,34 @@ import { searchPlaces } from '../services/mapsService'; 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 +80,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 +114,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(); @@ -112,6 +142,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 +159,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,6 +187,7 @@ 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(); @@ -181,6 +214,7 @@ export function registerTools(server: McpServer, userId: number): void { website: z.string().max(500).optional(), phone: z.string().max(50).optional(), }, + annotations: TOOL_ANNOTATIONS_WRITE, }, async ({ tripId, placeId, name, description, lat, lng, address, notes, website, phone }) => { if (isDemoUser(userId)) return demoDenied(); @@ -200,6 +234,7 @@ 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(); @@ -218,6 +253,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 +270,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 +294,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(); @@ -278,6 +316,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(); @@ -303,6 +342,7 @@ 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(); @@ -321,6 +361,7 @@ 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(); @@ -343,6 +384,7 @@ 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(); @@ -362,6 +404,7 @@ 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(); @@ -381,6 +424,7 @@ 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(); @@ -414,6 +458,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(); @@ -457,6 +502,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(); @@ -484,6 +530,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(); @@ -525,6 +572,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(); @@ -550,6 +598,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(); @@ -581,6 +630,7 @@ export function registerTools(server: McpServer, userId: number): void { 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(); @@ -619,6 +669,7 @@ 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(); @@ -642,6 +693,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(); @@ -665,6 +717,7 @@ 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_NON_IDEMPOTENT, }, async ({ tripId, dayId, assignmentIds }) => { if (isDemoUser(userId)) return demoDenied(); @@ -685,6 +738,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 +761,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 +777,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 +796,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 +812,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(); @@ -776,6 +834,7 @@ export function registerTools(server: McpServer, userId: number): void { 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'), }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, async ({ tripId, title, content, category, color }) => { if (isDemoUser(userId)) return demoDenied(); @@ -799,6 +858,7 @@ 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(); @@ -818,6 +878,7 @@ 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(); @@ -842,6 +903,7 @@ 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(); @@ -865,6 +927,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(); @@ -886,6 +949,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(); @@ -897,4 +961,114 @@ export function registerTools(server: McpServer, userId: number): void { 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 { listPackingItems } = await import('../services/packingService'); + const items = listPackingItems(tripId); + if (!items.length) { + return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] }; + } + const grouped = items.reduce((acc: Record, item: any) => { + const cat = item.category || 'General'; + if (!acc[cat]) acc[cat] = []; + acc[cat].push(item); + return acc; + }, {}); + const lines = Object.entries(grouped).map(([cat, items]) => + `## ${cat}\n${(items as any[]).map((i: any) => `- [${i.checked ? 'x' : ' '}] ${i.name}`).join('\n')}` + ).join('\n\n'); + const { trip } = getTripSummary(tripId) || {}; + return { + description: `Packing list for "${trip?.title || tripId}"`, + messages: [{ role: 'user', content: { type: 'text', text: `# Packing List: ${trip?.title || 'Trip'}\n\n${lines}\n\n_${items.length} items across ${Object.keys(grouped).length} categories_` } }], + }; + } + ); + + server.registerPrompt( + 'budget-overview', + { + title: 'Budget Overview', + description: 'Get a formatted budget summary for a trip', + argsSchema: { + tripId: z.number().int().positive().describe('Trip ID'), + }, + }, + async ({ tripId }) => { + if (!canAccessTrip(tripId, userId)) { + return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] }; + } + const summary = getTripSummary(tripId); + if (!summary) { + return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found.' } }] }; + } + const { trip, budget } = summary; + const currency = trip?.currency || 'EUR'; + const byCategory = (budget || []).reduce((acc: Record, item: any) => { + const cat = item.category || 'Uncategorized'; + acc[cat] = (acc[cat] || 0) + (item.total_price || 0); + return acc; + }, {} as Record); + const total = Object.values(byCategory).reduce((s, v) => s + v, 0); + const lines = Object.entries(byCategory) + .sort(([, a], [, b]) => b - a) + .map(([cat, amount]) => `- ${cat}: ${amount} ${currency}`) + .join('\n'); + const perPerson = (summary.members?.length || 1) > 0 ? (total / (summary.members?.length || 1)).toFixed(2) : total.toFixed(2); + return { + description: `Budget overview for "${trip?.title || tripId}"`, + messages: [{ role: 'user', content: { type: 'text', text: `# Budget: ${trip?.title || 'Trip'}\n\n**Total: ${total} ${currency}** (${perPerson} ${currency} per person)\n\n${lines || 'No expenses recorded.'}` } }], + }; + } + ); } From 978df648eb655285a1d8389d55cfd7d251f66338 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 14:49:07 +0200 Subject: [PATCH 02/12] feat(mcp): add list_places assignment filter for orphan activities --- server/src/mcp/resources.ts | 5 +- server/src/mcp/tools.ts | 20 +++++ server/src/services/placeService.ts | 10 ++- server/tests/unit/mcp/tools-places.test.ts | 95 +++++++++++++++++++++- 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index 84582fc3..806422e6 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -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', mimeType: 'application/json' }, + { 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); } ); diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 547fbcf4..958d9f7b 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -246,6 +246,26 @@ export function registerTools(server: McpServer, userId: number): void { } ); + 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'), + }, + 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( diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 911f5ae4..640de1e3 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -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[]; diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index 60a594b9..33674e38 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -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); + }); + }); +}); From 46449d374a73235311bf36ce1a8b9191966acc9f Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 15:01:48 +0200 Subject: [PATCH 03/12] fix(mcp): document assignment enum values in list_places description --- server/src/mcp/tools.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 958d9f7b..a42df8c8 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -255,7 +255,7 @@ export function registerTools(server: McpServer, userId: number): void { search: z.string().optional(), category: z.string().optional(), tag: z.string().optional(), - assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all'), + 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, }, From 39db61cc767dde0d0fe0a232d00b3f3aae7ad603 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 15:05:43 +0200 Subject: [PATCH 04/12] fix(mcp): add describe() to remaining z.enum fields for better tool descriptions --- server/src/mcp/tools.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index a42df8c8..66be7489 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -465,7 +465,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(), @@ -641,12 +641,12 @@ 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'), }, From 1646caa66b1ef698dc021c1af9a605af848951f3 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 15:21:55 +0200 Subject: [PATCH 05/12] fix(mcp): add error handling and logging to prevent silent crashes --- server/src/mcp/index.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index d647dd92..86c8e421 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -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 { 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', detail: String(err) }); + } + } return; } @@ -142,16 +154,25 @@ export async function mcpHandler(req: Request, res: Response): Promise { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), + allowedOrigins: ['*'], 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). */ From 4b0cda41cf590074ee4a46cbcd3022cb020c630f Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 15:31:22 +0200 Subject: [PATCH 06/12] fix(mcp): wrap broadcast calls in try-catch to prevent WebSocket errors crashing tools --- server/src/mcp/tools.ts | 66 +++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 29 deletions(-) diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 66be7489..c2ba1955 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -3,6 +3,14 @@ import { z } from 'zod'; import { canAccessTrip } from '../db/database'; import { broadcast } from '../websocket'; import { isDemoUser } from '../services/authService'; + +function safeBroadcast(tripId: number, event: string, payload: Record): void { + try { + safeBroadcast(tripId, event, payload); + } catch (err) { + console.error(`[MCP] broadcast failed for ${event}:`, err); + } +} import { listTrips, createTrip, updateTrip, deleteTrip, getTripSummary, isOwner, verifyTripAccess, @@ -130,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 }); } ); @@ -193,7 +201,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -221,7 +229,7 @@ export function registerTools(server: McpServer, userId: number): void { if (!canAccessTrip(tripId, userId)) return noAccess(); const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, notes, website, phone }); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; - broadcast(tripId, 'place:updated', { place }); + safeBroadcast(tripId, 'place:updated', { place }); return ok({ place }); } ); @@ -241,7 +249,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -322,7 +330,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 }); } ); @@ -344,7 +352,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 }); } ); @@ -368,7 +376,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -388,7 +396,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -410,7 +418,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -431,7 +439,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -451,7 +459,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -507,9 +515,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 }); } ); @@ -530,9 +538,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 }); } ); @@ -574,8 +582,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 }); } ); @@ -604,7 +612,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 }); } ); @@ -626,7 +634,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 }); } ); @@ -668,7 +676,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 }); } ); @@ -696,7 +704,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -721,7 +729,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 }); } ); @@ -744,7 +752,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -860,7 +868,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); + safeBroadcast(tripId, 'collab:note:created', { note }); return ok({ note }); } ); @@ -885,7 +893,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -905,7 +913,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -930,7 +938,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 }); } ); @@ -955,7 +963,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 }); } ); @@ -977,7 +985,7 @@ 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 }); } ); From 6883f2fdf93b875186b5a2f416ad010f753de73c Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 15:38:52 +0200 Subject: [PATCH 07/12] fix(mcp): revert allowedOrigins to avoid SDK compatibility issues --- server/src/mcp/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index 86c8e421..aeffdba4 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -154,7 +154,6 @@ export async function mcpHandler(req: Request, res: Response): Promise { const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: () => randomUUID(), - allowedOrigins: ['*'], 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}`); From caa6b7ecca4cd729ce5d24bd24b568639f2877df Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 15:41:49 +0200 Subject: [PATCH 08/12] fix(mcp): safeBroadcast now calls broadcast correctly (was recursive call bug) --- server/src/mcp/tools.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index c2ba1955..9d539492 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -6,9 +6,9 @@ import { isDemoUser } from '../services/authService'; function safeBroadcast(tripId: number, event: string, payload: Record): void { try { - safeBroadcast(tripId, event, payload); + broadcast(tripId, event, payload); } catch (err) { - console.error(`[MCP] broadcast failed for ${event}:`, err); + console.error(`[MCP] broadcast failed for ${event}:`, err?.message ?? err); } } import { From 3ccafb9a7b9ecf10817052351c590a9bcc11370d Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 16:05:08 +0200 Subject: [PATCH 09/12] fix(mcp): add missing fields to update_place and create_collab_note pinned support --- server/src/mcp/tools.ts | 16 ++++++++++++---- server/src/services/collabService.ts | 9 +++++---- 2 files changed, 17 insertions(+), 8 deletions(-) diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 9d539492..bdbfd7f5 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -218,16 +218,23 @@ 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(), }, 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 }) => { 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 }); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; safeBroadcast(tripId, 'place:updated', { place }); return ok({ place }); @@ -861,13 +868,14 @@ 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 }); + const note = createCollabNote(tripId, userId, { title, content, category, color, pinned }); safeBroadcast(tripId, 'collab:note:created', { note }); return ok({ note }); } diff --git a/server/src/services/collabService.ts b/server/src/services/collabService.ts index 38c0be28..7f0b8b0e 100644 --- a/server/src/services/collabService.ts +++ b/server/src/services/collabService.ts @@ -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 = ? From 6aeec0ead1d27fda8b9483182e4e107cc80e54a1 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 16:10:54 +0200 Subject: [PATCH 10/12] fix: add osm_id to update_place --- server/src/mcp/tools.ts | 5 +++-- server/src/services/placeService.ts | 6 ++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index bdbfd7f5..933a996e 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -228,13 +228,14 @@ export function registerTools(server: McpServer, userId: number): void { 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 from search_place (e.g. "way:12345") — create-only field, normally not updated'), }, annotations: TOOL_ANNOTATIONS_WRITE, }, - async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode }) => { + 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 }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode }); + 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 }); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; safeBroadcast(tripId, 'place:updated', { place }); return ok({ place }); diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 640de1e3..23c7a464 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -141,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[]; }, ) { @@ -151,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; @@ -171,6 +171,7 @@ export function updatePlace( notes = ?, image_url = ?, google_place_id = ?, + osm_id = ?, website = ?, phone = ?, transport_mode = COALESCE(?, transport_mode), @@ -191,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, From 78b465a8155a5b6d82d30fb0a63f155154338983 Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 9 Apr 2026 12:56:05 +0200 Subject: [PATCH 11/12] fix(mcp): clean up import ordering, static imports, and annotation correctness - Move safeBroadcast after all imports (was incorrectly placed between import blocks) - Replace dynamic import of packingService in packing-list prompt with static import - Fix reorder_day_assignments annotation from NON_IDEMPOTENT to WRITE (reordering is idempotent) - Fix misleading osm_id description in update_place (removed "create-only" claim) - Remove internal error detail leakage from MCP 500 responses --- server/src/mcp/index.ts | 2 +- server/src/mcp/tools.ts | 23 +++++++++++------------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/server/src/mcp/index.ts b/server/src/mcp/index.ts index aeffdba4..9a970867 100644 --- a/server/src/mcp/index.ts +++ b/server/src/mcp/index.ts @@ -122,7 +122,7 @@ export async function mcpHandler(req: Request, res: Response): Promise { } catch (err) { console.error('[MCP] transport.handleRequest error:', err); if (!res.headersSent) { - res.status(500).json({ error: 'Internal MCP error', detail: String(err) }); + res.status(500).json({ error: 'Internal MCP error' }); } } return; diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 933a996e..9f6608b5 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -3,14 +3,6 @@ import { z } from 'zod'; import { canAccessTrip } from '../db/database'; import { broadcast } from '../websocket'; import { isDemoUser } from '../services/authService'; - -function safeBroadcast(tripId: number, event: string, payload: Record): void { - try { - broadcast(tripId, event, payload); - } catch (err) { - console.error(`[MCP] broadcast failed for ${event}:`, err?.message ?? err); - } -} import { listTrips, createTrip, updateTrip, deleteTrip, getTripSummary, isOwner, verifyTripAccess, @@ -22,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'; @@ -32,6 +24,14 @@ import { } from '../services/atlasService'; import { searchPlaces } from '../services/mapsService'; +function safeBroadcast(tripId: number, event: string, payload: Record): void { + try { + broadcast(tripId, event, payload); + } catch (err) { + console.error(`[MCP] broadcast failed for ${event}:`, err?.message ?? err); + } +} + const MAX_MCP_TRIP_DAYS = 90; const TOOL_ANNOTATIONS_READONLY = { @@ -228,7 +228,7 @@ export function registerTools(server: McpServer, userId: number): void { 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 from search_place (e.g. "way:12345") — create-only field, normally not updated'), + osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'), }, annotations: TOOL_ANNOTATIONS_WRITE, }, @@ -753,7 +753,7 @@ 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_NON_IDEMPOTENT, + annotations: TOOL_ANNOTATIONS_WRITE, }, async ({ tripId, dayId, assignmentIds }) => { if (isDemoUser(userId)) return demoDenied(); @@ -1050,7 +1050,6 @@ ${days?.map((d: any, i: number) => `Day ${i + 1} (${d.date}): ${d.assignments?.l if (!canAccessTrip(tripId, userId)) { return { messages: [{ role: 'user', content: { type: 'text', text: 'Trip not found or access denied.' } }] }; } - const { listPackingItems } = await import('../services/packingService'); const items = listPackingItems(tripId); if (!items.length) { return { messages: [{ role: 'user', content: { type: 'text', text: 'No packing items found for this trip.' } }] }; From a565f3c665f6e06ea0e85ca67886852767d1d45d Mon Sep 17 00:00:00 2001 From: jubnl Date: Thu, 9 Apr 2026 13:51:00 +0200 Subject: [PATCH 12/12] fix(mcp): add missing google place id on update_place tool --- server/src/mcp/tools.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 9f6608b5..dc2f9dd6 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -229,13 +229,14 @@ export function registerTools(server: McpServer, userId: number): void { phone: z.string().max(50).optional(), transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(), osm_id: z.string().optional().describe('OpenStreetMap ID (e.g. "way:12345")'), + google_place_id: z.string().optional().describe('Google Place ID (e.g. "ChIJd8BlQ2BZwokRAFUEcm_qrcA")'), }, annotations: TOOL_ANNOTATIONS_WRITE, }, - async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id }) => { + async ({ tripId, placeId, name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); - const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id }); + const place = updatePlace(String(tripId), String(placeId), { name, description, lat, lng, address, category_id, price, currency, place_time, end_time, duration_minutes, notes, website, phone, transport_mode, osm_id, google_place_id }); if (!place) return { content: [{ type: 'text' as const, text: 'Place not found.' }], isError: true }; safeBroadcast(tripId, 'place:updated', { place }); return ok({ place });