mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-23 23:46:35 +00:00
8fd46c4c18
Small requests from the discussions, each built end to end: planner and map (cycling mode, date first headings, clear a day, stay times per day, off-route stops, map lock, compact unplanned markers, pending stays, cable car, transit quick picks), places (location filter, category from OSM, open in maps, geo: link, image from an attachment, enrichment on file import, places near a pin, place name language, Amap first in China), lists and costs (packed counter, inherited sharing, Markdown to-dos, payment notes, bookings CSV, my plan PDF), sharing (unplanned places, travel only, without photos), journey (photo zoom, drafts, photo order, trip suggestion, day jump), atlas (region counter, region bucket list, places per country), vacay half company holidays, studio copy and paste plus the book's own layouts, and RESTORE_FROM_BACKUP for a first start restore.
145 lines
7.7 KiB
TypeScript
145 lines
7.7 KiB
TypeScript
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, errorResult, ok, type McpContext } from '../../nest-mcp';
|
|
import { z } from 'zod';
|
|
import { POI_BBOX_TOOL_INPUT, POI_CATEGORY_KEYS, MAX_POI_CATEGORIES } from './maps.helpers';
|
|
import { MapsService } from './maps.service';
|
|
|
|
/**
|
|
* Geo MCP tools, moved 1:1 from the legacy registrar in
|
|
* src/mcp/tools/mapsWeather.ts when the maps domain went DI-native (the weather
|
|
* and airport tools stay there until their services migrate). Same names,
|
|
* descriptions, input schemas and result shapes; the legacy registration-time
|
|
* `canRead(scopes, 'geo')` gate became the declarative `access` marker ('geo'
|
|
* is a read-only scope group — there is no geo:write).
|
|
*/
|
|
@McpController()
|
|
export class MapsMcp {
|
|
constructor(private readonly maps: MapsService) {}
|
|
|
|
@Tool({
|
|
name: 'get_place_details',
|
|
description: 'Fetch detailed information about a place by its Google Place ID. The plain lookup returns name, address, coordinates, rating, opening hours, phone and website, and always answers with an empty review list. Set expand only when visitor reviews or the editorial summary are what was actually asked for: that field mask bills as a Google Enterprise SKU and costs the instance owner several times a plain lookup.',
|
|
inputSchema: {
|
|
placeId: z.string().describe('Google Place ID'),
|
|
lang: z.string().optional().default('en'),
|
|
expand: z.boolean().optional().default(false).describe('Also fetch up to five visitor reviews and the editorial summary. Billed as an Enterprise SKU, so leave it off unless the answer needs them'),
|
|
refresh: z.boolean().optional().default(false).describe('Bypass the cached expanded payload and re-fetch from the provider. Only does anything together with expand, and pays the expanded price again'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
access: { group: 'geo', mode: 'read' },
|
|
})
|
|
async getPlaceDetails(
|
|
{ placeId, lang, expand, refresh }: { placeId: string; lang?: string; expand?: boolean; refresh?: boolean },
|
|
ctx: McpContext,
|
|
) {
|
|
// The admin kill switch, checked before either path. An instance owner who
|
|
// turns Place Details off does it to stop Google billing them, and an
|
|
// assistant reaching the provider anyway would bill them from the one
|
|
// surface the switch does not cover. REST answers { place: null, disabled }
|
|
// rather than an error, so this does too, with a line the model can act on.
|
|
if (this.maps.detailsDisabled()) {
|
|
return ok({
|
|
details: null,
|
|
disabled: true,
|
|
note: 'Place Details is turned off on this instance by an administrator. Nothing was fetched.',
|
|
});
|
|
}
|
|
|
|
// Same split the REST route makes, and for the same reason: the expanded
|
|
// field mask is the expensive one, so nothing reaches it unless it was asked for.
|
|
const details = expand
|
|
? await this.maps.getPlaceDetailsExpanded(ctx.userId, placeId, lang ?? 'en', refresh ?? false)
|
|
: await this.maps.getPlaceDetails(ctx.userId, placeId, lang ?? 'en');
|
|
return ok({ details });
|
|
}
|
|
|
|
@Tool({
|
|
name: 'reverse_geocode',
|
|
description: 'Get a human-readable address for given coordinates.',
|
|
inputSchema: {
|
|
lat: z.number(),
|
|
lng: z.number(),
|
|
lang: z.string().optional().default('en'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
access: { group: 'geo', mode: 'read' },
|
|
})
|
|
async reverseGeocode({ lat, lng, lang }: { lat: number; lng: number; lang?: string }, _ctx: McpContext) {
|
|
const result = await this.maps.reverseGeocode(String(lat), String(lng), lang ?? 'en');
|
|
return ok(result);
|
|
}
|
|
|
|
@Tool({
|
|
name: 'resolve_maps_url',
|
|
description: 'Resolve a Google Maps or Amap (高德地图) share URL to coordinates and place name.',
|
|
inputSchema: {
|
|
url: z.string().describe('Google Maps or Amap share URL'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
access: { group: 'geo', mode: 'read' },
|
|
})
|
|
async resolveMapsUrl({ url }: { url: string }, _ctx: McpContext) {
|
|
const result = await this.maps.resolveGoogleMapsUrl(url);
|
|
return ok(result);
|
|
}
|
|
|
|
/** The MCP side of POST /api/maps/nearby (#976), with the same caps. */
|
|
@Tool({
|
|
name: 'search_nearby_places',
|
|
description: 'List named places of any kind around a coordinate, nearest first, each with its distance in metres (`distance_m`). No category and no name needed: use it for "what is at this spot", e.g. where a photo was taken. Answers from the TREK place index first, from Google Places only when the instance has a key and the index found nothing, and from OpenStreetMap otherwise; `source` says which. Prefer search_pois for one kind of place across a whole area, and search_place when the user named the place.',
|
|
inputSchema: {
|
|
lat: z.number().min(-90).max(90),
|
|
lng: z.number().min(-180).max(180),
|
|
radius: z.number().int().min(50).max(5000).optional().describe('Search radius in metres, 500 when left out'),
|
|
limit: z.number().int().min(1).max(20).optional().describe('How many places, 20 when left out'),
|
|
lang: z.string().max(35).optional().describe('Language for the place names, e.g. "de" or "ja"'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
access: { group: 'geo', mode: 'read' },
|
|
})
|
|
async searchNearbyPlaces(
|
|
{ lat, lng, radius, limit, lang }: { lat: number; lng: number; radius?: number; limit?: number; lang?: string },
|
|
ctx: McpContext,
|
|
) {
|
|
try {
|
|
return ok(await this.maps.nearbyPlaces(ctx.userId, lat, lng, { radius, limit, lang }));
|
|
} catch {
|
|
return errorResult('Nearby search failed.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The MCP side of GET /api/maps/pois, the "explore" pill on the trip map. Not
|
|
* part of the 1:1 move above: the route had no tool at all, which left the
|
|
* whole discover-what-is-here half of the map unreachable from a conversation.
|
|
*/
|
|
@Tool({
|
|
name: 'search_pois',
|
|
description: 'List points of interest of one category inside a map rectangle, with address, opening hours, website, phone and cuisine wherever the source carries them. Answers from the TREK place index where it can and from OpenStreetMap otherwise; each result names which one it came from in its `source` field. A charging station also reports its sockets: which plug families it has, how many of each and at what power, plus how many vehicles fit and whether it charges a fee. OSM states a socket type on roughly a third of them and a power on fewer, so treat a missing figure as unknown rather than as absent. This is the discovery tool: use it to answer "what is around here" for a neighbourhood or a whole city district. Prefer search_place when the user already named the place they mean. Never calls Google, so it costs nothing and works on an instance with no Places key.',
|
|
inputSchema: {
|
|
category: z.union([
|
|
z.enum(POI_CATEGORY_KEYS),
|
|
z.array(z.enum(POI_CATEGORY_KEYS)).min(1).max(MAX_POI_CATEGORIES),
|
|
]).describe('Which kind of place to look for. Pass several to cover them in one query rather than one request per kind'),
|
|
bbox: POI_BBOX_TOOL_INPUT,
|
|
lang: z.string().max(35).optional().describe('Language for the POI names, e.g. "de" or "ja". Falls back to the OSM international name and then the local one'),
|
|
},
|
|
annotations: TOOL_ANNOTATIONS_READONLY,
|
|
access: { group: 'geo', mode: 'read' },
|
|
})
|
|
async searchPois(
|
|
{ category, bbox, lang }: {
|
|
category: string | string[];
|
|
bbox: { south: number; west: number; north: number; east: number };
|
|
lang?: string;
|
|
},
|
|
_ctx: McpContext,
|
|
) {
|
|
try {
|
|
return ok(await this.maps.pois(Array.isArray(category) ? category.join(',') : category, bbox, lang));
|
|
} catch {
|
|
// Overpass is a set of public mirrors; an outage there is not a bad request.
|
|
return errorResult('POI search failed.');
|
|
}
|
|
}
|
|
}
|