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
This commit is contained in:
jubnl
2026-05-22 16:50:04 +02:00
parent 66ac2a1b1b
commit cf59b189cf
6 changed files with 65 additions and 18 deletions
+5 -3
View File
@@ -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 };
});
+10 -6
View File
@@ -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 };
});
+18 -2
View File
@@ -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 });
}
+19 -3
View File
@@ -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<string, string> = { ...(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 });
}
+2 -4
View File
@@ -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);
+11
View File
@@ -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,