From 6f4c84d33ee1962580e97179a11f278f0a6ab818 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 11:03:49 -0400 Subject: [PATCH 1/4] =?UTF-8?q?feat:=20optimization=5Fapp=5Ftool=20?= =?UTF-8?q?=E2=80=94=20TSP=20visit=20order=20on=20an=20interactive=20map?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Third app tool in the MCP Apps pattern series, following directions and isochrones. The tool calls Mapbox's Optimization API with 2-12 stops and returns the optimized visit order plus the route geometry; the OptimizationAppUIResource renders the trip line with numbered (1, 2, 3, ...) markers at each stop in visit order, with the first stop colored green and the last red (if not a roundtrip), and the camera fit to all stops. Tightens the camera padding (top: 70, bottom/left/right: 30) to match the directions and isochrone tools so the route fills the viewport without manual zoom. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + src/resources/resourceRegistry.ts | 2 + .../ui-apps/OptimizationAppUIResource.ts | 369 ++++++++++++++++++ src/tools/index.ts | 5 + .../OptimizationAppTool.input.schema.ts | 42 ++ .../OptimizationAppTool.ts | 171 ++++++++ src/tools/toolRegistry.ts | 2 + .../ui-apps/OptimizationAppUIResource.test.ts | 96 +++++ .../OptimizationAppTool.test.ts | 195 +++++++++ 9 files changed, 883 insertions(+) create mode 100644 src/resources/ui-apps/OptimizationAppUIResource.ts create mode 100644 src/tools/optimization-app-tool/OptimizationAppTool.input.schema.ts create mode 100644 src/tools/optimization-app-tool/OptimizationAppTool.ts create mode 100644 test/resources/ui-apps/OptimizationAppUIResource.test.ts create mode 100644 test/tools/optimization-app-tool/OptimizationAppTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f1c96bc..7022ca6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`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`. - **`directions_app_tool`**: New tool that renders a route on an interactive Mapbox GL JS map as an MCP App. Returns the route GeoJSON plus a `_meta.ui.resourceUri` reference to a separately-registered MCP App resource (`ui://mapbox/directions-app/index.html`) that hosts (Claude Desktop, VS Code, Cursor) render as a live map with the route drawn, start/end markers, and camera fit to the route bounds. The required public (`pk.*`) token is resolved server-side by the resource: it first calls `GET /tokens/v2/{user}?default=true` to fetch the user's default public token (requires `tokens:read` scope on the `sk.*` access token), and falls back to the optional `MAPBOX_PUBLIC_TOKEN` env var. Includes `_meta.ui.csp` with `workerDomains: ['blob:']` so MCP App hosts grant Mapbox GL JS the iframe sandbox permissions it needs. - **MCP Completions capability**: Add auto-completion support for prompt arguments per MCP spec (2025-11-25). Clients can now suggest values when users fill in prompt parameters (#176) diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 5fe62de..8609201 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -7,6 +7,7 @@ import { TemporaryDataResource } from './temporary/TemporaryDataResource.js'; import { StaticMapUIResource } from './ui-apps/StaticMapUIResource.js'; import { DirectionsAppUIResource } from './ui-apps/DirectionsAppUIResource.js'; import { IsochroneAppUIResource } from './ui-apps/IsochroneAppUIResource.js'; +import { OptimizationAppUIResource } from './ui-apps/OptimizationAppUIResource.js'; import { VersionResource } from './version/VersionResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -18,6 +19,7 @@ export const ALL_RESOURCES = [ new StaticMapUIResource(), new DirectionsAppUIResource({ httpRequest }), new IsochroneAppUIResource({ httpRequest }), + new OptimizationAppUIResource({ httpRequest }), new VersionResource() ] as const; diff --git a/src/resources/ui-apps/OptimizationAppUIResource.ts b/src/resources/ui-apps/OptimizationAppUIResource.ts new file mode 100644 index 0000000..1f7779f --- /dev/null +++ b/src/resources/ui-apps/OptimizationAppUIResource.ts @@ -0,0 +1,369 @@ +// 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 Optimization App MCP App. + * + * Receives an optimized trip (route geometry + ordered stops) from + * `optimization_app_tool` via the MCP Apps postMessage protocol, draws the + * route as a line, and places numbered markers (1, 2, 3, …) at each stop in + * the visit order so the user can see "do these in this order" at a glance. + */ +export class OptimizationAppUIResource extends BaseResource { + readonly name = 'Optimization App UI'; + readonly uri = 'ui://mapbox/optimization-app/index.html'; + readonly description = + 'Interactive UI for visualizing an optimized multi-stop trip with Mapbox GL JS (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 = renderOptimizationAppHtml({ + 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 renderOptimizationAppHtml(params: { + publicToken: string; + glVersion: string; +}): string { + const { publicToken, glVersion } = params; + + return ` + + + + +Optimized Trip + + + + + +
+ +
Loading optimized trip…
+ + + + +`; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index 250ae1f..dd02357 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -39,6 +39,7 @@ export { CentroidTool } from './centroid-tool/CentroidTool.js'; export { DirectionsTool } from './directions-tool/DirectionsTool.js'; export { DirectionsAppTool } from './directions-app-tool/DirectionsAppTool.js'; export { IsochroneAppTool } from './isochrone-app-tool/IsochroneAppTool.js'; +export { OptimizationAppTool } from './optimization-app-tool/OptimizationAppTool.js'; export { DistanceTool } from './distance-tool/DistanceTool.js'; export { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; export { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -63,6 +64,7 @@ import { CentroidTool } from './centroid-tool/CentroidTool.js'; import { DirectionsTool } from './directions-tool/DirectionsTool.js'; import { DirectionsAppTool } from './directions-app-tool/DirectionsAppTool.js'; import { IsochroneAppTool } from './isochrone-app-tool/IsochroneAppTool.js'; +import { OptimizationAppTool } from './optimization-app-tool/OptimizationAppTool.js'; import { DistanceTool } from './distance-tool/DistanceTool.js'; import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -107,6 +109,9 @@ export const directionsApp = new DirectionsAppTool({ httpRequest }); /** Render reachable-area isochrones on an interactive Mapbox GL JS map (MCP App) */ export const isochroneApp = new IsochroneAppTool({ httpRequest }); +/** Render an optimized multi-stop trip on an interactive Mapbox GL JS map (MCP App) */ +export const optimizationApp = new OptimizationAppTool({ httpRequest }); + /** Calculate distance between points */ export const distance = new DistanceTool(); diff --git a/src/tools/optimization-app-tool/OptimizationAppTool.input.schema.ts b/src/tools/optimization-app-tool/OptimizationAppTool.input.schema.ts new file mode 100644 index 0000000..a04b589 --- /dev/null +++ b/src/tools/optimization-app-tool/OptimizationAppTool.input.schema.ts @@ -0,0 +1,42 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { coordinateSchema } from '../../schemas/shared.js'; + +export const OptimizationAppInputSchema = z.object({ + coordinates: z + .array(coordinateSchema) + .min(2) + .max(12) + .describe( + 'Between 2 and 12 stops to visit. Order does not matter — the tool returns the optimal visit order.' + ), + profile: z + .enum([ + 'mapbox/driving', + 'mapbox/driving-traffic', + 'mapbox/walking', + 'mapbox/cycling' + ]) + .default('mapbox/driving') + .describe('Mode of travel.'), + source: z + .enum(['any', 'first']) + .default('any') + .describe( + '"first" pins the first input coordinate as the start; "any" lets the optimizer choose.' + ), + destination: z + .enum(['any', 'last']) + .default('any') + .describe( + '"last" pins the last input coordinate as the end; "any" lets the optimizer choose.' + ), + roundtrip: z + .boolean() + .default(true) + .describe('If true, the trip returns to its starting waypoint.') +}); + +export type OptimizationAppInput = z.infer; diff --git a/src/tools/optimization-app-tool/OptimizationAppTool.ts b/src/tools/optimization-app-tool/OptimizationAppTool.ts new file mode 100644 index 0000000..5359a58 --- /dev/null +++ b/src/tools/optimization-app-tool/OptimizationAppTool.ts @@ -0,0 +1,171 @@ +// 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 { OptimizationAppInputSchema } from './OptimizationAppTool.input.schema.js'; + +// Docs: https://docs.mapbox.com/api/navigation/optimization/ + +interface Waypoint { + location: [number, number]; + trips_index?: number; + waypoint_index: number; + name?: string; +} + +interface Trip { + geometry?: { type: string; coordinates: [number, number][] }; + duration?: number; + distance?: number; + legs?: Array<{ distance?: number; duration?: number }>; +} + +interface OptimizationResponse { + code?: string; + message?: string; + trips?: Trip[]; + waypoints?: Waypoint[]; +} + +export class OptimizationAppTool extends MapboxApiBasedTool< + typeof OptimizationAppInputSchema +> { + name = 'optimization_app_tool'; + description = + 'Find the optimal order to visit a set of 2–12 stops and render the resulting trip on an interactive Mapbox GL JS map as an MCP App. ' + + 'Returns the optimized trip plus an MCP App resource reference that hosts (Claude Desktop, VS Code, Cursor) render as a live map with the route drawn, ' + + 'each stop marked with its visit order (1, 2, 3, …), and the camera fit to the trip. ' + + 'Use this when the user asks "what order should I do these errands in" or anything where the visit order matters and they want to see it on a map.'; + annotations = { + title: 'Optimization App Tool', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/optimization-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: OptimizationAppInputSchema, + 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}optimized-trips/v1/${input.profile}/${encodeURIComponent(coordsStr)}` + ); + url.searchParams.set('access_token', accessToken); + url.searchParams.set('geometries', 'geojson'); + url.searchParams.set('overview', 'full'); + url.searchParams.set('roundtrip', String(input.roundtrip)); + if (input.source !== 'any') url.searchParams.set('source', input.source); + if (input.destination !== 'any') + url.searchParams.set('destination', input.destination); + + const response = await this.httpRequest(url.toString()); + if (!response.ok) { + const errorText = await this.getErrorMessage(response); + return { + content: [ + { type: 'text', text: `Optimization API error: ${errorText}` } + ], + isError: true + }; + } + + const data = (await response.json()) as OptimizationResponse; + + if (data.code && data.code !== 'Ok') { + return { + content: [ + { + type: 'text', + text: `Optimization error: ${data.message || data.code}` + } + ], + isError: true + }; + } + + const trip = data.trips?.[0]; + const waypoints = data.waypoints ?? []; + if (!trip?.geometry?.coordinates?.length || waypoints.length === 0) { + return { + content: [ + { + type: 'text', + text: 'Optimization API returned no usable trip or waypoints.' + } + ], + isError: true + }; + } + + // Build a list of stops in the OPTIMIZED order using waypoint_index. + // waypoint_index = position of this stop in the optimized trip. + const orderedStops = waypoints + .map((wp, inputIndex) => ({ wp, inputIndex })) + .sort((a, b) => a.wp.waypoint_index - b.wp.waypoint_index) + .map(({ wp, inputIndex }, orderIndex) => ({ + order: orderIndex + 1, // 1-based visit order + input_index: inputIndex, + location: wp.location, + name: wp.name + })); + + const durationMin = trip.duration + ? `${(trip.duration / 60).toFixed(1)} min` + : 'unknown'; + const distanceMiles = trip.distance + ? `${(trip.distance / 1609.34).toFixed(1)} mi` + : 'unknown'; + const orderDesc = orderedStops + .map((s) => `${s.order}: input #${s.input_index}`) + .join(' → '); + + const summary = `Optimized trip: ${distanceMiles}, ${durationMin}\nOrder — ${orderDesc}`; + + const payload = { + summary, + profile: input.profile, + roundtrip: input.roundtrip, + geometry: trip.geometry, + stops: orderedStops, + distance_meters: trip.distance, + duration_seconds: trip.duration + }; + + return { + content: [ + { type: 'text', text: summary }, + { type: 'text', text: JSON.stringify(payload) } + ], + structuredContent: { optimization: payload }, + isError: false, + _meta: { + viewUUID: randomUUID() + } + }; + } +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 9dff0f2..46ead50 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -30,6 +30,7 @@ import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; import { MatrixTool } from './matrix-tool/MatrixTool.js'; import { OptimizationTool } from './optimization-tool/OptimizationTool.js'; +import { OptimizationAppTool } from './optimization-app-tool/OptimizationAppTool.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'; @@ -69,6 +70,7 @@ export const CORE_TOOLS = [ new MapMatchingTool({ httpRequest }), new MatrixTool({ httpRequest }), new OptimizationTool({ httpRequest }), + new OptimizationAppTool({ httpRequest }), new ReverseGeocodeTool({ httpRequest }), new StaticMapImageTool({ httpRequest }), new SearchAndGeocodeTool({ httpRequest }) diff --git a/test/resources/ui-apps/OptimizationAppUIResource.test.ts b/test/resources/ui-apps/OptimizationAppUIResource.test.ts new file mode 100644 index 0000000..cfe2088 --- /dev/null +++ b/test/resources/ui-apps/OptimizationAppUIResource.test.ts @@ -0,0 +1,96 @@ +// 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 { OptimizationAppUIResource } from '../../../src/resources/ui-apps/OptimizationAppUIResource.js'; +import { __resetMapboxPublicTokenCache } from '../../../src/utils/mapboxPublicToken.js'; + +const fakeTokenList = [ + { + id: 'cktest123', + usage: 'pk', + default: true, + token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token', + scopes: ['styles:read', 'styles:tiles', 'fonts:read'] + } +]; + +function makeOkJson(body: unknown): Partial { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +describe('OptimizationAppUIResource', () => { + beforeEach(() => { + __resetMapboxPublicTokenCache(); + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serves HTML with the mcp-app mime type and the public token baked in', async () => { + const httpRequest = vi.fn(async (url: string) => { + if (url.includes('tokens/v2/')) + return makeOkJson(fakeTokenList) as Response; + throw new Error(`Unexpected URL: ${url}`); + }); + + const resource = new OptimizationAppUIResource({ httpRequest }); + + const result = await resource.read( + 'ui://mapbox/optimization-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 + ); + + expect(result.contents).toHaveLength(1); + const entry = result.contents[0]; + expect(entry.mimeType).toBe('text/html;profile=mcp-app'); + expect(entry.uri).toBe('ui://mapbox/optimization-app/index.html'); + expect(typeof entry.text).toBe('string'); + expect(entry.text as string).toContain( + 'pk.eyJ1IjoidGVzdHVzZXIifQ.fake-public-token' + ); + expect(entry.text as string).toContain('mapbox-gl.js'); + // Optimization-specific: numbered stop markers + expect(entry.text as string).toContain('stop-marker'); + + const meta = (entry as { _meta?: unknown })._meta as + | { ui?: { csp?: { workerDomains?: string[] } } } + | undefined; + expect(meta?.ui?.csp?.workerDomains).toContain('blob:'); + }); + + it('falls back to MAPBOX_PUBLIC_TOKEN when the Tokens API call fails', async () => { + process.env.MAPBOX_PUBLIC_TOKEN = 'pk.fallback-token'; + + const httpRequest = vi.fn( + async () => ({ ok: false, status: 403 }) as Response + ); + + const resource = new OptimizationAppUIResource({ httpRequest }); + + const result = await resource.read( + 'ui://mapbox/optimization-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 + ); + + expect(result.contents[0].text as string).toContain('pk.fallback-token'); + }); +}); diff --git a/test/tools/optimization-app-tool/OptimizationAppTool.test.ts b/test/tools/optimization-app-tool/OptimizationAppTool.test.ts new file mode 100644 index 0000000..a6cca1c --- /dev/null +++ b/test/tools/optimization-app-tool/OptimizationAppTool.test.ts @@ -0,0 +1,195 @@ +// 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 { OptimizationAppTool } from '../../../src/tools/optimization-app-tool/OptimizationAppTool.js'; + +// 3 input points; optimized order is 0 -> 2 -> 1 -> 0 (roundtrip). +const fakeOptimizationResponse = { + code: 'Ok', + trips: [ + { + geometry: { + type: 'LineString', + coordinates: [ + [-122.4, 37.78], + [-122.41, 37.79], + [-122.42, 37.8], + [-122.4, 37.78] + ] + }, + duration: 900, + distance: 5000, + legs: [ + { distance: 1500, duration: 300 }, + { distance: 2000, duration: 400 }, + { distance: 1500, duration: 200 } + ], + weight: 900, + weight_name: 'duration' + } + ], + waypoints: [ + { + location: [-122.4, 37.78], + waypoint_index: 0, + trips_index: 0, + name: 'A' + }, + { + location: [-122.42, 37.8], + waypoint_index: 2, + trips_index: 0, + name: 'C' + }, + { + location: [-122.41, 37.79], + waypoint_index: 1, + trips_index: 0, + name: 'B' + } + ] +}; + +function makeOkResponse(body: unknown): Partial { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +describe('OptimizationAppTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns optimized stops in visit order with the route geometry', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeOptimizationResponse) + ); + + const result = await new OptimizationAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4, latitude: 37.78 }, + { longitude: -122.41, latitude: 37.79 }, + { longitude: -122.42, latitude: 37.8 } + ] + }); + + expect(result.isError).toBe(false); + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('optimized-trips/v1/mapbox/driving/'); + expect(calledUrl).toContain('geometries=geojson'); + expect(calledUrl).toContain('roundtrip=true'); + + expect(result.content).toHaveLength(2); + const summary = (result.content[0] as { type: 'text'; text: string }).text; + expect(summary).toMatch(/Optimized trip:/); + // Optimized order: visit #1 is input 0, visit #2 is input 2, visit #3 is input 1 + expect(summary).toMatch(/1: input #0.*2: input #2.*3: input #1/); + + const payload = JSON.parse( + (result.content[1] as { type: 'text'; text: string }).text + ); + expect(payload.stops).toHaveLength(3); + // First visit (order=1) should be input index 0 + expect(payload.stops[0].input_index).toBe(0); + expect(payload.stops[0].order).toBe(1); + // Second visit (order=2) should be input index 2 (re-sorted by waypoint_index) + expect(payload.stops[1].input_index).toBe(2); + expect(payload.stops[1].order).toBe(2); + // Third visit (order=3) should be input index 1 + expect(payload.stops[2].input_index).toBe(1); + expect(payload.stops[2].order).toBe(3); + + expect(payload.geometry.coordinates).toHaveLength(4); + + const structuredContent = ( + result as unknown as { structuredContent?: { optimization?: unknown } } + ).structuredContent; + expect(structuredContent?.optimization).toBeDefined(); + }); + + it('declares the MCP App resourceUri on meta', () => { + const { httpRequest } = setupHttpRequest(); + const tool = new OptimizationAppTool({ httpRequest }); + expect(tool.meta?.ui?.resourceUri).toBe( + 'ui://mapbox/optimization-app/index.html' + ); + }); + + it('passes source/destination/roundtrip params when not "any"/true', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeOptimizationResponse) + ); + + await new OptimizationAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4, latitude: 37.78 }, + { longitude: -122.41, latitude: 37.79 }, + { longitude: -122.42, latitude: 37.8 } + ], + source: 'first', + destination: 'last', + roundtrip: false, + profile: 'mapbox/walking' + }); + + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('optimized-trips/v1/mapbox/walking/'); + expect(calledUrl).toContain('source=first'); + expect(calledUrl).toContain('destination=last'); + expect(calledUrl).toContain('roundtrip=false'); + }); + + it('returns an error when the API responds with code != "Ok"', async () => { + const { httpRequest } = setupHttpRequest( + makeOkResponse({ + code: 'NoTrips', + message: 'No trips found' + }) + ); + + const result = await new OptimizationAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4, latitude: 37.78 }, + { longitude: -122.41, latitude: 37.79 } + ] + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Optimization error'); + expect(text).toContain('No trips found'); + }); + + it('returns an error when the API returns a non-2xx response', async () => { + const { httpRequest } = setupHttpRequest({ + ok: false, + status: 422, + statusText: 'Unprocessable Entity', + json: async () => ({ message: 'Bad params' }), + text: async () => '{"message":"Bad params"}' + }); + + const result = await new OptimizationAppTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4, latitude: 37.78 }, + { longitude: -122.41, latitude: 37.79 } + ] + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Optimization API error'); + }); +}); From 88c7e44501c6e8bb247a619d3c0ac1f314dfcab9 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 11:43:43 -0400 Subject: [PATCH 2/4] fix(optimization_app): defer fitBounds until after iframe resize MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as directions/isochrone — fit AFTER the host applies the size-changed notification so the trip fills the final viewport. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/ui-apps/OptimizationAppUIResource.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/resources/ui-apps/OptimizationAppUIResource.ts b/src/resources/ui-apps/OptimizationAppUIResource.ts index 1f7779f..7d238fa 100644 --- a/src/resources/ui-apps/OptimizationAppUIResource.ts +++ b/src/resources/ui-apps/OptimizationAppUIResource.ts @@ -354,13 +354,15 @@ function renderOptimizationAppHtml(params: { [Math.min.apply(null, lngs), Math.min.apply(null, lats)], [Math.max.apply(null, lngs), Math.max.apply(null, lats)] ]; - map.fitBounds(bounds, { - padding: { top: 70, bottom: 30, left: 30, right: 30 }, - duration: 600 - }); - loadingEl.style.display = 'none'; requestSizeToFit(); + setTimeout(function() { + map.resize(); + map.fitBounds(bounds, { + padding: { top: 70, bottom: 30, left: 30, right: 30 }, + duration: 600 + }); + }, 60); } })(); From 947a6c310117df265e4e6bc99d74782f639cd3fc Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 16:21:32 -0400 Subject: [PATCH 3/4] refactor: fold MCP App support into optimization_tool (drops optimization_app_tool) Same pattern as the directions/isochrone refactors. Drops the sibling optimization_app_tool; the existing optimization_tool now emits both meta.ui.resourceUri (MCP Apps) and inline rawHtml (MCP-UI). Shared optimizationAppHtml.ts renders the trip line with numbered visit-order markers; the iframe normalizes both GeoJSON and polyline-encoded geometry inputs. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ui-apps/OptimizationAppUIResource.ts | 296 +------------- src/resources/ui-apps/optimizationAppHtml.ts | 375 ++++++++++++++++++ .../optimization-tool/OptimizationTool.ts | 90 ++++- src/tools/toolRegistry.ts | 2 - 4 files changed, 466 insertions(+), 297 deletions(-) create mode 100644 src/resources/ui-apps/optimizationAppHtml.ts diff --git a/src/resources/ui-apps/OptimizationAppUIResource.ts b/src/resources/ui-apps/OptimizationAppUIResource.ts index 7d238fa..5c951c8 100644 --- a/src/resources/ui-apps/OptimizationAppUIResource.ts +++ b/src/resources/ui-apps/OptimizationAppUIResource.ts @@ -11,17 +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 { renderOptimizationAppHtml } from './optimizationAppHtml.js'; -const MAPBOX_GL_VERSION = '3.12.0'; - -/** - * Serves the HTML for the Optimization App MCP App. - * - * Receives an optimized trip (route geometry + ordered stops) from - * `optimization_app_tool` via the MCP Apps postMessage protocol, draws the - * route as a line, and places numbered markers (1, 2, 3, …) at each stop in - * the visit order so the user can see "do these in this order" at a glance. - */ export class OptimizationAppUIResource extends BaseResource { readonly name = 'Optimization App UI'; readonly uri = 'ui://mapbox/optimization-app/index.html'; @@ -59,8 +50,7 @@ export class OptimizationAppUIResource extends BaseResource { }); const html = renderOptimizationAppHtml({ - publicToken: publicToken ?? '', - glVersion: MAPBOX_GL_VERSION + publicToken: publicToken ?? '' }); return { @@ -87,285 +77,3 @@ export class OptimizationAppUIResource extends BaseResource { }; } } - -function renderOptimizationAppHtml(params: { - publicToken: string; - glVersion: string; -}): string { - const { publicToken, glVersion } = params; - - return ` - - - - -Optimized Trip - - - - - -
- -
Loading optimized trip…
- - - - -`; -} diff --git a/src/resources/ui-apps/optimizationAppHtml.ts b/src/resources/ui-apps/optimizationAppHtml.ts new file mode 100644 index 0000000..4fd277a --- /dev/null +++ b/src/resources/ui-apps/optimizationAppHtml.ts @@ -0,0 +1,375 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Render the optimized-trip MCP App HTML — used by both the MCP Apps resource + * and `optimization_tool`'s inline MCP-UI rawHtml block. + */ + +export const MAPBOX_GL_VERSION = '3.12.0'; + +export interface OptimizationAppInitialData { + geometry: unknown; // GeoJSON LineString OR encoded polyline string + stops: Array<{ + order: number; + input_index: number; + location: [number, number]; + name?: string; + }>; + roundtrip?: boolean; + summary?: string; +} + +export function renderOptimizationAppHtml(params: { + publicToken: string; + glVersion?: string; + initialData?: OptimizationAppInitialData; +}): string { + const { publicToken, initialData } = params; + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + + const initialDataScript = initialData + ? `` + : ''; + + return ` + + + + +Optimized Trip + + + + + +
+ +
Loading optimized trip…
+ +${initialDataScript} + + + +`; +} + +function escapeForScript(s: string): string { + return s.replace(/<\/script>/gi, '<\\/script>'); +} diff --git a/src/tools/optimization-tool/OptimizationTool.ts b/src/tools/optimization-tool/OptimizationTool.ts index ca640f5..b6ae9b2 100644 --- a/src/tools/optimization-tool/OptimizationTool.ts +++ b/src/tools/optimization-tool/OptimizationTool.ts @@ -1,7 +1,9 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. +import { randomUUID } from 'node:crypto'; import { SpanStatusCode } from '@opentelemetry/api'; +import { createUIResource } from '@mcp-ui/server'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import { OptimizationInputSchema, @@ -14,6 +16,9 @@ import { import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { ToolExecutionContext } from '../../utils/tracing.js'; import type { HttpRequest } from '../../utils/types.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; +import { renderOptimizationAppHtml } from '../../resources/ui-apps/optimizationAppHtml.js'; /** * OptimizationTool - Find optimal route through multiple coordinates (V1 API) @@ -38,6 +43,15 @@ export class OptimizationTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/optimization-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; constructor(params: { httpRequest: HttpRequest }) { super({ @@ -165,8 +179,33 @@ export class OptimizationTool extends MapboxApiBasedTool< validatedResult.waypoints.length ); + const content: CallToolResult['content'] = [ + { type: 'text' as const, text } + ]; + + if (isMcpUiEnabled()) { + const inlineHtml = await tryRenderOptimizationInlineHtml( + validatedResult, + input, + accessToken, + this.httpRequest + ); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/optimization/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } + } + return { - content: [{ type: 'text' as const, text }], + content, structuredContent: validatedResult, isError: false }; @@ -187,3 +226,52 @@ export class OptimizationTool extends MapboxApiBasedTool< } } } + +/** + * Bake the optimized trip into the shared iframe template for MCP-UI clients. + */ +async function tryRenderOptimizationInlineHtml( + result: OptimizationOutput, + _input: OptimizationInput, + accessToken: string, + httpRequest: HttpRequest +): Promise { + const trip = result.trips?.[0]; + if (!trip?.geometry) return undefined; + + const publicToken = await resolveMapboxPublicToken({ + accessToken, + apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint, + httpRequest + }); + if (!publicToken) return undefined; + + // Sort waypoints by waypoint_index = position in optimized trip + const stops = (result.waypoints ?? []) + .map((wp, inputIndex) => ({ wp, inputIndex })) + .sort((a, b) => a.wp.waypoint_index - b.wp.waypoint_index) + .map(({ wp, inputIndex }, orderIndex) => ({ + order: orderIndex + 1, + input_index: inputIndex, + location: wp.location as [number, number], + name: wp.name + })); + + const parts: string[] = []; + if (typeof trip.distance === 'number') { + parts.push(`${(trip.distance / 1609.34).toFixed(1)} mi`); + } + if (typeof trip.duration === 'number') { + parts.push(`${Math.round(trip.duration / 60)} min`); + } + const summary = `Optimized trip: ${parts.length ? parts.join(', ') : `${stops.length} stops`}`; + + return renderOptimizationAppHtml({ + publicToken, + initialData: { + geometry: trip.geometry, + stops, + summary + } + }); +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 3f6e449..75babfd 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -28,7 +28,6 @@ import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; import { MatrixTool } from './matrix-tool/MatrixTool.js'; import { OptimizationTool } from './optimization-tool/OptimizationTool.js'; -import { OptimizationAppTool } from './optimization-app-tool/OptimizationAppTool.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'; @@ -66,7 +65,6 @@ export const CORE_TOOLS = [ new MapMatchingTool({ httpRequest }), new MatrixTool({ httpRequest }), new OptimizationTool({ httpRequest }), - new OptimizationAppTool({ httpRequest }), new ReverseGeocodeTool({ httpRequest }), new StaticMapImageTool({ httpRequest }), new SearchAndGeocodeTool({ httpRequest }) From 7511fda6d4898ff89186fffe3a34a9c21963d63c Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 16:51:58 -0400 Subject: [PATCH 4/4] fix(optimization_tool): address PR #191 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI fixes in optimizationAppHtml.ts: - Guard stop.location: skip stops whose location is missing or not a [lng, lat] pair, instead of throwing setLngLat mid-loop. The iframe accepts tool-result from any postMessage source, so a malformed payload would have left earlier markers rendered and the rest absent. - Relabel the stop popup as "on " instead of "Stop N — ". The Mapbox Optimization API populates waypoint.name with the road name the input coordinate snapped to, not a place name, so the prior label implied the tool had identified a place ("Stop 1 — Main Street" when the user passed a Starbucks coordinate). Schema refinement in OptimizationTool.input.schema.ts: - Refuse roundtrip=false unless source='first' AND destination='last'. The Mapbox API requires both endpoints to be fixed for a one-way trip and otherwise returns a generic InvalidInput 422. Converts that into a precise Zod error before the network round-trip. One new test covers the refinement. Co-Authored-By: Claude Opus 4.7 --- src/resources/ui-apps/optimizationAppHtml.ts | 20 ++- .../OptimizationTool.input.schema.ts | 131 ++++++++++-------- .../OptimizationTool.test.ts | 17 +++ 3 files changed, 104 insertions(+), 64 deletions(-) diff --git a/src/resources/ui-apps/optimizationAppHtml.ts b/src/resources/ui-apps/optimizationAppHtml.ts index 4fd277a..d7e6229 100644 --- a/src/resources/ui-apps/optimizationAppHtml.ts +++ b/src/resources/ui-apps/optimizationAppHtml.ts @@ -330,19 +330,31 @@ ${initialDataScript} }); stops.forEach(function(stop, idx) { + // The iframe accepts tool-result from any postMessage source, so guard + // against malformed payloads where stop.location is missing or not a + // [lng, lat] pair — otherwise setLngLat throws mid-loop and leaves the + // UI half-rendered. Skip the bad entry and keep going. + if (!Array.isArray(stop.location) || stop.location.length < 2 || + typeof stop.location[0] !== 'number' || + typeof stop.location[1] !== 'number') { + return; + } var el = document.createElement('div'); el.className = 'stop-marker'; if (idx === 0) el.classList.add('start'); else if (idx === stops.length - 1 && !payload.roundtrip) el.classList.add('end'); el.textContent = String(stop.order); + // The Mapbox Optimization API populates waypoint.name with the road + // name the input coordinate was snapped to (not a place name), so + // label it as "on " to avoid implying we identified a place. + var label = 'Stop ' + stop.order + ' (input #' + stop.input_index + ')'; + if (stop.name) label += ' — on ' + stop.name; + stopMarkers.push( new mapboxgl.Marker({ element: el }) .setLngLat(stop.location) - .setPopup(new mapboxgl.Popup().setText( - 'Stop ' + stop.order + (stop.name ? ' — ' + stop.name : '') + - ' (input #' + stop.input_index + ')' - )) + .setPopup(new mapboxgl.Popup().setText(label)) .addTo(map) ); }); diff --git a/src/tools/optimization-tool/OptimizationTool.input.schema.ts b/src/tools/optimization-tool/OptimizationTool.input.schema.ts index aad2b5e..22b662d 100644 --- a/src/tools/optimization-tool/OptimizationTool.input.schema.ts +++ b/src/tools/optimization-tool/OptimizationTool.input.schema.ts @@ -20,65 +20,76 @@ const profileSchema = z * The V1 Optimization API finds the optimal (shortest by travel time) route * through a set of coordinates, solving the Traveling Salesman Problem. */ -export const OptimizationInputSchema = z.object({ - coordinates: z - .array(coordinateSchema) - .min(2, 'At least 2 coordinates are required') - .max(12, 'Maximum 12 coordinates allowed for V1 API') - .describe( - 'Array of {longitude, latitude} coordinate pairs to optimize a route through. ' + - 'The V1 API supports 2-12 coordinates and returns the optimal visiting order.' - ), - profile: profileSchema - .optional() - .default('mapbox/driving') - .describe('Routing profile to use for optimization'), - source: z - .enum(['any', 'first']) - .optional() - .default('any') - .describe( - 'Location to start the trip. "any" allows any coordinate, "first" forces the first coordinate as start.' - ), - destination: z - .enum(['any', 'last']) - .optional() - .default('any') - .describe( - 'Location to end the trip. "any" allows any coordinate, "last" forces the last coordinate as end.' - ), - roundtrip: z - .boolean() - .optional() - .default(true) - .describe( - 'Whether to return to the starting point. Set to false for one-way trips.' - ), - geometries: z - .enum(['geojson', 'polyline', 'polyline6']) - .optional() - .default('geojson') - .describe('Format for route geometry'), - overview: z - .enum(['full', 'simplified', 'false']) - .optional() - .default('simplified') - .describe('Detail level of route geometry'), - steps: z - .boolean() - .optional() - .default(false) - .describe('Whether to include turn-by-turn instructions'), - annotations: z - .array(z.enum(['duration', 'distance', 'speed'])) - .optional() - .describe('Additional metadata to include for each route segment'), - language: z - .string() - .optional() - .describe( - 'Language for instructions (if steps=true). ISO 639-1 code (e.g., "en", "es").' - ) -}); +export const OptimizationInputSchema = z + .object({ + coordinates: z + .array(coordinateSchema) + .min(2, 'At least 2 coordinates are required') + .max(12, 'Maximum 12 coordinates allowed for V1 API') + .describe( + 'Array of {longitude, latitude} coordinate pairs to optimize a route through. ' + + 'The V1 API supports 2-12 coordinates and returns the optimal visiting order.' + ), + profile: profileSchema + .optional() + .default('mapbox/driving') + .describe('Routing profile to use for optimization'), + source: z + .enum(['any', 'first']) + .optional() + .default('any') + .describe( + 'Location to start the trip. "any" allows any coordinate, "first" forces the first coordinate as start.' + ), + destination: z + .enum(['any', 'last']) + .optional() + .default('any') + .describe( + 'Location to end the trip. "any" allows any coordinate, "last" forces the last coordinate as end.' + ), + roundtrip: z + .boolean() + .optional() + .default(true) + .describe( + 'Whether to return to the starting point. Set to false for one-way trips.' + ), + geometries: z + .enum(['geojson', 'polyline', 'polyline6']) + .optional() + .default('geojson') + .describe('Format for route geometry'), + overview: z + .enum(['full', 'simplified', 'false']) + .optional() + .default('simplified') + .describe('Detail level of route geometry'), + steps: z + .boolean() + .optional() + .default(false) + .describe('Whether to include turn-by-turn instructions'), + annotations: z + .array(z.enum(['duration', 'distance', 'speed'])) + .optional() + .describe('Additional metadata to include for each route segment'), + language: z + .string() + .optional() + .describe( + 'Language for instructions (if steps=true). ISO 639-1 code (e.g., "en", "es").' + ) + }) + .refine( + (data) => + data.roundtrip !== false || + (data.source === 'first' && data.destination === 'last'), + { + message: + "When roundtrip=false, source must be 'first' AND destination must be 'last' (Mapbox Optimization API requires both endpoints to be fixed for one-way trips).", + path: ['roundtrip'] + } + ); export type OptimizationInput = z.infer; diff --git a/test/tools/optimization-tool/OptimizationTool.test.ts b/test/tools/optimization-tool/OptimizationTool.test.ts index a347eda..3088583 100644 --- a/test/tools/optimization-tool/OptimizationTool.test.ts +++ b/test/tools/optimization-tool/OptimizationTool.test.ts @@ -113,6 +113,23 @@ describe('OptimizationTool V1 API', () => { expect(callUrl).toContain('optimized-trips/v1/mapbox/cycling/'); }); + it('rejects roundtrip=false without source=first and destination=last', async () => { + const { httpRequest } = setupHttpRequest(); + const tool = new OptimizationTool({ httpRequest }); + const result = await tool.run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 } + ], + roundtrip: false + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain( + 'roundtrip=false' + ); + }); + it('should handle source and destination options', async () => { const { httpRequest, mockHttpRequest } = setupHttpRequest({ ok: true,