From b85f8c5bca6c4d7298139259c5f198cea4dec4c8 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sun, 19 Apr 2026 16:17:04 +0200 Subject: [PATCH] feat(mcp): add compound tools for common multi-step workflows Adds three atomic compound MCP tools that collapse invariant sequential call patterns into single operations with transaction-backed rollback: - create_and_assign_place: create place + assign to day - create_place_accommodation: create place + book accommodation - create_budget_item_with_members: create budget item + set split members --- server/src/mcp/tools/budget.ts | 38 +++++++++++++++++++++++++- server/src/mcp/tools/days.ts | 50 +++++++++++++++++++++++++++++++++- server/src/mcp/tools/places.ts | 45 +++++++++++++++++++++++++++++- 3 files changed, 130 insertions(+), 3 deletions(-) diff --git a/server/src/mcp/tools/budget.ts b/server/src/mcp/tools/budget.ts index e0dbc1c4..18736ff4 100644 --- a/server/src/mcp/tools/budget.ts +++ b/server/src/mcp/tools/budget.ts @@ -1,6 +1,6 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; -import { canAccessTrip } from '../../db/database'; +import { canAccessTrip, db } from '../../db/database'; import { isDemoUser } from '../../services/authService'; import { createBudgetItem, updateBudgetItem, deleteBudgetItem, @@ -94,6 +94,42 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s // --- BUDGET ADVANCED --- + if (W) server.registerTool( + 'create_budget_item_with_members', + { + description: 'Create a budget/expense item and optionally set the trip members splitting it in one atomic operation. If userIds is omitted or empty, behaves like create_budget_item. Only use when the place does not yet exist — if it already exists, use set_budget_item_members directly.', + inputSchema: { + tripId: z.number().int().positive(), + name: z.string().min(1).max(200), + category: z.string().max(100).optional().describe('Budget category (e.g. Accommodation, Food, Transport)'), + total_price: z.number().nonnegative(), + note: z.string().max(500).optional(), + userIds: z.array(z.number().int().positive()).optional().describe('User IDs splitting this item; omit or pass empty array to skip member assignment'), + }, + annotations: TOOL_ANNOTATIONS_NON_IDEMPOTENT, + }, + async ({ tripId, name, category, total_price, note, userIds }) => { + if (isDemoUser(userId)) return demoDenied(); + if (!canAccessTrip(tripId, userId)) return noAccess(); + const hasMembers = userIds && userIds.length > 0; + try { + const run = db.transaction(() => { + const item = createBudgetItem(tripId, { category, name, total_price, note }); + if (hasMembers) { + return updateBudgetMembers(item.id, tripId, userIds!); + } + return { item }; + }); + const result = run(); + safeBroadcast(tripId, 'budget:created', { item: (result as any).item ?? result }); + if (hasMembers) safeBroadcast(tripId, 'budget:members-updated', { item: result }); + return ok({ item: result }); + } catch { + return { content: [{ type: 'text' as const, text: 'Failed to create budget item.' }], isError: true }; + } + } + ); + if (W) server.registerTool( 'set_budget_item_members', { diff --git a/server/src/mcp/tools/days.ts b/server/src/mcp/tools/days.ts index adae5cc3..e6b22a05 100644 --- a/server/src/mcp/tools/days.ts +++ b/server/src/mcp/tools/days.ts @@ -1,12 +1,13 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; -import { canAccessTrip } from '../../db/database'; +import { canAccessTrip, db } from '../../db/database'; import { isDemoUser } from '../../services/authService'; import { getDay, updateDay, validateAccommodationRefs, createDay, deleteDay, createAccommodation, getAccommodation, updateAccommodation, deleteAccommodation, } from '../../services/dayService'; +import { createPlace } from '../../services/placeService'; import { createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote, deleteNote as deleteDayNote, dayExists as dayNoteExists, @@ -112,6 +113,53 @@ 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.', + inputSchema: { + tripId: z.number().int().positive(), + name: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + 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 to see available options'), + google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), + osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), + place_notes: z.string().max(2000).optional().describe('Notes for the place'), + website: z.string().max(500).optional(), + phone: z.string().max(50).optional(), + start_day_id: z.number().int().positive().describe('Check-in day ID'), + end_day_id: z.number().int().positive().describe('Check-out day ID'), + 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"'), + confirmation: z.string().max(100).optional(), + accommodation_notes: z.string().max(1000).optional().describe('Notes for the accommodation'), + }, + 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 }) => { + 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 accommodation = createAccommodation(tripId, { place_id: place.id, start_day_id, end_day_id, check_in, check_out, confirmation, notes: accommodation_notes }); + return { place, accommodation }; + }); + const result = run(); + safeBroadcast(tripId, 'place:created', { place: result.place }); + safeBroadcast(tripId, 'accommodation:created', { accommodation: result.accommodation }); + return ok(result); + } catch { + return { content: [{ type: 'text' as const, text: 'Failed to create place and accommodation.' }], isError: true }; + } + } + ); + server.registerTool( 'update_accommodation', { diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts index 7b4c3607..451edaf2 100644 --- a/server/src/mcp/tools/places.ts +++ b/server/src/mcp/tools/places.ts @@ -1,8 +1,9 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { z } from 'zod'; -import { canAccessTrip } from '../../db/database'; +import { canAccessTrip, db } from '../../db/database'; import { isDemoUser } from '../../services/authService'; import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService'; +import { createAssignment, dayExists } from '../../services/assignmentService'; import { onPlaceDeleted } from '../../services/journeyService'; import { listCategories } from '../../services/categoryService'; import { searchPlaces } from '../../services/mapsService'; @@ -48,6 +49,48 @@ 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.', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive().describe('Day to assign the place to'), + name: z.string().min(1).max(200), + description: z.string().max(2000).optional(), + 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 to see available options'), + google_place_id: z.string().optional().describe('Google Place ID from search_place — enables opening hours display'), + osm_id: z.string().optional().describe('OpenStreetMap ID from search_place (e.g. "way:12345")'), + place_notes: z.string().max(2000).optional().describe('Notes for the place'), + 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'), + }, + 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 }) => { + 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 assignment = createAssignment(dayId, place.id, assignment_notes ?? null); + return { place, assignment }; + }); + const result = run(); + safeBroadcast(tripId, 'place:created', { place: result.place }); + safeBroadcast(tripId, 'assignment:created', { assignment: result.assignment }); + return ok(result); + } catch { + return { content: [{ type: 'text' as const, text: 'Failed to create place and assignment.' }], isError: true }; + } + } + ); + if (W) server.registerTool( 'update_place', {