diff --git a/CHANGELOG.md b/CHANGELOG.md index 4eb44612..8b53fe67 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`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. - **`directions_tool` now renders as a live Mapbox GL JS map** for both the MCP Apps spec and legacy MCP-UI clients: - **MCP Apps**: the tool declares `_meta.ui.resourceUri` pointing to a new `DirectionsAppUIResource` (`ui://mapbox/directions-app/index.html`). MCP App–capable hosts (Claude Desktop, VS Code, Cursor) render the route via postMessage handoff. diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 5fe62de5..86092016 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 00000000..5c951c89 --- /dev/null +++ b/src/resources/ui-apps/OptimizationAppUIResource.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 { renderOptimizationAppHtml } from './optimizationAppHtml.js'; + +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 ?? '' + }); + + 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/optimizationAppHtml.ts b/src/resources/ui-apps/optimizationAppHtml.ts new file mode 100644 index 00000000..d7e62294 --- /dev/null +++ b/src/resources/ui-apps/optimizationAppHtml.ts @@ -0,0 +1,387 @@ +// 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.input.schema.ts b/src/tools/optimization-tool/OptimizationTool.input.schema.ts index aad2b5ee..22b662dd 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/src/tools/optimization-tool/OptimizationTool.ts b/src/tools/optimization-tool/OptimizationTool.ts index ca640f58..b6ae9b28 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/test/resources/ui-apps/OptimizationAppUIResource.test.ts b/test/resources/ui-apps/OptimizationAppUIResource.test.ts new file mode 100644 index 00000000..cfe2088b --- /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-tool/OptimizationTool.test.ts b/test/tools/optimization-tool/OptimizationTool.test.ts index a347edab..30885836 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,