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
This commit is contained in:
jubnl
2026-04-19 16:17:04 +02:00
parent da39b570eb
commit b85f8c5bca
3 changed files with 130 additions and 3 deletions
+37 -1
View File
@@ -1,6 +1,6 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod'; import { z } from 'zod';
import { canAccessTrip } from '../../db/database'; import { canAccessTrip, db } from '../../db/database';
import { isDemoUser } from '../../services/authService'; import { isDemoUser } from '../../services/authService';
import { import {
createBudgetItem, updateBudgetItem, deleteBudgetItem, createBudgetItem, updateBudgetItem, deleteBudgetItem,
@@ -94,6 +94,42 @@ export function registerBudgetTools(server: McpServer, userId: number, scopes: s
// --- BUDGET ADVANCED --- // --- 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( if (W) server.registerTool(
'set_budget_item_members', 'set_budget_item_members',
{ {
+49 -1
View File
@@ -1,12 +1,13 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod'; import { z } from 'zod';
import { canAccessTrip } from '../../db/database'; import { canAccessTrip, db } from '../../db/database';
import { isDemoUser } from '../../services/authService'; import { isDemoUser } from '../../services/authService';
import { import {
getDay, updateDay, validateAccommodationRefs, getDay, updateDay, validateAccommodationRefs,
createDay, deleteDay, createDay, deleteDay,
createAccommodation, getAccommodation, updateAccommodation, deleteAccommodation, createAccommodation, getAccommodation, updateAccommodation, deleteAccommodation,
} from '../../services/dayService'; } from '../../services/dayService';
import { createPlace } from '../../services/placeService';
import { import {
createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote, createNote as createDayNote, getNote as getDayNote, updateNote as updateDayNote,
deleteNote as deleteDayNote, dayExists as dayNoteExists, 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( server.registerTool(
'update_accommodation', 'update_accommodation',
{ {
+44 -1
View File
@@ -1,8 +1,9 @@
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod'; import { z } from 'zod';
import { canAccessTrip } from '../../db/database'; import { canAccessTrip, db } from '../../db/database';
import { isDemoUser } from '../../services/authService'; import { isDemoUser } from '../../services/authService';
import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService'; import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
import { createAssignment, dayExists } from '../../services/assignmentService';
import { onPlaceDeleted } from '../../services/journeyService'; import { onPlaceDeleted } from '../../services/journeyService';
import { listCategories } from '../../services/categoryService'; import { listCategories } from '../../services/categoryService';
import { searchPlaces } from '../../services/mapsService'; 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( if (W) server.registerTool(
'update_place', 'update_place',
{ {