mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
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:
@@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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',
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user