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.
This commit is contained in:
Ida Rogalska
2026-08-13 20:50:42 +02:00
committed by Maurice
parent 03f3c5ae36
commit e375f9e002
2 changed files with 136 additions and 0 deletions
+24
View File
@@ -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',
@@ -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);
});
});
});