From cf59b189cf9d6073bc73fd81bdef4c74769a5c04 Mon Sep 17 00:00:00 2001 From: jubnl Date: Fri, 22 May 2026 16:50:04 +0200 Subject: [PATCH] fix(mcp): expose price fields on all create tools so AI can link costs to items Add price/currency params to create_place, create_and_assign_place, create_place_accommodation (passed through to createPlace which already persists them). Add price/budget_category to create_transport and create_reservation: price is written to metadata.price (shown on the booking) and a linked budget_items row is created via a new linkBudgetItemToReservation helper in budgetService (also used to de-duplicate the existing route-level logic in routes/reservations.ts). Fixes #1031 --- server/src/mcp/tools/days.ts | 8 +++++--- server/src/mcp/tools/places.ts | 16 ++++++++++------ server/src/mcp/tools/reservations.ts | 20 ++++++++++++++++++-- server/src/mcp/tools/transports.ts | 22 +++++++++++++++++++--- server/src/routes/reservations.ts | 6 ++---- server/src/services/budgetService.ts | 11 +++++++++++ 6 files changed, 65 insertions(+), 18 deletions(-) diff --git a/server/src/mcp/tools/days.ts b/server/src/mcp/tools/days.ts index e6b22a05..83ff881c 100644 --- a/server/src/mcp/tools/days.ts +++ b/server/src/mcp/tools/days.ts @@ -116,7 +116,7 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri server.registerTool( 'create_place_accommodation', { - description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly.', + description: 'Create a new place and immediately set it as an accommodation for a date range in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use create_accommodation directly. Set price + currency to record the accommodation cost so it shows on the item.', inputSchema: { tripId: z.number().int().positive(), name: z.string().min(1).max(200), @@ -136,17 +136,19 @@ export function registerDayTools(server: McpServer, userId: number, scopes: stri check_out: z.string().max(10).optional().describe('Check-out time e.g. "11:00"'), confirmation: z.string().max(100).optional(), accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'), + price: z.number().nonnegative().optional().describe('Total accommodation cost (shown on the item)'), + currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, - async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes }) => { + async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, start_day_id, end_day_id, check_in, check_out, confirmation, accommodation_notes, price, currency }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); const dayErrors = validateAccommodationRefs(tripId, undefined, start_day_id, end_day_id); if (dayErrors.length > 0) return { content: [{ type: 'text' as const, text: dayErrors.map(e => e.message).join(', ') }], isError: true }; try { const run = db.transaction(() => { - const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone }); + const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency }); const accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes }); return { place, accommodation }; }); diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts index 451edaf2..a5bdcfe4 100644 --- a/server/src/mcp/tools/places.ts +++ b/server/src/mcp/tools/places.ts @@ -23,7 +23,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st if (W) server.registerTool( 'create_place', { - description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings.', + description: 'Add a new place/POI to a trip. Set google_place_id or osm_id (from search_place) so the app can show opening hours and ratings. Set price + currency to record the cost so it shows on the item.', inputSchema: { tripId: z.number().int().positive(), name: z.string().min(1).max(200), @@ -37,13 +37,15 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st notes: z.string().max(2000).optional(), website: z.string().max(500).optional(), phone: z.string().max(50).optional(), + price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'), + currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, - async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone }) => { + async ({ tripId, name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }) => { 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 }); + const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes, website, phone, price, currency }); safeBroadcast(tripId, 'place:created', { place }); return ok({ place }); } @@ -52,7 +54,7 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st if (W) server.registerTool( 'create_and_assign_place', { - description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly.', + description: 'Create a new place and immediately assign it to a day in one atomic operation. Use place details from search_place results. Only use when the place does not yet exist — if it already exists, use assign_place_to_day directly. Set price + currency to record the cost so it shows on the item.', inputSchema: { tripId: z.number().int().positive(), dayId: z.number().int().positive().describe('Day to assign the place to'), @@ -68,16 +70,18 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st website: z.string().max(500).optional(), phone: z.string().max(50).optional(), assignment_notes: z.string().max(500).optional().describe('Notes for this day assignment'), + price: z.number().nonnegative().optional().describe('Cost of this place/activity (e.g. ticket price, entry fee)'), + currency: z.string().length(3).optional().describe('ISO 4217 currency code (e.g. "EUR", "USD")'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, - async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes }) => { + async ({ tripId, dayId, name, description, lat, lng, address, category_id, google_place_id, osm_id, place_notes, website, phone, assignment_notes, price, currency }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); if (!dayExists(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; try { const run = db.transaction(() => { - const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone }); + const place = createPlace(String(tripId), { name, description, lat, lng, address, category_id, google_place_id, osm_id, notes: place_notes, website, phone, price, currency }); const assignment = createAssignment(dayId, place.id, assignment_notes ?? null); return { place, assignment }; }); diff --git a/server/src/mcp/tools/reservations.ts b/server/src/mcp/tools/reservations.ts index 1a2acef5..9c8825fb 100644 --- a/server/src/mcp/tools/reservations.ts +++ b/server/src/mcp/tools/reservations.ts @@ -6,6 +6,7 @@ import { createReservation, getReservation, updateReservation, deleteReservation, updatePositions as updateReservationPositions, } from '../../services/reservationService'; +import { linkBudgetItemToReservation } from '../../services/budgetService'; import { getDay } from '../../services/dayService'; import { placeExists, getAssignmentForTrip } from '../../services/assignmentService'; import { @@ -22,7 +23,7 @@ export function registerReservationTools(server: McpServer, userId: number, scop server.registerTool( 'create_reservation', { - description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id.', + description: 'Recommend a reservation for a trip. Created as pending — the user must confirm it. For flights, trains, cars, and cruises, use create_transport instead. Linking: hotel → use place_id + start_day_id + end_day_id (all three required to create the accommodation link); restaurant/event/tour/activity/other → use assignment_id. Set price to record the cost; it will appear on the booking and in the Budget tab.', inputSchema: { tripId: z.number().int().positive(), title: z.string().min(1).max(200), @@ -38,10 +39,12 @@ export function registerReservationTools(server: McpServer, userId: number, scop check_in: z.string().max(10).optional().describe('Check-in time (e.g. "15:00", hotel type only)'), check_out: z.string().max(10).optional().describe('Check-out time (e.g. "11:00", hotel type only)'), assignment_id: z.number().int().positive().optional().describe('Link to a day assignment (restaurant, train, car, cruise, event, tour, activity, other)'), + price: z.number().nonnegative().optional().describe('Reservation cost — shown on the booking and linked in the Budget tab'), + budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to reservation type)'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, - async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id }) => { + async ({ tripId, title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, start_day_id, end_day_id, check_in, check_out, assignment_id, price, budget_category }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); @@ -61,15 +64,28 @@ export function registerReservationTools(server: McpServer, userId: number, scop ? { place_id, start_day_id, end_day_id, check_in: check_in || undefined, check_out: check_out || undefined, confirmation: confirmation_number || undefined } : undefined; + const metadata = price != null ? { price: String(price) } : undefined; + const { reservation, accommodationCreated } = createReservation(tripId, { title, type, reservation_time, location, confirmation_number, notes, day_id, place_id, assignment_id, create_accommodation: createAccommodation, + metadata, }); if (accommodationCreated) { safeBroadcast(tripId, 'accommodation:created', {}); } + + if (price != null && price > 0) { + const item = linkBudgetItemToReservation(tripId, reservation.id, { + name: title, + category: budget_category || type, + total_price: price, + }); + safeBroadcast(tripId, 'budget:created', { item }); + } + safeBroadcast(tripId, 'reservation:created', { reservation }); return ok({ reservation }); } diff --git a/server/src/mcp/tools/transports.ts b/server/src/mcp/tools/transports.ts index 535ab7bc..d2cf022e 100644 --- a/server/src/mcp/tools/transports.ts +++ b/server/src/mcp/tools/transports.ts @@ -5,6 +5,7 @@ import { isDemoUser } from '../../services/authService'; import { createReservation, deleteReservation, getReservation, updateReservation, } from '../../services/reservationService'; +import { linkBudgetItemToReservation } from '../../services/budgetService'; import { getDay } from '../../services/dayService'; import { safeBroadcast, TOOL_ANNOTATIONS_DELETE, TOOL_ANNOTATIONS_NON_IDEMPOTENT, @@ -32,7 +33,7 @@ export function registerTransportTools(server: McpServer, userId: number, scopes server.registerTool( 'create_transport', { - description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport.', + description: 'Create a transport booking (flight, train, car, or cruise) for a trip. Use endpoints[] to record origin/destination and intermediate stops — for flights, set code to the IATA airport code (use search_airports first). Created as pending — confirm with update_transport. Set price to record the cost; it will appear on the booking and in the Budget tab.', inputSchema: { tripId: z.number().int().positive(), type: z.enum(['flight', 'train', 'car', 'cruise']), @@ -47,10 +48,12 @@ export function registerTransportTools(server: McpServer, userId: number, scopes metadata: z.record(z.string(), z.string()).optional().describe('Type-specific metadata: flights → { airline, flight_number, departure_airport, arrival_airport }; trains → { train_number, platform, seat }'), endpoints: endpointSchema, needs_review: z.boolean().optional(), + price: z.number().nonnegative().optional().describe('Transport cost — shown on the booking and linked in the Budget tab'), + budget_category: z.string().max(100).optional().describe('Budget category for the price entry (defaults to transport type)'), }, annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, }, - async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review }) => { + async ({ tripId, type, title, status, start_day_id, end_day_id, reservation_time, reservation_end_time, confirmation_number, notes, metadata, endpoints, needs_review, price, budget_category }) => { if (isDemoUser(userId)) return demoDenied(); if (!canAccessTrip(tripId, userId)) return noAccess(); @@ -59,6 +62,9 @@ export function registerTransportTools(server: McpServer, userId: number, scopes if (end_day_id && !getDay(end_day_id, tripId)) return { content: [{ type: 'text' as const, text: 'end_day_id does not belong to this trip.' }], isError: true }; + const meta: Record = { ...(metadata ?? {}) }; + if (price != null) meta.price = String(price); + const { reservation } = createReservation(tripId, { title, type, @@ -70,10 +76,20 @@ export function registerTransportTools(server: McpServer, userId: number, scopes day_id: start_day_id, end_day_id: end_day_id ?? start_day_id, status: status ?? 'pending', - metadata, + metadata: Object.keys(meta).length > 0 ? meta : undefined, endpoints, needs_review, }); + + if (price != null && price > 0) { + const item = linkBudgetItemToReservation(tripId, reservation.id, { + name: title, + category: budget_category || type, + total_price: price, + }); + safeBroadcast(tripId, 'budget:created', { item }); + } + safeBroadcast(tripId, 'reservation:created', { reservation }); return ok({ reservation }); } diff --git a/server/src/routes/reservations.ts b/server/src/routes/reservations.ts index 52cfdc63..48e9ef03 100644 --- a/server/src/routes/reservations.ts +++ b/server/src/routes/reservations.ts @@ -13,7 +13,7 @@ import { updateReservation, deleteReservation, } from '../services/reservationService'; -import { createBudgetItem, updateBudgetItem, deleteBudgetItem } from '../services/budgetService'; +import { createBudgetItem, updateBudgetItem, deleteBudgetItem, linkBudgetItemToReservation } from '../services/budgetService'; const router = express.Router({ mergeParams: true }); @@ -55,13 +55,11 @@ router.post('/', authenticate, (req: Request, res: Response) => { // Auto-create budget entry if price was provided if (create_budget_entry && create_budget_entry.total_price > 0) { try { - const budgetItem = createBudgetItem(tripId, { + const budgetItem = linkBudgetItemToReservation(tripId, reservation.id, { name: title, category: create_budget_entry.category || type || 'Other', total_price: create_budget_entry.total_price, }); - db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservation.id, budgetItem.id); - budgetItem.reservation_id = reservation.id; broadcast(tripId, 'budget:created', { item: budgetItem }, req.headers['x-socket-id'] as string); } catch (err) { console.error('[reservations] Failed to create budget entry:', err); diff --git a/server/src/services/budgetService.ts b/server/src/services/budgetService.ts index f98d5d9e..7c5934c4 100644 --- a/server/src/services/budgetService.ts +++ b/server/src/services/budgetService.ts @@ -96,6 +96,17 @@ export function createBudgetItem( return item; } +export function linkBudgetItemToReservation( + tripId: string | number, + reservationId: number, + data: { name: string; category?: string; total_price: number }, +) { + const item = createBudgetItem(tripId, data) as BudgetItem & { reservation_id?: number | null }; + db.prepare('UPDATE budget_items SET reservation_id = ? WHERE id = ?').run(reservationId, item.id); + item.reservation_id = reservationId; + return item; +} + export function updateBudgetItem( id: string | number, tripId: string | number,