diff --git a/server/src/mcp/tools/places.ts b/server/src/mcp/tools/places.ts index d8f68d2a..283c6097 100644 --- a/server/src/mcp/tools/places.ts +++ b/server/src/mcp/tools/places.ts @@ -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 }); + } + ); } diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 4a1e3234..ca6cb14f 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -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[2], +): NonNullable>[] { + if (ids.length === 0) return []; + const updated: NonNullable>[] = []; + 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 // --------------------------------------------------------------------------- diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index 33674e38..79e1da43 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -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 // --------------------------------------------------------------------------- diff --git a/server/tests/unit/services/placeService.test.ts b/server/tests/unit/services/placeService.test.ts index 4bf54f6b..9ccfcb5f 100644 --- a/server/tests/unit/services/placeService.test.ts +++ b/server/tests/unit/services/placeService.test.ts @@ -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', () => { diff --git a/wiki/MCP-Tools-and-Resources.md b/wiki/MCP-Tools-and-Resources.md index 8d593c66..28f0ba54 100644 --- a/wiki/MCP-Tools-and-Resources.md +++ b/wiki/MCP-Tools-and-Resources.md @@ -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. |