feat(mcp): add bulk_update_places tool

Apply the same field values to many places in one call instead of one
update_place per place — e.g. re-categorising 80 POIs at once. Adds the
updatePlacesMany service (one transaction, trip-scoped, partial patch
built on updatePlace) and the bulk_update_places MCP tool with the usual
demo/access/place_edit guards and a place:updated broadcast per place.
This commit is contained in:
Maurice
2026-06-29 22:09:26 +02:00
committed by Maurice
parent 23987c76bb
commit 9dd9057b7b
5 changed files with 168 additions and 2 deletions
+38 -1
View File
@@ -2,7 +2,7 @@ import { McpServer } from '@modelcontextprotocol/sdk/server/mcp';
import { z } from 'zod';
import { canAccessTrip, db } from '../../db/database';
import { isDemoUser } from '../../services/authService';
import { deletePlacesMany, importGoogleList, importNaverList, listPlaces, createPlace, updatePlace, deletePlace } from '../../services/placeService';
import { deletePlacesMany, updatePlacesMany, 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';
@@ -269,4 +269,41 @@ export function registerPlaceTools(server: McpServer, userId: number, scopes: st
return ok({ deleted, count: deleted.length });
}
);
if (W) server.registerTool(
'bulk_update_places',
{
description: 'Update many places in a trip at once, applying the SAME field values to every listed place. Use this for sweeping edits — e.g. re-categorising a batch of POIs (set category_id for 80 places) — in a single call instead of one update_place per place. Only the fields you set are changed; everything else on each place is preserved. Use list_categories for category_id.',
inputSchema: {
tripId: z.number().int().positive(),
placeIds: z.array(z.number().int().positive()).min(1).max(500).describe('IDs of the places to update (from list_places)'),
category_id: z.number().int().positive().optional().describe('Category ID — use list_categories'),
price: z.number().optional(),
currency: z.string().length(3).optional(),
transport_mode: z.enum(['walking', 'driving', 'cycling', 'transit', 'flight']).optional(),
place_time: z.string().max(50).optional().describe('Scheduled time (e.g. "09:00")'),
end_time: z.string().max(50).optional().describe('End time (e.g. "11:00")'),
duration_minutes: z.number().int().positive().optional(),
notes: z.string().max(2000).optional(),
website: z.string().max(500).optional(),
phone: z.string().max(50).optional(),
description: z.string().max(2000).optional(),
},
annotations: TOOL_ANNOTATIONS_WRITE,
},
async ({ tripId, placeIds, category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, description }) => {
if (isDemoUser(userId)) return demoDenied();
if (!canAccessTrip(tripId, userId)) return noAccess();
if (!hasTripPermission('place_edit', tripId, userId)) return permissionDenied();
const fields = { category_id, price, currency, transport_mode, place_time, end_time, duration_minutes, notes, website, phone, description };
if (Object.values(fields).every(v => v === undefined)) {
return { content: [{ type: 'text' as const, text: 'Provide at least one field to update.' }], isError: true };
}
const updated = updatePlacesMany(String(tripId), placeIds, fields);
for (const place of updated) safeBroadcast(tripId, 'place:updated', { place });
return ok({ count: updated.length, updatedIds: updated.map(p => p.id), skipped: placeIds.length - updated.length });
}
);
}
+27
View File
@@ -284,6 +284,33 @@ export function deletePlacesMany(tripId: string, ids: number[]): number[] {
return deleted;
}
// ---------------------------------------------------------------------------
// Bulk update
// ---------------------------------------------------------------------------
/**
* Apply the same set of fields to many places in a single transaction. Each
* place is scoped to the trip and patched via updatePlace, so only the provided
* fields change and everything else is preserved. IDs that don't belong to the
* trip are skipped. Returns the updated places.
*/
export function updatePlacesMany(
tripId: string,
ids: number[],
body: Parameters<typeof updatePlace>[2],
): NonNullable<ReturnType<typeof updatePlace>>[] {
if (ids.length === 0) return [];
const updated: NonNullable<ReturnType<typeof updatePlace>>[] = [];
const run = db.transaction((list: number[]) => {
for (const id of list) {
const place = updatePlace(tripId, String(id), body);
if (place) updated.push(place);
}
});
run(ids);
return updated;
}
// ---------------------------------------------------------------------------
// Import GPX
// ---------------------------------------------------------------------------
@@ -197,6 +197,64 @@ describe('Tool: update_place', () => {
});
});
// ---------------------------------------------------------------------------
// bulk_update_places
// ---------------------------------------------------------------------------
describe('Tool: bulk_update_places', () => {
it('applies the same field to many places in one call', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const a = createPlace(testDb, trip.id, { name: 'A' });
const b = createPlace(testDb, trip.id, { name: 'B' });
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({
name: 'bulk_update_places',
arguments: { tripId: trip.id, placeIds: [a.id, b.id], transport_mode: 'walking' },
});
const data = parseToolResult(result) as any;
expect(data.count).toBe(2);
expect([...data.updatedIds].sort()).toEqual([a.id, b.id].sort());
expect(data.skipped).toBe(0);
});
});
it('broadcasts place:updated for each updated place', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const a = createPlace(testDb, trip.id);
const b = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
broadcastMock.mockClear();
await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [a.id, b.id], notes: 'seen' } });
const updates = broadcastMock.mock.calls.filter((c) => c[1] === 'place:updated');
expect(updates).toHaveLength(2);
});
});
it('errors when no update fields are provided', async () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const a = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [a.id] } });
expect(result.isError).toBe(true);
});
});
it('returns access denied for non-member', async () => {
const { user } = createUser(testDb);
const { user: other } = createUser(testDb);
const trip = createTrip(testDb, other.id);
const place = createPlace(testDb, trip.id);
await withHarness(user.id, async (h) => {
const result = await h.client.callTool({ name: 'bulk_update_places', arguments: { tripId: trip.id, placeIds: [place.id], notes: 'x' } });
expect(result.isError).toBe(true);
});
});
});
// ---------------------------------------------------------------------------
// delete_place
// ---------------------------------------------------------------------------
@@ -55,7 +55,7 @@ import { resetTestDb } from '../../helpers/test-db';
import { createUser, createTrip, createPlace, createCategory, createTag } from '../../helpers/factories';
import path from 'path';
import fs from 'fs';
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
import { listPlaces, createPlace as svcCreatePlace, getPlace, updatePlace, updatePlacesMany, deletePlace, importGpx, importKmlPlaces, importGoogleList, searchPlaceImage } from '../../../src/services/placeService';
const GPX_FIXTURE = path.join(__dirname, '../../fixtures/test.gpx');
const KML_FIXTURE = path.join(__dirname, '../../fixtures/test.kml');
@@ -233,6 +233,49 @@ describe('updatePlace', () => {
});
});
// ── updatePlacesMany ────────────────────────────────────────────────────────────
describe('updatePlacesMany', () => {
it('PLACE-SVC-039 — applies the same fields to many places, preserving the rest', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const a = createPlace(testDb, trip.id, { name: 'A' }) as any;
const b = createPlace(testDb, trip.id, { name: 'B' }) as any;
const c = createPlace(testDb, trip.id, { name: 'C' }) as any;
const updated = updatePlacesMany(String(trip.id), [a.id, b.id, c.id], { notes: 'visited', transport_mode: 'walking' });
expect(updated).toHaveLength(3);
for (const p of updated) {
expect((p as any).notes).toBe('visited');
expect((p as any).transport_mode).toBe('walking');
}
// Only the provided fields change — names are untouched.
expect(updated.map(p => (p as any).name).sort()).toEqual(['A', 'B', 'C']);
});
it('PLACE-SVC-040 — skips ids that are not in the trip and reports the rest', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
const other = createTrip(testDb, user.id);
const mine = createPlace(testDb, trip.id, { name: 'Mine' }) as any;
const foreign = createPlace(testDb, other.id, { name: 'Foreign' }) as any;
const updated = updatePlacesMany(String(trip.id), [mine.id, foreign.id, 99999], { notes: 'tagged' });
expect(updated).toHaveLength(1);
expect((updated[0] as any).id).toBe(mine.id);
// The place from the other trip stays untouched.
expect((getPlace(String(other.id), String(foreign.id)) as any).notes).toBeNull();
});
it('PLACE-SVC-041 — returns [] for an empty id list', () => {
const { user } = createUser(testDb);
const trip = createTrip(testDb, user.id);
expect(updatePlacesMany(String(trip.id), [], { notes: 'x' })).toEqual([]);
});
});
// ── deletePlace ───────────────────────────────────────────────────────────────
describe('deletePlace', () => {
+1
View File
@@ -52,6 +52,7 @@ Requires `places:read` or `places:write` scope.
| `list_places` | List places in a trip, optionally filtered by assignment status, category, tag, or search query. |
| `create_place` | Add a place with name, coordinates, address, category, notes, website, phone, and optional `google_place_id` / `osm_id`. |
| `update_place` | Update any field of an existing place including transport mode, timing, and price. |
| `bulk_update_places` | Update many places at once, applying the same field values (e.g. category, price, transport mode) to every listed place in a single call. |
| `delete_place` | Remove a place from a trip. Also removes all day assignments. |
| `bulk_delete_places` | Delete multiple places by ID. Removes all day assignments. Cannot be undone. |
| `import_places_from_url` | Import all places from a publicly shared Google Maps or Naver Maps list URL. |