feat(plugins): let a plugin answer place searches from its own index

TREK searches its own place index and OpenStreetMap. Neither carries a rating,
so "the best rated hotel around here" has never been answerable, and an operator
with a better index for their region had no way to reach the search box at all.
The searchProvider hook is that way in: a plugin is handed the query, a limit,
the language and the coordinate the search is biased toward, and answers with
places in the same shape the core search returns, plus a rating.

It sits beside the core search rather than inside it. The plugin runtime already
imports PlacesModule, which imports MapsModule, so asking the providers from the
maps service would close a module cycle — the same reason the other fourteen
contribution controllers stand next to what they contribute to. The client asks
both at once and merges, at the single point every caller already goes through,
so all nine search surfaces get plugin hits without knowing the hook exists.

A plugin index may never hold up the list: the client stops waiting at two and a
half seconds and the host gives a provider two, which is also why the budget is
the shortest of any hook here. With no such plugin installed the route answers
without reaching the runtime at all.

Every hit is normalized host-side and shared by the REST route and the MCP tool,
so a cap that holds for the browser holds for the assistant: coordinates are
range-checked, strings capped, the rating clamped to 0..5, a website must be
http or https, and ids are namespaced so they can never collide with an OSM one.

search_places_via_plugins is the MCP half, because what the user can see the
assistant has to be able to see; search_place now points at it.
This commit is contained in:
Maurice
2026-09-10 19:33:55 +02:00
committed by Maurice
parent a00cc889ed
commit a9907e2f10
51 changed files with 971 additions and 12 deletions
+1 -1
View File
@@ -292,7 +292,7 @@ export class PlacesMcp {
@Tool({
name: 'search_place',
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent.',
description: 'Search for a real-world place by name or address. Returns results with osm_id (and google_place_id/google_ftid if configured). Use these IDs when calling create_place so the app can display opening hours, ratings, and map links. Pass locationBias whenever the trip has a destination: a bare name like "Central Station" or "Museum of Modern Art" otherwise resolves wherever the provider guesses, which is regularly the wrong continent. Searches the TREK index and OpenStreetMap only: if the instance has a plugin providing its own search index, call search_places_via_plugins as well, and note that ratings can only come from there.',
inputSchema: {
query: z.string().min(1).max(500).describe('Place name or address to search for'),
locationBias: mapsSearchRequestSchema.shape.locationBias.describe('Centre the search on a coordinate: { lat, lng, radius? } with radius in metres (default 50000). Only the Google provider honours it; the OpenStreetMap fallback ignores it'),
@@ -3,8 +3,10 @@ import { PluginsRuntimeModule } from '../plugins-runtime.module';
import { AddonsModule } from '../../addons/addons.module';
import { JourneyDomainModule } from '../../journey/journey-domain.module';
import { PlaceDetailsController } from './place-details.controller';
import { PluginSearchController } from './plugin-search.controller';
import { TripWarningsController } from './trip-warnings.controller';
import { TripWarningsMcp } from './trip-warnings.mcp';
import { PluginSearchMcp } from './plugin-search.mcp';
import { PluginMcpToolsService } from './plugin-mcp-tools.service';
import { ViewContributionsController } from './view-contributions.controller';
import { TripCardContributionsController } from './trip-card-contributions.controller';
@@ -21,8 +23,9 @@ import { JournalEntryRowsController } from './journal-entry-rows.controller';
/**
* The read-only surface plugins contribute to the app: photos, calendar events,
* place details, trip warnings, table columns, map markers and layers, routes, day
* schedules and tints, PDF sections, atlas layers, journal entry rows, trip cards.
* place details, search results, trip warnings, table columns, map markers and
* layers, routes, day schedules and tints, PDF sections, atlas layers, journal entry
* rows, trip cards.
*
* Every one of these is the same shape — fan out over `providersOf(hook)`, call the
* hook through `PluginHooks`, normalize and cap what comes back, skip a provider that
@@ -34,6 +37,7 @@ import { JournalEntryRowsController } from './journal-entry-rows.controller';
imports: [PluginsRuntimeModule, AddonsModule, JourneyDomainModule],
controllers: [
PlaceDetailsController,
PluginSearchController,
TripWarningsController,
ViewContributionsController,
TripCardContributionsController,
@@ -55,6 +59,6 @@ import { JournalEntryRowsController } from './journal-entry-rows.controller';
// PluginMcpToolsService owns the process-level tool source. It lives here, and
// not on PluginRuntimeService beside the other sinks, because it needs
// PluginHooks and PluginHooks injects PluginRuntimeService.
providers: [TripWarningsMcp, PluginMcpToolsService],
providers: [TripWarningsMcp, PluginSearchMcp, PluginMcpToolsService],
})
export class PluginContributionsModule {}
@@ -0,0 +1,75 @@
import { Controller, Get, Query, Req, UseGuards } from '@nestjs/common';
import type { Request } from 'express';
import { JwtAuthGuard } from '../../auth/jwt-auth.guard';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
import {
interleave,
limitFrom,
MAX_QUERY,
nearFrom,
normalizeSearchHits,
type SearchHit,
} from './plugin-search.helpers';
/**
* GET /api/plugin-search — places found by plugins implementing `searchProvider` (#2221).
*
* A search index TREK does not ship. The core search asks the TREK index and
* OpenStreetMap; this asks whatever else the operator installed, and the client draws
* one list out of both. Additive and fail-safe like the other provider-hook
* controllers: a provider that errors or times out contributes nothing and the search
* is drawn without it.
*
* NOT trip-scoped, unlike its neighbours here. Searching for a place is not a read of
* anybody's trip: the query is the caller's own words and the answer is a public index,
* so the gate is being signed in. Nothing about a trip is passed to the plugin, which
* is also what keeps a search provider from becoming a way to enumerate one.
*
* A route of its own beside `POST /api/maps/search` rather than a branch inside it,
* for the reason `trip-warnings.mcp.ts` documents next door: the plugin runtime already
* imports PlacesModule, which imports MapsModule, so asking the providers from inside
* the maps service would close a module cycle. The client calls both and merges.
*/
@Controller('api/plugin-search')
@UseGuards(JwtAuthGuard)
export class PluginSearchController {
constructor(private readonly hooks: PluginHooks) {}
@Get()
async search(
@Query('q') q: string | undefined,
@Query('lat') lat: string | undefined,
@Query('lng') lng: string | undefined,
@Query('lang') lang: string | undefined,
@Query('limit') limit: string | undefined,
@Req() req: Request & { user?: { id: number } },
): Promise<{ places: SearchHit[] }> {
if (!pluginsEnabled()) return { places: [] };
const userId = req.user?.id;
const query = String(q ?? '').trim().slice(0, MAX_QUERY);
// An empty query is not an error here: the client fires this beside every search,
// and a blank box simply has nothing to ask a provider about.
if (!query || userId == null) return { places: [] };
const ids = this.hooks.providersOf('searchProvider');
if (ids.length === 0) return { places: [] };
const request = {
query,
limit: limitFrom(limit),
lang: lang ? String(lang).slice(0, 20) : undefined,
near: nearFrom(lat, lng),
};
const results = await Promise.all(
ids.map(async (id): Promise<SearchHit[]> => {
try {
return normalizeSearchHits(id, await this.hooks.searchPlaces(id, request, userId));
} catch {
return []; // a slow / failing provider is skipped, never fatal
}
}),
);
return { places: interleave(results) };
}
}
@@ -0,0 +1,135 @@
import { stripEmoji } from '../text-sanitize';
/**
* Turning what a `searchProvider` plugin answers into rows TREK can show (#2221).
*
* Shared by the REST route and the MCP tool rather than written twice: the two are the
* same question asked by a browser and by an assistant, and a cap or a url check that
* held on one path but not the other would be a hole with a second door.
*
* Every hit is normalized into the record shape the core search returns (`toPlaceRecord`
* in maps/trek-places.client.ts), because the callers read `name`, `lat`, `lng` and
* `address` off whatever the list holds and must not need to know which index a row
* came from.
*/
export interface SearchHit {
osm_id: string;
name: string;
address: string;
lat: number;
lng: number;
/** What a plugin index can answer that open data cannot. Null when it does not. */
rating: number | null;
website: string | null;
phone: string | null;
category: string | null;
description: string | null;
/** `plugin:<id>`, so a row can name the index it came from. */
source: string;
pluginId: string;
}
/** Per provider. Bounds one plugin's share of a list a person reads. */
export const MAX_HITS = 20;
/** The most one provider may be asked for, whatever the caller puts in the query. */
export const MAX_LIMIT = 20;
export const DEFAULT_LIMIT = 10;
/** Long enough for a full address line, short enough that no row can flood the list. */
export const MAX_QUERY = 200;
const cap = (v: unknown, n: number): string => stripEmoji(String(v ?? '')).slice(0, n);
const capOrNull = (v: unknown, n: number): string | null => {
if (v == null) return null;
const s = cap(v, n);
return s === '' ? null : s;
};
/**
* A url that may become an `<a href>`. Anything but http/https is dropped — a
* `javascript:` or `data:` url rendered as a link is click-XSS into the search list.
*/
function safeUrl(raw: unknown): string | null {
if (typeof raw !== 'string' || raw === '') return null;
try {
const u = new URL(raw);
return u.protocol === 'http:' || u.protocol === 'https:' ? raw.slice(0, 2048) : null;
} catch {
return null;
}
}
/**
* A rating a plugin index carries, on the five-point scale every such index uses.
*
* Clamped rather than rejected: a provider answering on a ten-point scale is a bug in
* that provider, and dropping the whole hit over it would lose the place as well.
*/
function safeRating(raw: unknown): number | null {
const n = Number(raw);
if (!Number.isFinite(n)) return null;
return Math.min(5, Math.max(0, Math.round(n * 10) / 10));
}
export function normalizeSearchHits(pluginId: string, raw: unknown): SearchHit[] {
const list = Array.isArray(raw) ? (raw as Array<Record<string, unknown>>) : [];
const out: SearchHit[] = [];
for (const h of list) {
if (out.length >= MAX_HITS) break;
if (!h || typeof h !== 'object') continue;
const lat = Number(h.lat);
const lng = Number(h.lng);
const name = cap(h.name, 200);
// A hit with no name or no place on the earth cannot be shown or picked.
if (!name || !Number.isFinite(lat) || !Number.isFinite(lng)) continue;
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) continue;
const id = cap(h.id, 100);
out.push({
// Namespaced so a plugin id can never collide with a real OSM id or a `gers:`
// one from the index. Falls back to the coordinate when the plugin has no stable
// id of its own, which still tells two rows apart.
osm_id: `plugin:${pluginId}:${id || `${lat},${lng}`}`,
name,
address: cap(h.address, 300),
lat,
lng,
rating: safeRating(h.rating),
website: safeUrl(h.website),
phone: capOrNull(h.phone, 60),
category: capOrNull(h.category, 60),
description: capOrNull(h.description, 500),
source: `plugin:${pluginId}`,
pluginId,
});
}
return out;
}
/** The bias, when the caller supplied a real coordinate rather than two strings. */
export function nearFrom(latRaw: unknown, lngRaw: unknown): { lat: number; lng: number } | undefined {
const lat = Number(latRaw);
const lng = Number(lngRaw);
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return undefined;
if (lat < -90 || lat > 90 || lng < -180 || lng > 180) return undefined;
return { lat, lng };
}
export function limitFrom(raw: unknown): number {
const asked = Number(raw);
return Number.isFinite(asked) && asked > 0 ? Math.min(MAX_LIMIT, Math.trunc(asked)) : DEFAULT_LIMIT;
}
/**
* One list out of several providers, taking a row from each in turn.
*
* Interleaved rather than concatenated: with two indexes installed, a plain
* concatenation buries the second one's best hit under twenty of the first one's.
*/
export function interleave(results: SearchHit[][]): SearchHit[] {
const out: SearchHit[] = [];
for (let i = 0; ; i += 1) {
const row = results.filter(r => r.length > i);
if (row.length === 0) return out;
for (const r of row) out.push(r[i]);
}
}
@@ -0,0 +1,77 @@
import { z } from 'zod';
import { McpController, Tool, TOOL_ANNOTATIONS_READONLY, ok, type McpContext, type McpTextResult } from '../../../nest-mcp';
import { pluginsEnabled } from '../kill-switch';
import { PluginHooks } from '../plugin-hooks.service';
import {
interleave,
limitFrom,
MAX_QUERY,
nearFrom,
normalizeSearchHits,
type SearchHit,
} from './plugin-search.helpers';
/**
* The MCP half of GET /api/plugin-search (#2221).
*
* The browser merges the plugin hits into its own search list, so a person searching
* for a hotel sees the installed index alongside OpenStreetMap without asking. An
* assistant calling `search_place` saw only TREK's own indexes, which is the same
* asymmetry `get_trip_warnings` exists to close: what the user can see, the assistant
* has to be able to see.
*
* A tool of its own rather than a section of `search_place`, for the reason spelled out
* in trip-warnings.mcp.ts: `search_place` lives in PlacesModule, the plugin runtime
* already imports PlacesModule, and reaching the hooks from there would close a module
* cycle. It is also a different cost — an IPC round trip into every installed provider
* with a four-second budget, against a synchronous call into the maps service.
*/
@McpController()
export class PluginSearchMcp {
constructor(private readonly hooks: PluginHooks) {}
@Tool({
name: 'search_places_via_plugins',
description: 'Search for a place in the search indexes installed plugins provide, which are the ones TREK does not ship itself. Use it alongside search_place, never instead of it: search_place is TREK\'s own index and OpenStreetMap, this is whatever else the instance owner installed, and only these results can carry a rating — open data has none, so a question like "the best rated hotel near here" can only be answered from this list. Results have the same shape search_place returns, plus a `rating` and the `pluginId` that found them. Returns an empty list when no plugin provides a search index, which is the normal case.',
inputSchema: {
query: z.string().min(1).max(MAX_QUERY).describe('Place name or address to search for'),
near: z.object({ lat: z.number(), lng: z.number() })
.optional()
.describe('Centre the search on a coordinate. Pass it whenever the trip has a destination: a bare name like "Central Station" otherwise resolves wherever the provider guesses'),
lang: z.string().max(20).optional().describe('BCP 47 language for the result names, e.g. "de" or "ja"'),
limit: z.number().int().positive().max(20).optional().describe('How many results per provider (default 10, capped at 20)'),
},
annotations: TOOL_ANNOTATIONS_READONLY,
access: { group: 'places', mode: 'read' },
})
async searchViaPlugins(
{ query, near, lang, limit }: {
query: string; near?: { lat: number; lng: number }; lang?: string; limit?: number;
},
ctx: McpContext,
): Promise<McpTextResult> {
if (!pluginsEnabled()) return ok({ places: [] });
const ids = this.hooks.providersOf('searchProvider');
if (ids.length === 0) return ok({ places: [] });
// Built through the same helpers the REST route uses, so a cap that holds for the
// browser holds for the assistant. `near` arrives typed here and still goes through
// nearFrom, because a schema that accepts any number still accepts latitude 91.
const request = {
query: query.trim().slice(0, MAX_QUERY),
limit: limitFrom(limit),
lang,
near: nearFrom(near?.lat, near?.lng),
};
const results = await Promise.all(
ids.map(async (id): Promise<SearchHit[]> => {
try {
return normalizeSearchHits(id, await this.hooks.searchPlaces(id, request, ctx.userId));
} catch {
return []; // a slow or failing provider contributes nothing, it does not fail the tool
}
}),
);
return ok({ places: interleave(results) });
}
}
@@ -18,6 +18,21 @@ export interface HookMcpToolCall {
args: unknown;
}
/**
* What a search provider is asked to look for.
*
* `near` is the same bias the core search uses, and it is what decides which of the
* four places called "Hase-dera" the answer is about. Optional, because a search from
* a trip with no places yet has nothing to bias toward.
*/
export interface HookSearchRequest {
query: string;
limit: number;
/** The caller's language tag, for providers that return localized names. */
lang?: string;
near?: { lat: number; lng: number };
}
/** The waypoint request a route provider is asked to solve. */
export interface HookRouteRequest {
tripId: number;
@@ -29,7 +44,7 @@ export interface HookRouteRequest {
/**
* Every host-to-plugin hook call, in one place.
*
* The 15 hooks the consent screen offers used to be invoked straight from the
* The 16 hooks the consent screen offers used to be invoked straight from the
* controllers, with the fn name and the timeout written out at each call site. That
* made three things impossible to check: that a granted `hook:*` permission actually
* has a consumer (a dead grant on the consent screen looks exactly like a live one),
@@ -81,6 +96,20 @@ export class PluginHooks {
return this.runtime.invokeHook(pluginId, 'placeDetailProvider', 'getDetails', [placeId], userId, 5000);
}
/**
* The shortest leash of any hook here, because a person is waiting on a list.
*
* The core search and this one run side by side, so the wait is the slower of the
* two rather than their sum, and the client stops waiting at two and a half seconds
* whatever happens. Two seconds is therefore the whole of what a provider can
* usefully spend: past it the list is already drawn and its answer is discarded, so
* a longer budget would only hold an IPC call open for nothing.
*/
@PluginHook('searchProvider', { permission: 'hook:search-provider', fn: 'search', timeoutMs: 2000 })
searchPlaces(pluginId: string, request: HookSearchRequest, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'searchProvider', 'search', [request], userId, 2000);
}
@PluginHook('warningProvider', { permission: 'hook:trip-warning-provider', fn: 'getWarnings', timeoutMs: 5000 })
tripWarnings(pluginId: string, tripId: number, userId: number): Promise<unknown> {
return this.runtime.invokeHook(pluginId, 'warningProvider', 'getWarnings', [tripId], userId, 5000);
+1 -1
View File
@@ -20,7 +20,7 @@ import { AppConfigModule } from '../app-config/app-config.module';
*
* - `PluginsRuntimeModule` — supervisor, capability router, hook contracts. The half
* with the domain imports, and the only half AdminModule needs.
* - `PluginContributionsModule` — the 14 read-only hook controllers.
* - `PluginContributionsModule` — the 15 read-only hook controllers.
* - `PluginOAuthModule` — the outbound-OAuth leaf.
*
* What is left here is the CRUD and delivery surface: install/activate/configure, the
@@ -358,6 +358,7 @@ export const KNOWN_PERMISSIONS = [
'hook:photo-provider',
'hook:calendar-source',
'hook:place-detail-provider',
'hook:search-provider',
'hook:trip-warning-provider',
'hook:table-contributor',
'hook:map-marker-provider',
@@ -430,6 +431,7 @@ export const HOOK_PERMISSION = {
photoProvider: 'hook:photo-provider',
calendarSource: 'hook:calendar-source',
placeDetailProvider: 'hook:place-detail-provider',
searchProvider: 'hook:search-provider',
warningProvider: 'hook:trip-warning-provider',
tableContributor: 'hook:table-contributor',
mapMarkerProvider: 'hook:map-marker-provider',
@@ -414,6 +414,39 @@ export interface PlaceDetailItem { label: string; value?: string; url?: string;
export interface PlaceDetailProvider {
getDetails(placeId: number, ctx: PluginContext): Promise<PlaceDetailItem[]>;
}
/**
* A place a search provider found, in the shape the host turns into a search row.
*
* `rating` is the one field open data cannot answer, and the reason this hook exists:
* OpenStreetMap carries no ratings at all, so "the best one around here" needs an
* index that has them. Zero to five, the scale every such index uses.
*/
export interface SearchResultPlace {
/** Stable id in your own index. The host namespaces it as `plugin:<yourId>:<id>`. */
id?: string;
name: string;
lat: number;
lng: number;
address?: string;
rating?: number;
website?: string;
phone?: string;
category?: string;
description?: string;
}
/** What the host asks a search provider to look for. */
export interface SearchRequest {
query: string;
/** The most rows worth returning. The host caps it at 20 whatever it is asked for. */
limit: number;
/** The caller's language tag, for indexes that carry localized names. */
lang?: string;
/** Where the person is looking, when there is somewhere to bias toward. */
near?: { lat: number; lng: number };
}
export interface SearchProvider {
search(request: SearchRequest, ctx: PluginContext): Promise<SearchResultPlace[]>;
}
/** A validation/warning a plugin raises on a trip; TREK surfaces it in the planner. */
export interface TripWarning { level: 'info' | 'warning' | 'error'; message: string; dayId?: number; placeId?: number; }
export interface WarningProvider {
@@ -721,6 +754,7 @@ export interface PluginDefinition {
photoProvider?: PhotoProvider;
calendarSource?: CalendarSource;
placeDetailProvider?: PlaceDetailProvider;
searchProvider?: SearchProvider;
warningProvider?: WarningProvider;
tableContributor?: TableContributor;
mapMarkerProvider?: MapMarkerProvider;