From e9d1bf2a857489525de3c5cfa0e63ec4fac231d4 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 11:20:25 -0400 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20map=5Fmatching=5Fapp=5Ftool=20?= =?UTF-8?q?=E2=80=94=20GPS=20trace=20vs=20matched=20route?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fifth in the MCP Apps series. Snaps a raw GPS trace to the road network and renders both the raw trace (dashed orange) and the matched route (solid blue) on an interactive Mapbox GL JS map. Includes a small legend; the camera fits to the combined bounds of both traces. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + src/resources/resourceRegistry.ts | 2 + .../ui-apps/MapMatchingAppUIResource.ts | 358 ++++++++++++++++++ src/tools/index.ts | 5 + .../MapMatchingAppTool.input.schema.ts | 21 + .../MapMatchingAppTool.ts | 147 +++++++ src/tools/toolRegistry.ts | 2 + .../ui-apps/MapMatchingAppUIResource.test.ts | 57 +++ .../MapMatchingAppTool.test.ts | 118 ++++++ 9 files changed, 711 insertions(+) create mode 100644 src/resources/ui-apps/MapMatchingAppUIResource.ts create mode 100644 src/tools/map-matching-app-tool/MapMatchingAppTool.input.schema.ts create mode 100644 src/tools/map-matching-app-tool/MapMatchingAppTool.ts create mode 100644 test/resources/ui-apps/MapMatchingAppUIResource.test.ts create mode 100644 test/tools/map-matching-app-tool/MapMatchingAppTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f15139d..c96d9a5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`map_matching_app_tool`**: New tool that snaps a raw GPS trace to the road network and renders both the raw trace (dashed orange line) and the matched route (solid blue line) on an interactive Mapbox GL JS map (MCP App). Includes a small legend; the camera fits to the combined bounds of both traces. Useful for verifying how a noisy GPS recording maps to the road graph. - **`search_and_geocode_app_tool` + `category_search_app_tool`**: Two new tools that render Mapbox Search Box results as numbered, popup-equipped pins on an interactive Mapbox GL JS map (MCP App). Both share a single MCP App resource (`ui://mapbox/search-app/index.html`). When a `proximity` point is given, it's also rendered as a small dark "you are here" dot. Useful for any "find me X near Y" prompt — the natural UX for multi-result POI lists. - **`optimization_app_tool`**: New tool that solves the traveling-salesman version of a multi-stop trip and renders the result on an interactive Mapbox GL JS map as an MCP App. Accepts 2–12 stops, returns the optimized visit order plus the route geometry, and the registered MCP App resource (`ui://mapbox/optimization-app/index.html`) draws the route line with numbered markers (1, 2, 3, …) at each stop in the visit order. Supports `source`, `destination`, `roundtrip`, and `profile` options. Useful for "what order should I do these errands in?" type questions. - **`isochrone_app_tool`**: New tool that renders reachable-area isochrones on an interactive Mapbox GL JS map as an MCP App. Returns the isochrone FeatureCollection plus a `_meta.ui.resourceUri` reference to a registered MCP App resource (`ui://mapbox/isochrone-app/index.html`) that hosts render as a live map with each contour drawn as a translucent fill + outline layer, the origin point marked, and the camera fit to the contours. Reuses the same `tokens:read`-via-Tokens-API public token resolution as `directions_app_tool`. diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index fafbe17..9791bfe 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 0000000..d2ae72f --- /dev/null +++ b/src/resources/ui-apps/MapMatchingAppUIResource.ts @@ -0,0 +1,358 @@ +// 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'; + +const MAPBOX_GL_VERSION = '3.12.0'; + +/** + * Serves the HTML for the Map Matching App MCP App. + * + * Receives a raw GPS trace and the snapped matched geometry from + * `map_matching_app_tool`, draws the raw trace as a dashed orange line, + * the matched route as a solid blue line on top, and fits the camera to both. + */ +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 ?? '', + glVersion: MAPBOX_GL_VERSION + }); + + 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 } + } + } + } + ] + }; + } +} + +function renderMapMatchingAppHtml(params: { + publicToken: string; + glVersion: string; +}): string { + const { publicToken, glVersion } = params; + + return ` + + + + +Map Matching Preview + + + + + +
+ +
+
Raw GPS trace
+
Matched route
+
+
Loading map matching…
+ + + + +`; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 5e97b03..5add93e 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -44,6 +44,7 @@ export { SearchAndGeocodeAppTool, CategorySearchAppTool } from './search-app-tool/SearchAppTool.js'; +export { MapMatchingAppTool } from './map-matching-app-tool/MapMatchingAppTool.js'; export { DistanceTool } from './distance-tool/DistanceTool.js'; export { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; export { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -73,6 +74,7 @@ import { SearchAndGeocodeAppTool, CategorySearchAppTool } from './search-app-tool/SearchAppTool.js'; +import { MapMatchingAppTool } from './map-matching-app-tool/MapMatchingAppTool.js'; import { DistanceTool } from './distance-tool/DistanceTool.js'; import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -126,6 +128,9 @@ export const searchAndGeocodeApp = new SearchAndGeocodeAppTool({ httpRequest }); /** Category-filtered POI search results on an interactive Mapbox GL JS map (MCP App) */ export const categorySearchApp = new CategorySearchAppTool({ httpRequest }); +/** Snap a GPS trace to the road network and render raw + matched on a map (MCP App) */ +export const mapMatchingApp = new MapMatchingAppTool({ httpRequest }); + /** Calculate distance between points */ export const distance = new DistanceTool(); diff --git a/src/tools/map-matching-app-tool/MapMatchingAppTool.input.schema.ts b/src/tools/map-matching-app-tool/MapMatchingAppTool.input.schema.ts new file mode 100644 index 0000000..835fa69 --- /dev/null +++ b/src/tools/map-matching-app-tool/MapMatchingAppTool.input.schema.ts @@ -0,0 +1,21 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { coordinateSchema } from '../../schemas/shared.js'; + +export const MapMatchingAppInputSchema = z.object({ + coordinates: z + .array(coordinateSchema) + .min(2) + .max(100) + .describe( + 'GPS trace as an array of {longitude, latitude} pairs, in recorded order. 2-100 points.' + ), + profile: z + .enum(['driving', 'driving-traffic', 'walking', 'cycling']) + .default('driving') + .describe('Transport mode to snap the trace to.') +}); + +export type MapMatchingAppInput = z.infer; diff --git a/src/tools/map-matching-app-tool/MapMatchingAppTool.ts b/src/tools/map-matching-app-tool/MapMatchingAppTool.ts new file mode 100644 index 0000000..cb4b9f3 --- /dev/null +++ b/src/tools/map-matching-app-tool/MapMatchingAppTool.ts @@ -0,0 +1,147 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { randomUUID } from 'node:crypto'; +import type { z } from 'zod'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { MapMatchingAppInputSchema } from './MapMatchingAppTool.input.schema.js'; + +// Docs: https://docs.mapbox.com/api/navigation/map-matching/ + +interface MatchingResponse { + matchings?: Array<{ + geometry?: { type: string; coordinates: [number, number][] }; + distance?: number; + duration?: number; + confidence?: number; + }>; + tracepoints?: Array; + code?: string; + message?: string; +} + +export class MapMatchingAppTool extends MapboxApiBasedTool< + typeof MapMatchingAppInputSchema +> { + name = 'map_matching_app_tool'; + description = + 'Snap a raw GPS trace to the road network and render both the raw trace and the matched route on an interactive Mapbox GL JS map (MCP App). ' + + 'The raw trace is drawn as a dashed orange line; the snapped route is drawn as a solid blue line on top. ' + + 'Useful for verifying how a noisy GPS trace lines up with the road graph.'; + annotations = { + title: 'Map Matching App Tool', + readOnlyHint: true, + destructiveHint: false, + 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({ + inputSchema: MapMatchingAppInputSchema, + httpRequest: params.httpRequest + }); + } + + protected async execute( + input: z.infer, + accessToken: string + ): Promise { + const coordsStr = input.coordinates + .map((c) => `${c.longitude},${c.latitude}`) + .join(';'); + + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}matching/v5/mapbox/${input.profile}/${encodeURIComponent(coordsStr)}` + ); + url.searchParams.set('access_token', accessToken); + url.searchParams.set('geometries', 'geojson'); + url.searchParams.set('overview', 'full'); + + const response = await this.httpRequest(url.toString()); + if (!response.ok) { + const errorText = await this.getErrorMessage(response); + return { + content: [ + { type: 'text', text: `Map Matching API error: ${errorText}` } + ], + isError: true + }; + } + + const data = (await response.json()) as MatchingResponse; + if (data.code && data.code !== 'Ok') { + return { + content: [ + { + type: 'text', + text: `Map matching error: ${data.message || data.code}` + } + ], + isError: true + }; + } + + const matching = data.matchings?.[0]; + if (!matching?.geometry?.coordinates?.length) { + return { + content: [ + { + type: 'text', + text: 'No matching route returned for the given trace.' + } + ], + isError: true + }; + } + + const distanceMiles = matching.distance + ? `${(matching.distance / 1609.34).toFixed(2)} mi` + : 'unknown'; + const durationMin = matching.duration + ? `${Math.round(matching.duration / 60)} min` + : 'unknown'; + const confidence = + typeof matching.confidence === 'number' + ? `${(matching.confidence * 100).toFixed(0)}%` + : 'n/a'; + + const summary = `Matched trace: ${distanceMiles}, ${durationMin} (confidence ${confidence})`; + + const payload = { + summary, + profile: input.profile, + raw_trace: { + type: 'LineString', + coordinates: input.coordinates.map((c) => [c.longitude, c.latitude]) + }, + matched_geometry: matching.geometry, + distance_meters: matching.distance, + duration_seconds: matching.duration, + confidence: matching.confidence + }; + + return { + content: [ + { type: 'text', text: summary }, + { type: 'text', text: JSON.stringify(payload) } + ], + structuredContent: { map_matching: payload }, + isError: false, + _meta: { + viewUUID: randomUUID() + } + }; + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index d407e8a..629daea 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -35,6 +35,7 @@ import { SearchAndGeocodeAppTool, CategorySearchAppTool } from './search-app-tool/SearchAppTool.js'; +import { MapMatchingAppTool } from './map-matching-app-tool/MapMatchingAppTool.js'; import { ResourceReaderTool } from './resource-reader-tool/ResourceReaderTool.js'; import { ReverseGeocodeTool } from './reverse-geocode-tool/ReverseGeocodeTool.js'; import { StaticMapImageTool } from './static-map-image-tool/StaticMapImageTool.js'; @@ -77,6 +78,7 @@ export const CORE_TOOLS = [ new OptimizationAppTool({ httpRequest }), new SearchAndGeocodeAppTool({ httpRequest }), new CategorySearchAppTool({ httpRequest }), + new MapMatchingAppTool({ httpRequest }), new ReverseGeocodeTool({ httpRequest }), new StaticMapImageTool({ httpRequest }), new SearchAndGeocodeTool({ httpRequest }) diff --git a/test/resources/ui-apps/MapMatchingAppUIResource.test.ts b/test/resources/ui-apps/MapMatchingAppUIResource.test.ts new file mode 100644 index 0000000..00039f1 --- /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'); + }); +}); diff --git a/test/tools/map-matching-app-tool/MapMatchingAppTool.test.ts b/test/tools/map-matching-app-tool/MapMatchingAppTool.test.ts new file mode 100644 index 0000000..69f0802 --- /dev/null +++ b/test/tools/map-matching-app-tool/MapMatchingAppTool.test.ts @@ -0,0 +1,118 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +process.env.MAPBOX_ACCESS_TOKEN = + 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; + +import { describe, it, expect, afterEach, vi } from 'vitest'; +import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; +import { MapMatchingAppTool } from '../../../src/tools/map-matching-app-tool/MapMatchingAppTool.js'; + +const fakeMatchingResponse = { + code: 'Ok', + matchings: [ + { + geometry: { + type: 'LineString', + coordinates: [ + [-122.4194, 37.7749], + [-122.42, 37.78], + [-122.43, 37.79] + ] + }, + distance: 5000, + duration: 600, + confidence: 0.92 + } + ], + tracepoints: [] +}; + +function makeOkResponse(body: unknown): Partial { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +describe('MapMatchingAppTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns raw_trace + matched_geometry and the resource reference', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeMatchingResponse) + ); + + const result = await new MapMatchingAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.42, latitude: 37.78 }, + { longitude: -122.43, latitude: 37.79 } + ] + }); + + expect(result.isError).toBe(false); + + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('matching/v5/mapbox/driving/'); + expect(calledUrl).toContain('geometries=geojson'); + + const payload = JSON.parse( + (result.content[1] as { type: 'text'; text: string }).text + ); + expect(payload.raw_trace.coordinates).toHaveLength(3); + expect(payload.matched_geometry.coordinates).toHaveLength(3); + expect(payload.confidence).toBeCloseTo(0.92); + }); + + it('declares the map-matching resourceUri', () => { + const { httpRequest } = setupHttpRequest(); + const tool = new MapMatchingAppTool({ httpRequest }); + expect(tool.meta?.ui?.resourceUri).toBe( + 'ui://mapbox/map-matching-app/index.html' + ); + }); + + it('errors on non-Ok API code', async () => { + const { httpRequest } = setupHttpRequest( + makeOkResponse({ code: 'NoMatch', message: 'No match' }) + ); + + const result = await new MapMatchingAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: 0, latitude: 0 }, + { longitude: 1, latitude: 1 } + ] + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Map matching error'); + }); + + it('errors on non-2xx response', async () => { + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ message: 'Bad trace' }), + text: async () => '{"message":"Bad trace"}' + }); + + const result = await new MapMatchingAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: 0, latitude: 0 }, + { longitude: 1, latitude: 1 } + ] + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Map Matching API error'); + }); +}); From 7718ace0a3e85c469446204ce4bdf6f2cccd26bb Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 11:44:36 -0400 Subject: [PATCH 2/3] fix(map_matching_app): defer fitBounds until after iframe resize Same pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/ui-apps/MapMatchingAppUIResource.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/resources/ui-apps/MapMatchingAppUIResource.ts b/src/resources/ui-apps/MapMatchingAppUIResource.ts index d2ae72f..842394e 100644 --- a/src/resources/ui-apps/MapMatchingAppUIResource.ts +++ b/src/resources/ui-apps/MapMatchingAppUIResource.ts @@ -343,13 +343,15 @@ function renderMapMatchingAppHtml(params: { ext(payload.raw_trace.coordinates); ext(payload.matched_geometry.coordinates); - map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { - padding: { top: 70, bottom: 50, left: 30, right: 30 }, - duration: 600 - }); - loadingEl.style.display = 'none'; requestSizeToFit(); + setTimeout(function() { + map.resize(); + map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + padding: { top: 70, bottom: 50, left: 30, right: 30 }, + duration: 600 + }); + }, 60); } })(); From eca0db5384003f78557b211b2e37e85bca7c9fa6 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 16:30:10 -0400 Subject: [PATCH 3/3] refactor: fold MCP App support into map_matching_tool Same pattern. Drops map_matching_app_tool; existing tool emits meta.ui (MCP Apps) + inline rawHtml (MCP-UI). Shared renderMapMatchingAppHtml draws raw trace as dashed orange, matched route as solid blue, with polyline-string decoding for the matched geometry. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ui-apps/MapMatchingAppUIResource.ts | 287 +------------- src/resources/ui-apps/mapMatchingAppHtml.ts | 356 ++++++++++++++++++ .../map-matching-tool/MapMatchingTool.ts | 72 +++- 3 files changed, 415 insertions(+), 300 deletions(-) create mode 100644 src/resources/ui-apps/mapMatchingAppHtml.ts diff --git a/src/resources/ui-apps/MapMatchingAppUIResource.ts b/src/resources/ui-apps/MapMatchingAppUIResource.ts index 842394e..35b7525 100644 --- a/src/resources/ui-apps/MapMatchingAppUIResource.ts +++ b/src/resources/ui-apps/MapMatchingAppUIResource.ts @@ -11,16 +11,8 @@ 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'; -const MAPBOX_GL_VERSION = '3.12.0'; - -/** - * Serves the HTML for the Map Matching App MCP App. - * - * Receives a raw GPS trace and the snapped matched geometry from - * `map_matching_app_tool`, draws the raw trace as a dashed orange line, - * the matched route as a solid blue line on top, and fits the camera to both. - */ export class MapMatchingAppUIResource extends BaseResource { readonly name = 'Map Matching App UI'; readonly uri = 'ui://mapbox/map-matching-app/index.html'; @@ -57,10 +49,7 @@ export class MapMatchingAppUIResource extends BaseResource { httpRequest: this.httpRequest }); - const html = renderMapMatchingAppHtml({ - publicToken: publicToken ?? '', - glVersion: MAPBOX_GL_VERSION - }); + const html = renderMapMatchingAppHtml({ publicToken: publicToken ?? '' }); return { contents: [ @@ -86,275 +75,3 @@ export class MapMatchingAppUIResource extends BaseResource { }; } } - -function renderMapMatchingAppHtml(params: { - publicToken: string; - glVersion: string; -}): string { - const { publicToken, glVersion } = params; - - return ` - - - - -Map Matching Preview - - - - - -
- -
-
Raw GPS trace
-
Matched route
-
-
Loading map matching…
- - - - -`; -} diff --git a/src/resources/ui-apps/mapMatchingAppHtml.ts b/src/resources/ui-apps/mapMatchingAppHtml.ts new file mode 100644 index 0000000..cff526a --- /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 4760d9b..94e454b 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 + }; } }