diff --git a/CHANGELOG.md b/CHANGELOG.md index 6ef4b2e..d017a7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`ground_location_tool` now renders as a live Mapbox GL JS map** showing the reverse-geocoded origin marker + nearby POIs (numbered orange pins) + the isochrone polygons when present. Same dual-spec dispatch (MCP Apps + inline MCP-UI) and shared `renderGroundLocationAppHtml` template. - **`map_matching_tool` now renders as a live Mapbox GL JS map** showing the raw GPS trace as a dashed orange line and the snapped matched route as a solid blue line on top. Same dual-spec dispatch (MCP Apps + inline MCP-UI) and shared `renderMapMatchingAppHtml` template. - **`search_and_geocode_tool` and `category_search_tool` now render as a live Mapbox GL JS map** following the same dual-spec pattern as `directions_tool`/`isochrone_tool`/`optimization_tool`. Both tools share a single `SearchAppUIResource` (`ui://mapbox/search-app/index.html`) and one `renderSearchAppHtml` template that drops numbered pins on each result with name/category/address popups. - **`optimization_tool` now renders as a live Mapbox GL JS map** following the same dual-spec pattern as `directions_tool`/`isochrone_tool`: MCP Apps via `_meta.ui.resourceUri` → `OptimizationAppUIResource`, plus an inline MCP-UI rawHtml block (gated by `ENABLE_MCP_UI`). One shared `renderOptimizationAppHtml` template renders the trip line with numbered markers (1, 2, 3, …) at each stop in the visit order. diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 9791bfe..6da6c31 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -10,6 +10,7 @@ import { IsochroneAppUIResource } from './ui-apps/IsochroneAppUIResource.js'; import { OptimizationAppUIResource } from './ui-apps/OptimizationAppUIResource.js'; import { SearchAppUIResource } from './ui-apps/SearchAppUIResource.js'; import { MapMatchingAppUIResource } from './ui-apps/MapMatchingAppUIResource.js'; +import { GroundLocationAppUIResource } from './ui-apps/GroundLocationAppUIResource.js'; import { VersionResource } from './version/VersionResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -24,6 +25,7 @@ export const ALL_RESOURCES = [ new OptimizationAppUIResource({ httpRequest }), new SearchAppUIResource({ httpRequest }), new MapMatchingAppUIResource({ httpRequest }), + new GroundLocationAppUIResource({ httpRequest }), new VersionResource() ] as const; diff --git a/src/resources/ui-apps/GroundLocationAppUIResource.ts b/src/resources/ui-apps/GroundLocationAppUIResource.ts new file mode 100644 index 0000000..b39b909 --- /dev/null +++ b/src/resources/ui-apps/GroundLocationAppUIResource.ts @@ -0,0 +1,79 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { RequestHandlerExtra } from '@modelcontextprotocol/sdk/shared/protocol.js'; +import type { + ReadResourceResult, + ServerNotification, + ServerRequest +} from '@modelcontextprotocol/sdk/types.js'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; +import { BaseResource } from '../BaseResource.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; +import { renderGroundLocationAppHtml } from './groundLocationAppHtml.js'; + +export class GroundLocationAppUIResource extends BaseResource { + readonly name = 'Ground Location App UI'; + readonly uri = 'ui://mapbox/ground-location-app/index.html'; + readonly description = + 'Interactive UI for visualizing a grounded location and nearby POIs (MCP Apps)'; + readonly mimeType = RESOURCE_MIME_TYPE; + + private readonly httpRequest: HttpRequest; + private readonly apiEndpoint: () => string; + + constructor(params: { + httpRequest: HttpRequest; + apiEndpoint?: () => string; + }) { + super(); + this.httpRequest = params.httpRequest; + this.apiEndpoint = + params.apiEndpoint ?? + (() => process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'); + } + + async read( + _uri: string, + extra?: RequestHandlerExtra + ): Promise { + const accessToken = + (extra?.authInfo?.token as string | undefined) || + process.env.MAPBOX_ACCESS_TOKEN || + ''; + + const publicToken = await resolveMapboxPublicToken({ + accessToken, + apiEndpoint: this.apiEndpoint(), + httpRequest: this.httpRequest + }); + + const html = renderGroundLocationAppHtml({ + publicToken: publicToken ?? '' + }); + + return { + contents: [ + { + uri: this.uri, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: { + ui: { + csp: { + connectDomains: [ + 'https://*.mapbox.com', + 'https://events.mapbox.com' + ], + resourceDomains: ['https://api.mapbox.com'], + workerDomains: ['blob:'] + }, + preferredSize: { width: 1000, height: 600 } + } + } + } + ] + }; + } +} diff --git a/src/resources/ui-apps/groundLocationAppHtml.ts b/src/resources/ui-apps/groundLocationAppHtml.ts new file mode 100644 index 0000000..21ac3ee --- /dev/null +++ b/src/resources/ui-apps/groundLocationAppHtml.ts @@ -0,0 +1,374 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Render the ground-location MCP App HTML — used by both the MCP Apps resource + * and the inline MCP-UI rawHtml block emitted by `ground_location_tool`. + */ + +export const MAPBOX_GL_VERSION = '3.12.0'; + +export interface GroundLocationAppInitialData { + origin: { + longitude: number; + latitude: number; + name: string; + address?: string; + }; + pois?: Array<{ + index: number; + name: string; + address?: string; + category?: string; + location: [number, number]; + }>; + summary?: string; +} + +export function renderGroundLocationAppHtml(params: { + publicToken: string; + glVersion?: string; + initialData?: GroundLocationAppInitialData; +}): string { + const { publicToken, initialData } = params; + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + + const initialDataScript = initialData + ? `` + : ''; + + return ` + + + + +Ground Location + + + + + +
+ +
Loading location…
+ +${initialDataScript} + + + +`; +} + +function escapeForScript(s: string): string { + return s.replace(/<\/script>/gi, '<\\/script>'); +} + +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; +import type { HttpRequest } from '../../utils/types.js'; + +interface PoiLike { + name?: string; + address?: string; + category?: string; + longitude?: number; + latitude?: number; +} + +export async function tryRenderGroundLocationInlineHtml(params: { + place: string; + full_address?: string; + longitude: number; + latitude: number; + nearby_pois?: PoiLike[]; + accessToken: string; + apiEndpoint: string; + httpRequest: HttpRequest; +}): Promise { + const { + place, + full_address, + longitude, + latitude, + nearby_pois, + accessToken, + apiEndpoint, + httpRequest + } = params; + + const publicToken = await resolveMapboxPublicToken({ + accessToken, + apiEndpoint, + httpRequest + }); + if (!publicToken) return undefined; + + const pois = (nearby_pois ?? []) + .filter( + (p): p is PoiLike & { longitude: number; latitude: number } => + typeof p?.longitude === 'number' && typeof p?.latitude === 'number' + ) + .map((p, idx) => ({ + index: idx + 1, + name: p.name ?? 'Place', + address: p.address, + category: p.category, + location: [p.longitude, p.latitude] as [number, number] + })); + + return renderGroundLocationAppHtml({ + publicToken, + initialData: { + origin: { longitude, latitude, name: place, address: full_address }, + pois, + summary: pois.length > 0 ? `${place} — ${pois.length} nearby` : place + } + }); +} diff --git a/src/tools/ground-location-tool/GroundLocationTool.ts b/src/tools/ground-location-tool/GroundLocationTool.ts index 0d50bcb..2b77b1d 100644 --- a/src/tools/ground-location-tool/GroundLocationTool.ts +++ b/src/tools/ground-location-tool/GroundLocationTool.ts @@ -1,7 +1,9 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +import { randomUUID } from 'node:crypto'; import type { z } from 'zod'; +import { createUIResource } from '@mcp-ui/server'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { HttpRequest } from '../../utils/types.js'; @@ -10,6 +12,8 @@ import { GroundLocationOutputSchema, type GroundLocationOutput } from './GroundLocationTool.output.schema.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { tryRenderGroundLocationInlineHtml } from '../../resources/ui-apps/groundLocationAppHtml.js'; // Minimal types for API responses we care about interface GeocodingFeature { @@ -77,6 +81,15 @@ export class GroundLocationTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/ground-location-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; constructor(params: { httpRequest: HttpRequest }) { super({ @@ -271,8 +284,37 @@ export class GroundLocationTool extends MapboxApiBasedTool< const validated = GroundLocationOutputSchema.safeParse(result); const output = validated.success ? validated.data : result; + const content: CallToolResult['content'] = [ + { type: 'text', text: this.formatOutput(output) } + ]; + + if (isMcpUiEnabled()) { + const inlineHtml = await tryRenderGroundLocationInlineHtml({ + place: output.place, + full_address: output.full_address, + longitude: output.longitude, + latitude: output.latitude, + nearby_pois: output.nearby_pois, + accessToken, + apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint, + httpRequest: this.httpRequest + }); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/ground-location/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } + } + return { - content: [{ type: 'text', text: this.formatOutput(output) }], + content, structuredContent: output as unknown as Record, isError: false };