diff --git a/CHANGELOG.md b/CHANGELOG.md index 488f3f5c..4eb44612 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`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. - **MCP-UI**: when `geometries=geojson` is requested and the response carries a renderable LineString, an inline `rawHtml` UIResource is added to the tool's `content[]` (gated by the existing `ENABLE_MCP_UI`/`--disable-mcp-ui` flag, like `static_map_image_tool`). diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 69a81f03..5fe62de5 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -6,6 +6,7 @@ import { CategoryListResource } from './category-list/CategoryListResource.js'; 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 { VersionResource } from './version/VersionResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -16,6 +17,7 @@ export const ALL_RESOURCES = [ new TemporaryDataResource(), new StaticMapUIResource(), new DirectionsAppUIResource({ httpRequest }), + new IsochroneAppUIResource({ httpRequest }), new VersionResource() ] as const; diff --git a/src/resources/ui-apps/IsochroneAppUIResource.ts b/src/resources/ui-apps/IsochroneAppUIResource.ts new file mode 100644 index 00000000..290109ab --- /dev/null +++ b/src/resources/ui-apps/IsochroneAppUIResource.ts @@ -0,0 +1,83 @@ +// 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 { renderIsochroneAppHtml } from './isochroneAppHtml.js'; + +/** + * MCP Apps resource for `isochrone_tool` — serves the HTML at + * `ui://mapbox/isochrone-app/index.html`. The iframe receives the tool's + * FeatureCollection via the `ui/notifications/tool-result` postMessage event + * and renders each contour as a translucent fill + outline layer. + */ +export class IsochroneAppUIResource extends BaseResource { + readonly name = 'Isochrone App UI'; + readonly uri = 'ui://mapbox/isochrone-app/index.html'; + readonly description = + 'Interactive UI for visualizing Mapbox isochrone contours 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 = renderIsochroneAppHtml({ 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/isochroneAppHtml.ts b/src/resources/ui-apps/isochroneAppHtml.ts new file mode 100644 index 00000000..3ecd5a1c --- /dev/null +++ b/src/resources/ui-apps/isochroneAppHtml.ts @@ -0,0 +1,361 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Render the isochrone MCP App HTML — used by both the MCP Apps resource + * (postMessage delivery) and `isochrone_tool`'s inline MCP-UI rawHtml block + * (initial-data baked in). See the directions counterpart for the rationale. + */ + +export const MAPBOX_GL_VERSION = '3.12.0'; + +export interface IsochroneAppInitialData { + featureCollection: { type: string; features: unknown[] }; + origin?: { longitude: number; latitude: number }; + summary?: string; +} + +export function renderIsochroneAppHtml(params: { + publicToken: string; + glVersion?: string; + initialData?: IsochroneAppInitialData; +}): string { + const { publicToken, initialData } = params; + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + + const initialDataScript = initialData + ? `` + : ''; + + return ` + + + + +Isochrone Preview + + + + + +
+ +
Loading isochrone…
+ +${initialDataScript} + + + +`; +} + +function escapeForScript(s: string): string { + return s.replace(/<\/script>/gi, '<\\/script>'); +} diff --git a/src/tools/isochrone-tool/IsochroneTool.input.schema.ts b/src/tools/isochrone-tool/IsochroneTool.input.schema.ts index e1e20abe..56d75c05 100644 --- a/src/tools/isochrone-tool/IsochroneTool.input.schema.ts +++ b/src/tools/isochrone-tool/IsochroneTool.input.schema.ts @@ -3,83 +3,149 @@ import { z } from 'zod'; -export const IsochroneInputSchema = z.object({ - profile: z - .enum([ - 'mapbox/driving-traffic', - 'mapbox/driving', - 'mapbox/cycling', - 'mapbox/walking' - ]) - .default('mapbox/driving-traffic') - .describe('Mode of travel.'), - coordinates: z - .object({ - longitude: z.number().min(-180).max(180), - latitude: z.number().min(-90).max(90) - }) - .describe( - 'A coordinate object with longitude and latitude properties around which to center the isochrone lines. Longitude: -180 to 180, Latitude: -85.0511 to 85.0511' - ), +function isAscending(arr: readonly number[]): boolean { + for (let i = 1; i < arr.length; i++) { + if (arr[i] <= arr[i - 1]) return false; + } + return true; +} - contours_minutes: z - .array(z.number().int().min(1).max(60)) - .max(4) - .optional() - .describe( - 'Contour times in minutes. Times must be in increasing order. Must be specified either contours_minutes or contours_meters.' - ), +export const IsochroneInputSchema = z + .object({ + profile: z + .enum([ + 'mapbox/driving-traffic', + 'mapbox/driving', + 'mapbox/cycling', + 'mapbox/walking' + ]) + .default('mapbox/driving-traffic') + .describe('Mode of travel.'), + coordinates: z + .object({ + longitude: z.number().min(-180).max(180), + latitude: z.number().min(-90).max(90) + }) + .describe( + 'A coordinate object with longitude and latitude properties around which to center the isochrone lines. Longitude: -180 to 180, Latitude: -85.0511 to 85.0511' + ), - contours_meters: z - .array(z.number().int().min(1).max(100000)) - .max(4) - .optional() - .describe( - 'Distances in meters. Distances must be in increasing order. Must be specified either contours_minutes or contours_meters.' - ), + contours_minutes: z + .array(z.number().int().min(1).max(60)) + .max(4) + .optional() + .describe( + 'Contour times in minutes. Times must be in increasing order. Must be specified either contours_minutes or contours_meters.' + ), - contours_colors: z - .array(z.string().regex(/^[0-9a-fA-F]{6}$/)) - .max(4) - .optional() - .describe( - 'Contour colors as hex strings without starting # (for example ff0000 for red. must match contours_minutes or contours_meters length if provided).' - ), + contours_meters: z + .array(z.number().int().min(1).max(100000)) + .max(4) + .optional() + .describe( + 'Distances in meters. Distances must be in increasing order. Must be specified either contours_minutes or contours_meters.' + ), - polygons: z - .boolean() - .default(false) - .optional() - .describe('Whether to return Polygons (true) or LineStrings (false).'), + contours_colors: z + .array(z.string().regex(/^[0-9a-fA-F]{6}$/)) + .max(4) + .optional() + .describe( + 'Contour colors as hex strings without starting # (for example ff0000 for red. must match contours_minutes or contours_meters length if provided).' + ), - denoise: z - .number() - .min(0) - .max(1) - .optional() - .describe( - 'A floating point value that can be used to remove smaller contours. A value of 1.0 will only return the largest contour for a given value.' - ), + polygons: z + .boolean() + .default(false) + .optional() + .describe('Whether to return Polygons (true) or LineStrings (false).'), - generalize: z - .number() - .min(0) - .describe( - `Positive number in meters that is used to simplify geometries. + denoise: z + .number() + .min(0) + .max(1) + .optional() + .describe( + 'A floating point value that can be used to remove smaller contours. A value of 1.0 will only return the largest contour for a given value.' + ), + + generalize: z + .number() + .min(0) + .describe( + `Positive number in meters that is used to simplify geometries. - Walking: use 0-500. Prefer 50-200 for short contours (minutes < 10 or meters < 5000), 300-500 as they grow. - Driving: use 1000-5000. Start at 2000, use 3000 if minutes > 10 or meters > 20000. Use 4000-5000 if near 60 minutes or 100000 meters. `.trim() - ), + ), - exclude: z - .array(z.enum(['motorway', 'toll', 'ferry', 'unpaved', 'cash_only_tolls'])) - .optional() - .describe('Exclude certain road types and custom locations from routing.'), + exclude: z + .array( + z.enum(['motorway', 'toll', 'ferry', 'unpaved', 'cash_only_tolls']) + ) + .optional() + .describe( + 'Exclude certain road types and custom locations from routing.' + ), - depart_at: z - .string() - .optional() - .describe( - 'An ISO 8601 date-time string representing the time to depart (format string: YYYY-MM-DDThh:mmss±hh:mm).' - ) -}); + depart_at: z + .string() + .optional() + .describe( + 'An ISO 8601 date-time string representing the time to depart (format string: YYYY-MM-DDThh:mmss±hh:mm).' + ) + }) + .refine( + (data) => + (data.contours_minutes && data.contours_minutes.length > 0) || + (data.contours_meters && data.contours_meters.length > 0), + { + message: + "At least one of 'contours_minutes' or 'contours_meters' must be provided", + path: ['contours_minutes'] + } + ) + .refine( + (data) => + !( + data.contours_minutes && + data.contours_minutes.length > 0 && + data.contours_meters && + data.contours_meters.length > 0 + ), + { + message: + "Provide only one of 'contours_minutes' or 'contours_meters', not both", + path: ['contours_meters'] + } + ) + .refine( + (data) => !data.contours_minutes || isAscending(data.contours_minutes), + { + message: 'contours_minutes must be in strictly ascending order', + path: ['contours_minutes'] + } + ) + .refine( + (data) => !data.contours_meters || isAscending(data.contours_meters), + { + message: 'contours_meters must be in strictly ascending order', + path: ['contours_meters'] + } + ) + .refine( + (data) => { + if (!data.contours_colors || data.contours_colors.length === 0) + return true; + const contoursLen = + (data.contours_minutes && data.contours_minutes.length) || + (data.contours_meters && data.contours_meters.length) || + 0; + return data.contours_colors.length === contoursLen; + }, + { + message: + 'contours_colors length must match contours_minutes or contours_meters length', + path: ['contours_colors'] + } + ); diff --git a/src/tools/isochrone-tool/IsochroneTool.ts b/src/tools/isochrone-tool/IsochroneTool.ts index 3348eda6..5a06e52e 100644 --- a/src/tools/isochrone-tool/IsochroneTool.ts +++ b/src/tools/isochrone-tool/IsochroneTool.ts @@ -1,8 +1,9 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. -import { randomBytes } from 'node:crypto'; +import { randomBytes, randomUUID } from 'node:crypto'; import type { z } from 'zod'; +import { createUIResource } from '@mcp-ui/server'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { HttpRequest } from '../../utils/types.js'; @@ -12,6 +13,9 @@ import { type IsochroneResponse } from './IsochroneTool.output.schema.js'; import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js'; +import { isMcpUiEnabled } from '../../config/toolConfig.js'; +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; +import { renderIsochroneAppHtml } from '../../resources/ui-apps/isochroneAppHtml.js'; export class IsochroneTool extends MapboxApiBasedTool< typeof IsochroneInputSchema, @@ -30,6 +34,15 @@ export class IsochroneTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/isochrone-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; constructor(params: { httpRequest: HttpRequest }) { super({ @@ -87,20 +100,6 @@ export class IsochroneTool extends MapboxApiBasedTool< `${MapboxApiBasedTool.mapboxApiEndpoint}isochrone/v1/${input.profile}/${input.coordinates.longitude}%2C${input.coordinates.latitude}` ); url.searchParams.append('access_token', accessToken); - if ( - (!input.contours_minutes || input.contours_minutes.length === 0) && - (!input.contours_meters || input.contours_meters.length === 0) - ) { - return { - content: [ - { - type: 'text', - text: "At least one of 'contours_minutes' or 'contours_meters' must be provided" - } - ], - isError: true - }; - } if (input.contours_minutes && input.contours_minutes.length > 0) { url.searchParams.append( 'contours_minutes', @@ -178,29 +177,95 @@ export class IsochroneTool extends MapboxApiBasedTool< // Validate the response against our schema const parsedData = IsochroneResponseSchema.safeParse(data); + const validatedData = parsedData.success + ? (parsedData.data as unknown as Record) + : (data as Record); - if (parsedData.success) { - // Valid response - use formatted output - const formattedText = this.formatIsochroneResponse(parsedData.data); - return { - content: [{ type: 'text', text: formattedText }], - structuredContent: parsedData.data as unknown as Record< - string, - unknown - >, - isError: false - }; - } else { - // Invalid response - fall back to JSON string for backward compatibility + if (!parsedData.success) { this.log( 'warning', `IsochroneTool: Response validation failed: ${parsedData.error.message}` ); - return { - content: [{ type: 'text', text: responseText }], - structuredContent: data as Record, - isError: false - }; } + + const text = parsedData.success + ? this.formatIsochroneResponse(parsedData.data) + : responseText; + + const content: CallToolResult['content'] = [{ type: 'text', text }]; + + if (isMcpUiEnabled()) { + const inlineHtml = await tryRenderIsochroneInlineHtml( + data, + input, + accessToken, + this.httpRequest + ); + if (inlineHtml) { + content.push( + createUIResource({ + uri: `ui://mapbox/isochrone/${randomUUID()}`, + content: { type: 'rawHtml', htmlString: inlineHtml }, + encoding: 'text', + uiMetadata: { + 'preferred-frame-size': ['100%', '500px'] + } + }) + ); + } + } + + return { + content, + structuredContent: validatedData, + isError: false + }; } } + +/** + * Bake the isochrone FeatureCollection + origin coordinates into the shared + * iframe template so MCP-UI clients render inline without a postMessage hop. + */ +async function tryRenderIsochroneInlineHtml( + data: unknown, + input: z.infer, + accessToken: string, + httpRequest: HttpRequest +): Promise { + const fc = data as { type?: string; features?: unknown[] } | null; + if ( + !fc || + fc.type !== 'FeatureCollection' || + !Array.isArray(fc.features) || + fc.features.length === 0 + ) { + return undefined; + } + + const publicToken = await resolveMapboxPublicToken({ + accessToken, + apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint, + httpRequest + }); + if (!publicToken) return undefined; + + const mode = input.profile.replace('mapbox/', '').replace('-', ' '); + let summary = `Isochrone: ${fc.features.length} contour${fc.features.length !== 1 ? 's' : ''}`; + if (input.contours_minutes && input.contours_minutes.length > 0) { + summary = `Reachable by ${mode}: ${input.contours_minutes.map((m) => `${m} min`).join(', ')}`; + } else if (input.contours_meters && input.contours_meters.length > 0) { + summary = `Reachable by ${mode}: ${input.contours_meters + .map((m) => (m >= 1000 ? `${(m / 1000).toFixed(1)} km` : `${m} m`)) + .join(', ')}`; + } + + return renderIsochroneAppHtml({ + publicToken, + initialData: { + featureCollection: fc as { type: string; features: unknown[] }, + origin: input.coordinates, + summary + } + }); +} diff --git a/test/resources/ui-apps/IsochroneAppUIResource.test.ts b/test/resources/ui-apps/IsochroneAppUIResource.test.ts new file mode 100644 index 00000000..c138f70b --- /dev/null +++ b/test/resources/ui-apps/IsochroneAppUIResource.test.ts @@ -0,0 +1,90 @@ +// 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 { IsochroneAppUIResource } from '../../../src/resources/ui-apps/IsochroneAppUIResource.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('IsochroneAppUIResource', () => { + 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 IsochroneAppUIResource({ httpRequest }); + + const result = await resource.read('ui://mapbox/isochrone-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/isochrone-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'); + // Fill-layer-specific bits — not present in the directions template + expect(entry.text as string).toContain('fill-color'); + + 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 IsochroneAppUIResource({ httpRequest }); + + const result = await resource.read('ui://mapbox/isochrone-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/isochrone-tool/IsochroneTool.test.ts b/test/tools/isochrone-tool/IsochroneTool.test.ts index d91eedb9..6c6b09b6 100644 --- a/test/tools/isochrone-tool/IsochroneTool.test.ts +++ b/test/tools/isochrone-tool/IsochroneTool.test.ts @@ -131,4 +131,62 @@ describe('IsochroneTool', () => { expect(result.content[0].type).toEqual('text'); expect(result.isError).toBe(true); }); + + it('rejects when both contours_minutes and contours_meters are provided', async () => { + const { httpRequest } = setupHttpRequest(); + const result = await new IsochroneTool({ httpRequest }).run({ + coordinates: { longitude: -74.006, latitude: 40.7128 }, + profile: 'mapbox/driving', + contours_minutes: [5], + contours_meters: [1000], + generalize: 1000 + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain( + 'only one of' + ); + }); + + it('rejects non-ascending contours_minutes', async () => { + const { httpRequest } = setupHttpRequest(); + const result = await new IsochroneTool({ httpRequest }).run({ + coordinates: { longitude: -74.006, latitude: 40.7128 }, + profile: 'mapbox/driving', + contours_minutes: [30, 10], + generalize: 1000 + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain('ascending'); + }); + + it('rejects non-ascending contours_meters', async () => { + const { httpRequest } = setupHttpRequest(); + const result = await new IsochroneTool({ httpRequest }).run({ + coordinates: { longitude: -74.006, latitude: 40.7128 }, + profile: 'mapbox/driving', + contours_meters: [5000, 1000], + generalize: 1000 + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain('ascending'); + }); + + it('rejects mismatched contours_colors length', async () => { + const { httpRequest } = setupHttpRequest(); + const result = await new IsochroneTool({ httpRequest }).run({ + coordinates: { longitude: -74.006, latitude: 40.7128 }, + profile: 'mapbox/driving', + contours_minutes: [5, 10], + contours_colors: ['ff0000'], + generalize: 1000 + }); + + expect(result.isError).toBe(true); + expect((result.content[0] as { text: string }).text).toContain( + 'contours_colors' + ); + }); });