From e375f9e002b93b6208ad41bb3633f062e0b5c96f Mon Sep 17 00:00:00 2001 From: Ida Rogalska Date: Thu, 13 Aug 2026 20:50:42 +0200 Subject: [PATCH] feat(mcp): add set_day_default_transport_mode tool Wrap DaysService.setDefaultTransportMode behind an MCP tool, mirroring the days/:id/transport REST route. Adapter only; per-segment leg modes still override the day default. --- server/src/nest/days/days.mcp.ts | 24 ++++ .../unit/mcp/tools-leg-transport-mode.test.ts | 112 ++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/server/src/nest/days/days.mcp.ts b/server/src/nest/days/days.mcp.ts index 06959c41e..13ef856d5 100644 --- a/server/src/nest/days/days.mcp.ts +++ b/server/src/nest/days/days.mcp.ts @@ -104,6 +104,30 @@ export class DaysMcp { return ok({ success: true }); } + @Tool({ + name: 'set_day_default_transport_mode', + description: 'Set the whole-day default travel mode for a day. transport_mode is a route profile key (e.g. "driving", "walking", "cycling", "transit"); null clears the default. Per-segment leg modes still override this.', + inputSchema: { + tripId: z.number().int().positive(), + dayId: z.number().int().positive(), + transport_mode: z.string().nullable().optional().describe('Route profile key (e.g. "driving"), or null to clear the day default'), + }, + annotations: TOOL_ANNOTATIONS_WRITE, + access: { group: 'trips', mode: 'write' }, + }) + async setDayDefaultTransportMode( + { tripId, dayId, transport_mode }: { tripId: number; dayId: number; transport_mode?: string | null }, + ctx: McpContext, + ) { + if (this.auth.isDemoUser(ctx.userId)) return demoDenied(); + if (!this.days.verifyTripAccess(tripId, ctx.userId)) return noAccess(); + if (!this.guards.hasTripPermission('day_edit', tripId, ctx.userId)) return permissionDenied(); + if (!this.days.getDay(dayId, tripId)) return { content: [{ type: 'text' as const, text: 'Day not found.' }], isError: true }; + const day = this.days.setDefaultTransportMode(dayId, transport_mode ?? null); + this.guards.safeBroadcast(tripId, 'day:updated', { day }); + return ok({ day }); + } + @ResourceTemplate({ name: 'trip-days', uriTemplate: 'trek://trips/{tripId}/days', diff --git a/server/tests/unit/mcp/tools-leg-transport-mode.test.ts b/server/tests/unit/mcp/tools-leg-transport-mode.test.ts index 1404fbef7..f52cb2b47 100644 --- a/server/tests/unit/mcp/tools-leg-transport-mode.test.ts +++ b/server/tests/unit/mcp/tools-leg-transport-mode.test.ts @@ -182,3 +182,115 @@ describe('Tool: set_leg_transport_mode', () => { }); }); }); + +// --------------------------------------------------------------------------- +// set_day_default_transport_mode +// --------------------------------------------------------------------------- + +describe('Tool: set_day_default_transport_mode', () => { + it('sets the day default transport mode', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_day_default_transport_mode', + arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'driving' }, + }); + const data = parseToolResult(result) as any; + expect(data.day.default_transport_mode).toBe('driving'); + expect(broadcastMock).toHaveBeenCalledWith(trip.id, 'day:updated', expect.any(Object)); + }); + }); + + it('clears the day default with null', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + testDb.prepare('UPDATE days SET default_transport_mode = ? WHERE id = ?').run('driving', day.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_day_default_transport_mode', + arguments: { tripId: trip.id, dayId: day.id, transport_mode: null }, + }); + const data = parseToolResult(result) as any; + expect(data.day.default_transport_mode).toBeNull(); + }); + }); + + it('returns the day with its populated assignments array', async () => { + // setDefaultTransportMode returns { ...day, assignments }; exercise the non-empty case (panel item 4). + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + const place = createPlace(testDb, trip.id); + const assignment = createDayAssignment(testDb, day.id, place.id); + + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_day_default_transport_mode', + arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'driving' }, + }); + const data = parseToolResult(result) as any; + expect(Array.isArray(data.day.assignments)).toBe(true); + expect(data.day.assignments).toHaveLength(1); + expect(data.day.assignments[0].id).toBe(assignment.id); + }); + }); + + it('rejects a day belonging to another trip (cross-trip join)', async () => { + const { user } = createUser(testDb); + const trip1 = createTrip(testDb, user.id); + const trip2 = createTrip(testDb, user.id); + const day2 = createDay(testDb, trip2.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_day_default_transport_mode', + arguments: { tripId: trip1.id, dayId: day2.id, transport_mode: 'walking' }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('returns error when day not found', async () => { + const { user } = createUser(testDb); + const trip = createTrip(testDb, user.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_day_default_transport_mode', + arguments: { tripId: trip.id, dayId: 99999, transport_mode: 'walking' }, + }); + 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 day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_day_default_transport_mode', + arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'walking' }, + }); + expect(result.isError).toBe(true); + }); + }); + + it('blocks demo user', async () => { + process.env.DEMO_MODE = 'true'; + const { user } = createUser(testDb, { email: 'demo@nomad.app' }); + const trip = createTrip(testDb, user.id); + const day = createDay(testDb, trip.id); + await withHarness(user.id, async (h) => { + const result = await h.client.callTool({ + name: 'set_day_default_transport_mode', + arguments: { tripId: trip.id, dayId: day.id, transport_mode: 'walking' }, + }); + expect(result.isError).toBe(true); + }); + }); +});