From 356df90095600d41f940ac68b366385a9b166508 Mon Sep 17 00:00:00 2001 From: Maurice Date: Thu, 15 Oct 2026 22:08:39 +0200 Subject: [PATCH] Tours: read the saved tours of a trip over MCP (P2D-MCP-01) --- .../src/db/repositories/Tours.repository.ts | 31 ++++- server/src/nest/tours/tours.mcp.ts | 98 +++++++++++++++ server/src/nest/tours/tours.module.ts | 5 +- server/src/nest/tours/tours.service.ts | 6 + server/tests/unit/nest/tours.mcp.test.ts | 114 ++++++++++++++++++ wiki/MCP-Addon-Tools.md | 10 ++ 6 files changed, 262 insertions(+), 2 deletions(-) create mode 100644 server/src/nest/tours/tours.mcp.ts create mode 100644 server/tests/unit/nest/tours.mcp.test.ts diff --git a/server/src/db/repositories/Tours.repository.ts b/server/src/db/repositories/Tours.repository.ts index 88836921b..3f3642a69 100644 --- a/server/src/db/repositories/Tours.repository.ts +++ b/server/src/db/repositories/Tours.repository.ts @@ -74,7 +74,16 @@ export type TourUpdate = Omit; +type ToursKyselyDB = Pick; + +/** One day a tour of the trip sits on, in the order of the trip. */ +export interface TourDayRow { + place_id: number; + day_id: number; + day_number: number; + date: string | null; + title: string | null; +} /** * Where one tour came from, as the provenance contract reads it: the facet's @@ -243,6 +252,26 @@ export class ToursRepository extends TrekRepository { .execute(); } + /** The days every tour of a trip sits on, each tour and day once, in day order. */ + async listTourDays(trip_id: number): Promise { + return await this.db() + .selectFrom('day_assignments as da') + .innerJoin('tours as t', 't.place_id', 'da.place_id') + .innerJoin('days as d', 'd.id', 'da.day_id') + .select([ + 'da.place_id as place_id', + 'd.id as day_id', + 'd.day_number as day_number', + 'd.date as date', + 'd.title as title', + ]) + .where('d.trip_id', '=', trip_id) + .distinct() + .orderBy('d.day_number', 'asc') + .orderBy('da.place_id', 'asc') + .execute(); + } + /** TO3 — `SELECT 1 FROM tours WHERE place_id = ?`: whether the place is a tour. */ async existsForPlace(place_id: number): Promise { const row = await this.db() diff --git a/server/src/nest/tours/tours.mcp.ts b/server/src/nest/tours/tours.mcp.ts new file mode 100644 index 000000000..102f097a0 --- /dev/null +++ b/server/src/nest/tours/tours.mcp.ts @@ -0,0 +1,98 @@ +import { ADDON_IDS } from '../../addons'; +import type { TourDayRow } from '../../db/repositories/Tours.repository'; +import { noAccess } from '../../mcp/tools/_shared'; +import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type McpContext } from '../../nest-mcp'; +import { addonGate } from '../addons/addon-gate'; +import { AddonsService } from '../addons/addons.service'; +import { TripAccessService } from '../trip-membership/trip-access.service'; +import { ToursService } from './tours.service'; +import { NotFoundException } from '@nestjs/common'; +import { idSchema, type TourListItem } from '@trek/shared'; + +type TourDay = Pick; + +/** A tour as a client reads it: its list facts and the days of the trip it is on. */ +function withDays(tour: TourListItem, days: ReadonlyMap) { + return { ...tour, days: days.get(tour.place_id) ?? [] }; +} + +/** + * P2D-MCP-01: the saved tours of a trip, read only. A tool reads what the + * tours list and the tour detail answer over REST, through the same service, + * so a client sees the same facts the app shows: the tours of a trip, one + * tour's plan (its control points, measures and provenance; the full line + * stays out, it runs to thousands of points), and the tours of one day. + * Nothing here writes. Drafts in a browser are never visible here: only + * saved tours exist on the server. + */ +@McpController() +export class ToursMcp { + constructor( + private readonly tours: ToursService, + private readonly access: TripAccessService, + // Read by the addon gate of every tool here. + readonly addons: AddonsService, + ) {} + + /** Each tour of the trip with the days it is on, each day once, in day order. */ + private async daysByTour(tripId: number): Promise> { + const byTour = new Map(); + for (const { place_id, ...day } of await this.tours.listTourDays(tripId)) { + byTour.set(place_id, [...(byTour.get(place_id) ?? []), day]); + } + return byTour; + } + + @Tool({ + name: 'list_tours', + description: + 'List the saved tours of a trip (hikes and other tours planned or imported in the Tours addon): name, type, where it came from (origin: trek, gpx or wanderer), distance in km, ascent and descent in metres, the stored routing limit (max_hiking_difficulty, SAC T1 to T6), and the days of the trip each tour is on.', + inputSchema: { tripId: idSchema }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: addonGate(ADDON_IDS.TOURS), + access: { group: 'trips', mode: 'read' }, + }) + async listTours({ tripId }: { tripId: number }, ctx: McpContext) { + if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess(); + const [tours, days] = await Promise.all([this.tours.listTours(String(tripId)), this.daysByTour(tripId)]); + return ok({ tours: tours.map((tour) => withDays(tour, days)) }); + } + + @Tool({ + name: 'get_tour_plan', + description: + 'Read one saved tour of a trip: its facts, its ordered control points (start, via and end; an import without its own shows the two ends of its track), where it came from (provenance) and the days it is on. The full track geometry is not included.', + inputSchema: { tripId: idSchema, placeId: idSchema.describe('The place id of the tour, as list_tours gives it') }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: addonGate(ADDON_IDS.TOURS), + access: { group: 'trips', mode: 'read' }, + }) + async getTourPlan({ tripId, placeId }: { tripId: number; placeId: number }, ctx: McpContext) { + if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess(); + try { + const [detail, days] = await Promise.all([ + this.tours.getTour(String(tripId), String(placeId)), + this.daysByTour(tripId), + ]); + return ok({ ...detail, tour: withDays(detail.tour, days) }); + } catch (err: unknown) { + if (err instanceof NotFoundException) return errorResult('Tour not found.'); + throw err; + } + } + + @Tool({ + name: 'find_tours_for_day', + description: 'List the saved tours put on one day of a trip, with the same facts as list_tours.', + inputSchema: { tripId: idSchema, dayId: idSchema }, + annotations: TOOL_ANNOTATIONS_READONLY, + when: addonGate(ADDON_IDS.TOURS), + access: { group: 'trips', mode: 'read' }, + }) + async findToursForDay({ tripId, dayId }: { tripId: number; dayId: number }, ctx: McpContext) { + if (!(await this.access.findAccessible(tripId, ctx.userId))) return noAccess(); + const [tours, days] = await Promise.all([this.tours.listTours(String(tripId)), this.daysByTour(tripId)]); + const onDay = tours.filter((tour) => (days.get(tour.place_id) ?? []).some((day) => day.day_id === dayId)); + return ok({ tours: onDay.map((tour) => withDays(tour, days)) }); + } +} diff --git a/server/src/nest/tours/tours.module.ts b/server/src/nest/tours/tours.module.ts index a71c8ca79..94397f81f 100644 --- a/server/src/nest/tours/tours.module.ts +++ b/server/src/nest/tours/tours.module.ts @@ -7,11 +7,13 @@ import { AddonsModule } from '../addons/addons.module'; import { PermissionsModule } from '../permissions/permissions.module'; import { PlaceImportModule } from '../place-import/place-import.module'; import { PlacesModule } from '../places/places.module'; +import { TripMembershipModule } from '../trip-membership/trip-membership.module'; import { WandererModule } from '../wanderer/wanderer.module'; import { ToursImportController } from './tours-import.controller'; import { ToursWandererController } from './tours-wanderer.controller'; import { ToursWandererService } from './tours-wanderer.service'; import { ToursController } from './tours.controller'; +import { ToursMcp } from './tours.mcp'; import { ToursService } from './tours.service'; import { MikroOrmModule } from '@mikro-orm/nestjs'; import { Module } from '@nestjs/common'; @@ -28,6 +30,7 @@ import { Module } from '@nestjs/common'; PlacesModule, PlaceImportModule, PermissionsModule, + TripMembershipModule, AddonsModule, // The wanderer import reads trails through the caller's own connection. @@ -35,6 +38,6 @@ import { Module } from '@nestjs/common'; MikroOrmModule.forFeature([Tours, TourTypes, TourWaypoints, TourSources, Places]), ], controllers: [ToursController, ToursImportController, ToursWandererController], - providers: [ToursService, ToursWandererService], + providers: [ToursService, ToursWandererService, ToursMcp], }) export class ToursModule {} diff --git a/server/src/nest/tours/tours.service.ts b/server/src/nest/tours/tours.service.ts index 8560dedde..09066a5e0 100644 --- a/server/src/nest/tours/tours.service.ts +++ b/server/src/nest/tours/tours.service.ts @@ -6,6 +6,7 @@ import type { PlacesRepository } from '../../db/repositories/Places.repository'; import type { TourTypesRepository } from '../../db/repositories/TourTypes.repository'; import type { TourWaypointsRepository } from '../../db/repositories/TourWaypoints.repository'; import type { + TourDayRow, TourListRow, TourProvenanceRow, ToursRepository, @@ -159,6 +160,11 @@ export class ToursService { } /** One saved tour plus the persisted routing controls needed by the editor. */ + /** The days every tour of the trip sits on, for the MCP read tools. */ + async listTourDays(tripId: number): Promise { + return await this.toursRepo.listTourDays(tripId); + } + async getTour(tripId: string, placeId: string): Promise { const tid = toRowId(tripId); const pid = toRowId(placeId); diff --git a/server/tests/unit/nest/tours.mcp.test.ts b/server/tests/unit/nest/tours.mcp.test.ts new file mode 100644 index 000000000..d0e1a19eb --- /dev/null +++ b/server/tests/unit/nest/tours.mcp.test.ts @@ -0,0 +1,114 @@ +/** + * ToursMcp (P2D-MCP-01): the saved tours of a trip, read only, over the same + * service the REST list and detail use. The suite takes its subject from the + * container over a snapshot test db; the addon gate of the tools is the + * registry's and is covered with the other addon-gated tools. + */ +import type { McpContext } from '../../../src/nest-mcp'; +import { ToursMcp } from '../../../src/nest/tours/tours.mcp'; +import { ToursModule } from '../../../src/nest/tours/tours.module'; +import { createSnapshotTestDb } from '../../helpers/db-mock'; +import { + addTripMember, + createDay, + createDayAssignment, + createPlace, + createTrip, + createUser, +} from '../../helpers/factories'; +import { resetTestDb } from '../../helpers/test-db'; +import { createTestModule, type TestModule } from '../../helpers/test-module'; +import { createTour } from '../../helpers/tours-repos'; + +import { afterAll, beforeAll, beforeEach, describe, expect, it } from 'vitest'; + +const db = createSnapshotTestDb(); +let t: TestModule; +let mcp: ToursMcp; + +beforeAll(async () => { + t = await createTestModule({ db, imports: [ToursModule] }); + mcp = t.get(ToursMcp); +}); +beforeEach(() => resetTestDb(db)); +afterAll(async () => { + await t.close(); + db.close(); +}); + +const ctx = (userId: number) => ({ userId }) as McpContext; + +/** The JSON a tool answered, or the error text it refused with. */ +function read(result: { content: Array<{ type: string; text: string }>; isError?: boolean }) { + const text = result.content[0]?.text ?? ''; + return result.isError ? { error: text } : JSON.parse(text); +} + +function fixture() { + const { user } = createUser(db); + const { user: outsider } = createUser(db); + const { user: member } = createUser(db); + const trip = createTrip(db, user.id); + addTripMember(db, trip.id, member.id); + const day1 = createDay(db, trip.id, { day_number: 1, date: '2026-07-10', title: 'Arrive' }); + const day2 = createDay(db, trip.id, { day_number: 2, date: '2026-07-11' }); + const ridge = createPlace(db, trip.id, { name: 'Ridge' }); + createTour(db, ridge.id, { distance: 8.2, max_hiking_difficulty: 3 }); + const gorge = createPlace(db, trip.id, { name: 'Gorge' }); + createTour(db, gorge.id); + const cafe = createPlace(db, trip.id, { name: 'Cafe' }); + createDayAssignment(db, day2.id, ridge.id); + createDayAssignment(db, day1.id, ridge.id); + createDayAssignment(db, day1.id, cafe.id); + return { user, outsider, member, trip, day1, day2, ridge, gorge }; +} + +describe('ToursMcp', () => { + it('TOURS-MCP-001: list_tours lists the tours of the trip with their days in day order, no other place', async () => { + const { user, trip, day1, day2, ridge, gorge } = fixture(); + const { tours } = read(await mcp.listTours({ tripId: trip.id }, ctx(user.id))); + expect(tours.map((tour: { place_id: number }) => tour.place_id).sort()).toEqual([ridge.id, gorge.id].sort()); + const listedRidge = tours.find((tour: { place_id: number }) => tour.place_id === ridge.id); + expect(listedRidge).toMatchObject({ name: 'Ridge', origin: expect.any(String), max_hiking_difficulty: 3 }); + expect(listedRidge.days).toEqual([ + { day_id: day1.id, day_number: 1, date: '2026-07-10', title: 'Arrive' }, + { day_id: day2.id, day_number: 2, date: '2026-07-11', title: null }, + ]); + expect(tours.find((tour: { place_id: number }) => tour.place_id === gorge.id).days).toEqual([]); + }); + + it('TOURS-MCP-002: get_tour_plan reads one tour with its control points, provenance and days, without the track', async () => { + const { member, trip, ridge } = fixture(); + const plan = read(await mcp.getTourPlan({ tripId: trip.id, placeId: ridge.id }, ctx(member.id))); + expect(plan.tour).toMatchObject({ place_id: ridge.id, name: 'Ridge' }); + expect(plan.tour.days).toHaveLength(2); + expect(Array.isArray(plan.waypoints)).toBe(true); + expect(plan.provenance).toMatchObject({ origin: expect.any(String), source: { kind: expect.any(String) } }); + expect(JSON.stringify(plan)).not.toContain('route_geometry'); + }); + + it('TOURS-MCP-003: find_tours_for_day lists only the tours on that day', async () => { + const { user, trip, day1, day2, ridge } = fixture(); + expect( + read(await mcp.findToursForDay({ tripId: trip.id, dayId: day1.id }, ctx(user.id))).tours.map( + (tour: { place_id: number }) => tour.place_id, + ), + ).toEqual([ridge.id]); + expect(read(await mcp.findToursForDay({ tripId: trip.id, dayId: day2.id + 1000 }, ctx(user.id))).tours).toEqual([]); + }); + + it('TOURS-MCP-004: someone outside the trip learns nothing, and a place that is no tour is not found', async () => { + const { user, outsider, trip, ridge } = fixture(); + for (const result of [ + await mcp.listTours({ tripId: trip.id }, ctx(outsider.id)), + await mcp.getTourPlan({ tripId: trip.id, placeId: ridge.id }, ctx(outsider.id)), + await mcp.findToursForDay({ tripId: trip.id, dayId: 1 }, ctx(outsider.id)), + ]) { + expect(read(result)).toEqual({ error: 'Trip not found or access denied.' }); + } + const cafe = createPlace(db, trip.id, { name: 'Plain' }); + expect(read(await mcp.getTourPlan({ tripId: trip.id, placeId: cafe.id }, ctx(user.id)))).toEqual({ + error: 'Tour not found.', + }); + }); +}); diff --git a/wiki/MCP-Addon-Tools.md b/wiki/MCP-Addon-Tools.md index 19c3fc2b2..d6e79f4f5 100644 --- a/wiki/MCP-Addon-Tools.md +++ b/wiki/MCP-Addon-Tools.md @@ -231,6 +231,16 @@ Each tool asks for the scope of what it writes, so the scopes differ per tool. T | `list_trip_document_sync_issues` | List the documents that need a person: conflicts, documents refused for their type or size, transfers that failed, and documents gone from the store. Returns `configured: false` for a trip that is not bound, and an empty list when everything is in step. | | `sync_trip_documents` | Run every binding of the trip now instead of waiting for the next scheduled check, and report how many documents came in, went out and are in conflict. Pass `full: true` to compare both sides in full. A binding whose owner left the trip, or whose store an admin switched off, is reported and not run. Safe to call repeatedly: a run already in progress is not started twice. | +### Tours _(Tours addon required)_ + +Read only: the saved tours of a trip, as the Tours list and the tour detail show them. Tours someone is still drawing in the planner are not saved yet and do not show here. + +| Tool | Description | Scope | +|---|---|---| +| `list_tours` | The saved tours of a trip: name, type, origin (`trek` for planned in TREK, `gpx` for a GPX import, `wanderer` for a wanderer snapshot), distance, ascent and descent, the stored routing limit on the SAC scale, and the days of the trip each tour is on | `trips:read` | +| `get_tour_plan` | One tour: its facts, its ordered control points, its provenance (where it came from, when, and whether it was changed in TREK since) and its days. The full track geometry is not included | `trips:read` | +| `find_tours_for_day` | The tours put on one day of a trip, with the same facts as `list_tours` | `trips:read` | + ### Road trip _(Road trip addon required)_ These tools work without an open browser. The external assistant chooses places based on the traveller's interests, uses the existing trip, day, place and assignment tools to save them, and recalculates to check the result. What each tool corresponds to in the planner is described on [Road-Trip](Road-Trip#mcp-tools).