Tours: read the saved tours of a trip over MCP (P2D-MCP-01)

This commit is contained in:
Maurice
2026-10-15 22:08:39 +02:00
parent cd1b1410bd
commit 356df90095
6 changed files with 262 additions and 2 deletions
+30 -1
View File
@@ -74,7 +74,16 @@ export type TourUpdate = Omit<TourInsert, 'place_id' | 'planned_duration_minutes
break_additional_minutes?: number | null;
};
type ToursKyselyDB = Pick<DB, 'tours' | 'places' | 'day_assignments' | 'tour_waypoints' | 'tour_sources'>;
type ToursKyselyDB = Pick<DB, 'tours' | 'places' | 'day_assignments' | 'tour_waypoints' | 'tour_sources' | 'days'>;
/** 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<Tours> {
.execute();
}
/** The days every tour of a trip sits on, each tour and day once, in day order. */
async listTourDays(trip_id: number): Promise<TourDayRow[]> {
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<boolean> {
const row = await this.db()
+98
View File
@@ -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<TourDayRow, 'day_id' | 'day_number' | 'date' | 'title'>;
/** 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<number, TourDay[]>) {
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<Map<number, TourDay[]>> {
const byTour = new Map<number, TourDay[]>();
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)) });
}
}
+4 -1
View File
@@ -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 {}
+6
View File
@@ -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<TourDayRow[]> {
return await this.toursRepo.listTourDays(tripId);
}
async getTour(tripId: string, placeId: string): Promise<TourDetailResponse> {
const tid = toRowId(tripId);
const pid = toRowId(placeId);