diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f2eabb..6ef4b2ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`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. - **`isochrone_tool` now renders as a live Mapbox GL JS map** following the same dual-spec pattern as `directions_tool`: MCP Apps via `_meta.ui.resourceUri` → `IsochroneAppUIResource`, plus an inline MCP-UI rawHtml block (gated by `ENABLE_MCP_UI`). One shared `renderIsochroneAppHtml` template renders the contours as translucent fill + outline layers with the origin marked. diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index fafbe17a..9791bfef 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -9,6 +9,7 @@ import { DirectionsAppUIResource } from './ui-apps/DirectionsAppUIResource.js'; 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 { VersionResource } from './version/VersionResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -22,6 +23,7 @@ export const ALL_RESOURCES = [ new IsochroneAppUIResource({ httpRequest }), new OptimizationAppUIResource({ httpRequest }), new SearchAppUIResource({ httpRequest }), + new MapMatchingAppUIResource({ httpRequest }), new VersionResource() ] as const; diff --git a/src/resources/ui-apps/MapMatchingAppUIResource.ts b/src/resources/ui-apps/MapMatchingAppUIResource.ts new file mode 100644 index 00000000..35b7525a --- /dev/null +++ b/src/resources/ui-apps/MapMatchingAppUIResource.ts @@ -0,0 +1,77 @@ +// 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 { renderMapMatchingAppHtml } from './mapMatchingAppHtml.js'; + +export class MapMatchingAppUIResource extends BaseResource { + readonly name = 'Map Matching App UI'; + readonly uri = 'ui://mapbox/map-matching-app/index.html'; + readonly description = + 'Interactive UI for visualizing raw GPS traces snapped to the road network (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 = renderMapMatchingAppHtml({ 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/mapMatchingAppHtml.ts b/src/resources/ui-apps/mapMatchingAppHtml.ts new file mode 100644 index 00000000..cff526aa --- /dev/null +++ b/src/resources/ui-apps/mapMatchingAppHtml.ts @@ -0,0 +1,356 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Render the map-matching MCP App HTML — used by both the MCP Apps resource + * and the inline MCP-UI rawHtml block emitted by `map_matching_tool`. + */ + +export const MAPBOX_GL_VERSION = '3.12.0'; + +export interface MapMatchingAppInitialData { + raw_trace: { type: 'LineString'; coordinates: [number, number][] }; + matched_geometry: unknown; // GeoJSON LineString OR polyline string + summary?: string; +} + +export function renderMapMatchingAppHtml(params: { + publicToken: string; + glVersion?: string; + initialData?: MapMatchingAppInitialData; +}): string { + const { publicToken, initialData } = params; + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + + const initialDataScript = initialData + ? `` + : ''; + + return ` + + + + +Map Matching Preview + + + + + +
+ +
+
Raw GPS trace
+
Matched route
+
+
Loading map matching…
+ +${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'; + +export async function tryRenderMapMatchingInlineHtml(params: { + matching: { + geometry?: unknown; + distance?: number; + duration?: number; + confidence?: number; + }; + rawTrace: Array<{ longitude: number; latitude: number }>; + accessToken: string; + apiEndpoint: string; + httpRequest: HttpRequest; +}): Promise { + const { matching, rawTrace, accessToken, apiEndpoint, httpRequest } = params; + if (!matching?.geometry || rawTrace.length < 2) return undefined; + + const publicToken = await resolveMapboxPublicToken({ + accessToken, + apiEndpoint, + httpRequest + }); + if (!publicToken) return undefined; + + const parts: string[] = []; + if (typeof matching.distance === 'number') + parts.push(`${(matching.distance / 1609.34).toFixed(2)} mi`); + if (typeof matching.duration === 'number') + parts.push(`${Math.round(matching.duration / 60)} min`); + const conf = + typeof matching.confidence === 'number' + ? ` (confidence ${(matching.confidence * 100).toFixed(0)}%)` + : ''; + const summary = `Matched trace: ${parts.length ? parts.join(', ') : 'unknown'}${conf}`; + + return renderMapMatchingAppHtml({ + publicToken, + initialData: { + raw_trace: { + type: 'LineString', + coordinates: rawTrace.map( + (p) => [p.longitude, p.latitude] as [number, number] + ) + }, + matched_geometry: matching.geometry, + summary + } + }); +} diff --git a/src/tools/map-matching-tool/MapMatchingTool.ts b/src/tools/map-matching-tool/MapMatchingTool.ts index 4760d9b9..94e454b9 100644 --- a/src/tools/map-matching-tool/MapMatchingTool.ts +++ b/src/tools/map-matching-tool/MapMatchingTool.ts @@ -2,7 +2,9 @@ // Licensed under the MIT License. import { URLSearchParams } from 'node:url'; +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 { MapMatchingInputSchema } from './MapMatchingTool.input.schema.js'; @@ -11,6 +13,8 @@ import { type MapMatchingOutput } from './MapMatchingTool.output.schema.js'; import type { HttpRequest } from '../../utils/types.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { tryRenderMapMatchingInlineHtml } from '../../resources/ui-apps/mapMatchingAppHtml.js'; // Docs: https://docs.mapbox.com/api/navigation/map-matching/ @@ -32,6 +36,15 @@ export class MapMatchingTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/map-matching-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; constructor(params: { httpRequest: HttpRequest }) { super({ @@ -120,28 +133,57 @@ export class MapMatchingTool extends MapboxApiBasedTool< const data = (await response.json()) as MapMatchingOutput; // Validate the response against our output schema + let validatedData: MapMatchingOutput = data; try { - const validatedData = MapMatchingOutputSchema.parse(data); - - return { - content: [ - { type: 'text', text: JSON.stringify(validatedData, null, 2) } - ], - structuredContent: validatedData, - isError: false - }; + validatedData = MapMatchingOutputSchema.parse(data); } catch (validationError) { - // If validation fails, return the raw result anyway with a warning this.log( 'warning', `Schema validation warning: ${validationError instanceof Error ? validationError.message : String(validationError)}` ); + } - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: data, - isError: false - }; + const content: CallToolResult['content'] = [ + { type: 'text', text: JSON.stringify(validatedData, null, 2) } + ]; + + if (isMcpUiEnabled()) { + const matching = (validatedData as { matchings?: unknown[] }) + .matchings?.[0] as + | { + geometry?: unknown; + distance?: number; + duration?: number; + confidence?: number; + } + | undefined; + if (matching) { + const inlineHtml = await tryRenderMapMatchingInlineHtml({ + matching, + rawTrace: input.coordinates, + accessToken, + apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint, + httpRequest: this.httpRequest + }); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/map-matching/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } + } } + + return { + content, + structuredContent: validatedData, + isError: false + }; } } diff --git a/test/resources/ui-apps/MapMatchingAppUIResource.test.ts b/test/resources/ui-apps/MapMatchingAppUIResource.test.ts new file mode 100644 index 00000000..00039f18 --- /dev/null +++ b/test/resources/ui-apps/MapMatchingAppUIResource.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +const SK_TOKEN = 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { MapMatchingAppUIResource } from '../../../src/resources/ui-apps/MapMatchingAppUIResource.js'; +import { __resetMapboxPublicTokenCache } from '../../../src/utils/mapboxPublicToken.js'; + +function makeOkJson(body: unknown): Partial { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +describe('MapMatchingAppUIResource', () => { + beforeEach(() => { + __resetMapboxPublicTokenCache(); + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serves HTML with raw + matched line layers and the public token', async () => { + const httpRequest = vi.fn(async (url: string) => { + if (url.includes('tokens/v2/')) + return makeOkJson([ + { usage: 'pk', default: true, token: 'pk.fake' } + ]) as Response; + throw new Error(`Unexpected URL: ${url}`); + }); + + const resource = new MapMatchingAppUIResource({ httpRequest }); + + const result = await resource.read( + 'ui://mapbox/map-matching-app/index.html', + { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + authInfo: { token: SK_TOKEN } as any + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any + ); + + const entry = result.contents[0]; + expect(entry.mimeType).toBe('text/html;profile=mcp-app'); + expect(entry.text as string).toContain('pk.fake'); + expect(entry.text as string).toContain('matched-line'); + expect(entry.text as string).toContain('raw-line'); + expect(entry.text as string).toContain('line-dasharray'); + }); +});