From 978df648eb655285a1d8389d55cfd7d251f66338 Mon Sep 17 00:00:00 2001 From: unknown Date: Mon, 6 Apr 2026 14:49:07 +0200 Subject: [PATCH] feat(mcp): add list_places assignment filter for orphan activities --- server/src/mcp/resources.ts | 5 +- server/src/mcp/tools.ts | 20 +++++ server/src/services/placeService.ts | 10 ++- server/tests/unit/mcp/tools-places.test.ts | 95 +++++++++++++++++++++- 4 files changed, 126 insertions(+), 4 deletions(-) diff --git a/server/src/mcp/resources.ts b/server/src/mcp/resources.ts index 84582fc3..806422e6 100644 --- a/server/src/mcp/resources.ts +++ b/server/src/mcp/resources.ts @@ -79,11 +79,12 @@ export function registerResources(server: McpServer, userId: number): void { server.registerResource( 'trip-places', new ResourceTemplate('trek://trips/{tripId}/places', { list: undefined }), - { description: 'All places/POIs saved in a trip', mimeType: 'application/json' }, + { description: 'All places/POIs in a trip, optionally filtered by assignment status (e.g. ?assignment=unassigned)', mimeType: 'application/json' }, async (uri, { tripId }) => { const id = parseId(tripId); if (id === null || !canAccessTrip(id, userId)) return accessDenied(uri.href); - const places = listPlaces(String(id), {}); + const assignment = uri.searchParams.get('assignment') as 'all' | 'unassigned' | 'assigned' | null; + const places = listPlaces(String(id), { assignment: assignment ?? undefined }); return jsonContent(uri.href, places); } ); diff --git a/server/src/mcp/tools.ts b/server/src/mcp/tools.ts index 547fbcf4..958d9f7b 100644 --- a/server/src/mcp/tools.ts +++ b/server/src/mcp/tools.ts @@ -246,6 +246,26 @@ export function registerTools(server: McpServer, userId: number): void { } ); + server.registerTool( + 'list_places', + { + description: 'List all places/POIs in a trip, optionally filtered by assignment status. Use assignment=unassigned to find orphan activities not yet scheduled on any day.', + inputSchema: { + tripId: z.number().int().positive(), + search: z.string().optional(), + category: z.string().optional(), + tag: z.string().optional(), + assignment: z.enum(['all', 'unassigned', 'assigned']).optional().default('all'), + }, + annotations: TOOL_ANNOTATIONS_READONLY, + }, + async ({ tripId, search, category, tag, assignment }) => { + if (!canAccessTrip(tripId, userId)) return noAccess(); + const places = listPlaces(String(tripId), { search, category, tag, assignment }); + return ok({ places }); + } + ); + // --- CATEGORIES --- server.registerTool( diff --git a/server/src/services/placeService.ts b/server/src/services/placeService.ts index 911f5ae4..640de1e3 100644 --- a/server/src/services/placeService.ts +++ b/server/src/services/placeService.ts @@ -20,7 +20,7 @@ interface UnsplashSearchResponse { export function listPlaces( tripId: string, - filters: { search?: string; category?: string; tag?: string }, + filters: { search?: string; category?: string; tag?: string; assignment?: 'all' | 'unassigned' | 'assigned' }, ) { let query = ` SELECT DISTINCT p.*, c.name as category_name, c.color as category_color, c.icon as category_icon @@ -46,6 +46,14 @@ export function listPlaces( params.push(filters.tag); } + if (filters.assignment === 'unassigned') { + query += ` AND p.id NOT IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`; + params.push(tripId); + } else if (filters.assignment === 'assigned') { + query += ` AND p.id IN (SELECT da.place_id FROM day_assignments da JOIN days d ON da.day_id = d.id WHERE d.trip_id = ?)`; + params.push(tripId); + } + query += ' ORDER BY p.created_at DESC'; const places = db.prepare(query).all(...params) as PlaceWithCategory[]; diff --git a/server/tests/unit/mcp/tools-places.test.ts b/server/tests/unit/mcp/tools-places.test.ts index 60a594b9..33674e38 100644 --- a/server/tests/unit/mcp/tools-places.test.ts +++ b/server/tests/unit/mcp/tools-places.test.ts @@ -43,7 +43,7 @@ vi.mock('../../../src/services/mapsService', () => ({ searchPlaces: searchPlaces import { createTables } from '../../../src/db/schema'; import { runMigrations } from '../../../src/db/migrations'; import { resetTestDb } from '../../helpers/test-db'; -import { createUser, createTrip, createPlace } from '../../helpers/factories'; +import { createUser, createTrip, createPlace, createDay } from '../../helpers/factories'; import { createMcpHarness, parseToolResult, type McpHarness } from '../../helpers/mcp-harness'; beforeAll(() => { @@ -321,3 +321,96 @@ describe('Tool: search_place', () => { }); }); }); + +// --------------------------------------------------------------------------- +// list_places +// --------------------------------------------------------------------------- + +describe('Tool: list_places', () => { + it('returns all places by default', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place1 = createPlace(testDb, trip.id, { name: 'Orphan Place' }); + const place2 = createPlace(testDb, trip.id, { name: 'Assigned Place' }); + const day = createDay(testDb, trip.id); + testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place2.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } }); + const data = parseToolResult(result) as any; + expect(data.places).toHaveLength(2); + }); + }); + + it('returns only unassigned places with assignment=unassigned', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' }); + const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' }); + const day = createDay(testDb, trip.id); + testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } }); + const data = parseToolResult(result) as any; + expect(data.places).toHaveLength(1); + expect(data.places[0].name).toBe('Orphan Place'); + }); + }); + + it('returns only assigned places with assignment=assigned', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const orphan = createPlace(testDb, trip.id, { name: 'Orphan Place' }); + const assigned = createPlace(testDb, trip.id, { name: 'Assigned Place' }); + const day = createDay(testDb, trip.id); + testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'assigned' } }); + const data = parseToolResult(result) as any; + expect(data.places).toHaveLength(1); + expect(data.places[0].name).toBe('Assigned Place'); + }); + }); + + it('returns empty array when all places are assigned', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const place = createPlace(testDb, trip.id, { name: 'Only Place' }); + const day = createDay(testDb, trip.id); + testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, place.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned' } }); + const data = parseToolResult(result) as any; + expect(data.places).toHaveLength(0); + }); + }); + + it('composes with search filter', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const orphan = createPlace(testDb, trip.id, { name: 'Louvre Museum' }); + const assigned = createPlace(testDb, trip.id, { name: 'Eiffel Tower' }); + const day = createDay(testDb, trip.id); + testDb.prepare('INSERT INTO day_assignments (day_id, place_id) VALUES (?, ?)').run(day.id, assigned.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id, assignment: 'unassigned', search: 'Louvre' } }); + const data = parseToolResult(result) as any; + expect(data.places).toHaveLength(1); + expect(data.places[0].name).toBe('Louvre Museum'); + }); + }); + + it('returns access denied for non-member', async () => { + const { user } = createUser(testDb); + const { user: other } = createUser(testDb); + const trip = createTrip(testDb, other.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ name: 'list_places', arguments: { tripId: trip.id } }); + expect(result.isError).toBe(true); + }); + }); +});