From 95748936020339b9526d76ed8d3596619b4b6eaa Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 10:51:03 -0400 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20isochrone=5Fapp=5Ftool=20=E2=80=94?= =?UTF-8?q?=20interactive=20reachable-area=20MCP=20App?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to directions_app_tool that applies the same MCP Apps Resource pattern to isochrones. The tool calls Mapbox's Isochrone API and returns the polygon FeatureCollection plus a meta.ui.resourceUri pointing to a new IsochroneAppUIResource that renders each contour as a translucent fill + outline on a live Mapbox GL JS map, marks the origin, and fits the camera to the contours. Reuses the resolveMapboxPublicToken helper and CSP iframe pattern from the directions PR — once a host wires up MCP Apps for one of these tools, every subsequent app tool gets it for free. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 1 + src/resources/resourceRegistry.ts | 2 + .../ui-apps/IsochroneAppUIResource.ts | 379 ++++++++++++++++++ src/tools/index.ts | 5 + .../IsochroneAppTool.input.schema.ts | 54 +++ .../isochrone-app-tool/IsochroneAppTool.ts | 152 +++++++ src/tools/toolRegistry.ts | 2 + .../ui-apps/IsochroneAppUIResource.test.ts | 90 +++++ .../IsochroneAppTool.test.ts | 152 +++++++ 9 files changed, 837 insertions(+) create mode 100644 src/resources/ui-apps/IsochroneAppUIResource.ts create mode 100644 src/tools/isochrone-app-tool/IsochroneAppTool.input.schema.ts create mode 100644 src/tools/isochrone-app-tool/IsochroneAppTool.ts create mode 100644 test/resources/ui-apps/IsochroneAppUIResource.test.ts create mode 100644 test/tools/isochrone-app-tool/IsochroneAppTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f31772..f1c96bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ ### New Features +- **`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) - `category` argument on `find-places-nearby` — 482 Mapbox Search API categories diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 69a81f0..5fe62de 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 0000000..f43e3c4 --- /dev/null +++ b/src/resources/ui-apps/IsochroneAppUIResource.ts @@ -0,0 +1,379 @@ +// 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 Isochrone App MCP App. + * + * Receives the isochrone FeatureCollection from `isochrone_app_tool` via the + * MCP Apps postMessage protocol and renders each contour as a translucent fill + * (and outline) layer on a live Mapbox GL JS map, with the origin point marked. + */ +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 ?? '', + 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 renderIsochroneAppHtml(params: { + publicToken: string; + glVersion: string; +}): string { + const { publicToken, glVersion } = params; + + return ` + + + + +Isochrone Preview + + + + + +
+ +
Loading isochrone…
+ + + + +`; +} diff --git a/src/tools/index.ts b/src/tools/index.ts index e9eb48e..250ae1f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -38,6 +38,7 @@ export { CategorySearchTool } from './category-search-tool/CategorySearchTool.js 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 { DistanceTool } from './distance-tool/DistanceTool.js'; export { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; export { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -61,6 +62,7 @@ import { CategorySearchTool } from './category-search-tool/CategorySearchTool.js 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 { DistanceTool } from './distance-tool/DistanceTool.js'; import { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; @@ -102,6 +104,9 @@ export const directions = new DirectionsTool({ httpRequest }); /** Render a directions route on an interactive Mapbox GL JS map (MCP App) */ 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 }); + /** Calculate distance between points */ export const distance = new DistanceTool(); diff --git a/src/tools/isochrone-app-tool/IsochroneAppTool.input.schema.ts b/src/tools/isochrone-app-tool/IsochroneAppTool.input.schema.ts new file mode 100644 index 0000000..d7d1176 --- /dev/null +++ b/src/tools/isochrone-app-tool/IsochroneAppTool.input.schema.ts @@ -0,0 +1,54 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { coordinateSchema } from '../../schemas/shared.js'; + +export const IsochroneAppInputSchema = z + .object({ + profile: z + .enum([ + 'mapbox/driving', + 'mapbox/driving-traffic', + 'mapbox/walking', + 'mapbox/cycling' + ]) + .default('mapbox/driving') + .describe('Mode of travel.'), + coordinates: coordinateSchema.describe( + 'Center point of the isochrone (longitude, latitude).' + ), + contours_minutes: z + .array(z.number().int().min(1).max(60)) + .min(1) + .max(4) + .optional() + .describe( + 'Contour times in minutes, ascending. Either contours_minutes or contours_meters is required.' + ), + contours_meters: z + .array(z.number().int().min(1).max(100000)) + .min(1) + .max(4) + .optional() + .describe( + 'Contour distances in meters, ascending. Either contours_minutes or contours_meters is required.' + ), + contours_colors: z + .array(z.string().regex(/^[0-9a-fA-F]{6}$/)) + .max(4) + .optional() + .describe( + 'Hex colors (no leading #) for each contour. Length must match the contour values if provided.' + ) + }) + .refine( + (val) => + (val.contours_minutes && val.contours_minutes.length > 0) || + (val.contours_meters && val.contours_meters.length > 0), + { + message: 'Provide at least one of contours_minutes or contours_meters.' + } + ); + +export type IsochroneAppInput = z.infer; diff --git a/src/tools/isochrone-app-tool/IsochroneAppTool.ts b/src/tools/isochrone-app-tool/IsochroneAppTool.ts new file mode 100644 index 0000000..af0c78a --- /dev/null +++ b/src/tools/isochrone-app-tool/IsochroneAppTool.ts @@ -0,0 +1,152 @@ +// 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 { IsochroneAppInputSchema } from './IsochroneAppTool.input.schema.js'; + +// Docs: https://docs.mapbox.com/api/navigation/isochrone/ + +interface IsochroneFeature { + type: 'Feature'; + geometry?: { type: string; coordinates: unknown }; + properties?: { + contour?: number; + metric?: 'time' | 'distance'; + color?: string; + fill?: string; + fillColor?: string; + fillOpacity?: number; + }; +} + +interface IsochroneResponse { + type?: 'FeatureCollection'; + features?: IsochroneFeature[]; +} + +export class IsochroneAppTool extends MapboxApiBasedTool< + typeof IsochroneAppInputSchema +> { + name = 'isochrone_app_tool'; + description = + 'Render reachable-area isochrones on an interactive Mapbox GL JS map as an MCP App. ' + + 'Returns the isochrone polygons plus an MCP App resource reference that hosts ' + + '(Claude Desktop, VS Code, Cursor) render as a live map with the contours drawn as ' + + 'translucent fill layers and the origin point marked. Use this when the user asks ' + + '"how far can I get in X minutes" or anything that benefits from seeing the reachable ' + + 'area rather than reading raw GeoJSON.'; + annotations = { + title: 'Isochrone App Tool', + readOnlyHint: true, + destructiveHint: false, + 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({ + inputSchema: IsochroneAppInputSchema, + httpRequest: params.httpRequest + }); + } + + protected async execute( + input: z.infer, + accessToken: string + ): Promise { + const { longitude, latitude } = input.coordinates; + + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}isochrone/v1/${input.profile}/${longitude}%2C${latitude}` + ); + url.searchParams.set('access_token', accessToken); + url.searchParams.set('polygons', 'true'); + + if (input.contours_minutes && input.contours_minutes.length > 0) { + url.searchParams.set( + 'contours_minutes', + input.contours_minutes.join(',') + ); + } + if (input.contours_meters && input.contours_meters.length > 0) { + url.searchParams.set('contours_meters', input.contours_meters.join(',')); + } + if (input.contours_colors && input.contours_colors.length > 0) { + url.searchParams.set('contours_colors', input.contours_colors.join(',')); + } + + const response = await this.httpRequest(url.toString()); + if (!response.ok) { + const errorText = await this.getErrorMessage(response); + return { + content: [{ type: 'text', text: `Isochrone API error: ${errorText}` }], + isError: true + }; + } + + const data = (await response.json()) as IsochroneResponse; + if (!data.features?.length) { + return { + content: [ + { + type: 'text', + text: 'No isochrone contours returned for the given parameters.' + } + ], + isError: true + }; + } + + const summary = buildSummary(input, data.features); + + const payload = { + summary, + profile: input.profile, + origin: { longitude, latitude }, + featureCollection: { type: 'FeatureCollection', features: data.features } + }; + + return { + content: [ + { type: 'text', text: summary }, + { type: 'text', text: JSON.stringify(payload) } + ], + structuredContent: { isochrone: payload }, + isError: false, + _meta: { + viewUUID: randomUUID() + } + }; + } +} + +function buildSummary( + input: z.infer, + features: IsochroneFeature[] +): string { + const mode = input.profile.replace('mapbox/', '').replace('-', ' '); + if (input.contours_minutes && input.contours_minutes.length > 0) { + const labels = input.contours_minutes.map((m) => `${m} min`).join(', '); + return `Reachable by ${mode}: ${labels}`; + } + if (input.contours_meters && input.contours_meters.length > 0) { + const labels = input.contours_meters + .map((m) => (m >= 1000 ? `${(m / 1000).toFixed(1)} km` : `${m} m`)) + .join(', '); + return `Reachable by ${mode}: ${labels}`; + } + return `Isochrone: ${features.length} contour${features.length !== 1 ? 's' : ''}`; +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index a056bde..9dff0f2 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -25,6 +25,7 @@ import { CategoryListTool } from './category-list-tool/CategoryListTool.js'; import { CategorySearchTool } from './category-search-tool/CategorySearchTool.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 { IsochroneTool } from './isochrone-tool/IsochroneTool.js'; import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; import { MatrixTool } from './matrix-tool/MatrixTool.js'; @@ -63,6 +64,7 @@ export const CORE_TOOLS = [ new CategorySearchTool({ httpRequest }), new DirectionsTool({ httpRequest }), new DirectionsAppTool({ httpRequest }), + new IsochroneAppTool({ httpRequest }), new IsochroneTool({ httpRequest }), new MapMatchingTool({ httpRequest }), new MatrixTool({ httpRequest }), diff --git a/test/resources/ui-apps/IsochroneAppUIResource.test.ts b/test/resources/ui-apps/IsochroneAppUIResource.test.ts new file mode 100644 index 0000000..c138f70 --- /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-app-tool/IsochroneAppTool.test.ts b/test/tools/isochrone-app-tool/IsochroneAppTool.test.ts new file mode 100644 index 0000000..ae9d77d --- /dev/null +++ b/test/tools/isochrone-app-tool/IsochroneAppTool.test.ts @@ -0,0 +1,152 @@ +// 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 { IsochroneAppTool } from '../../../src/tools/isochrone-app-tool/IsochroneAppTool.js'; + +const fakeIsochroneResponse = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { contour: 15, metric: 'time', color: '3b82f6' }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-122.5, 37.7], + [-122.4, 37.7], + [-122.4, 37.8], + [-122.5, 37.8], + [-122.5, 37.7] + ] + ] + } + } + ] +}; + +function makeOkResponse(body: unknown): Partial { + return { + ok: true, + status: 200, + statusText: 'OK', + json: async () => body, + text: async () => JSON.stringify(body) + }; +} + +describe('IsochroneAppTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('returns isochrone summary, FeatureCollection, and structuredContent', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeIsochroneResponse) + ); + + const result = await new IsochroneAppTool({ httpRequest }).run({ + coordinates: { longitude: -122.45, latitude: 37.75 }, + contours_minutes: [15] + }); + + expect(result.isError).toBe(false); + expect(mockHttpRequest).toHaveBeenCalledTimes(1); + + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('isochrone/v1/mapbox/driving/'); + expect(calledUrl).toContain('-122.45%2C37.75'); + expect(calledUrl).toContain('polygons=true'); + expect(calledUrl).toContain('contours_minutes=15'); + + expect(result.content).toHaveLength(2); + const summary = (result.content[0] as { type: 'text'; text: string }).text; + expect(summary).toContain('15 min'); + + const payload = JSON.parse( + (result.content[1] as { type: 'text'; text: string }).text + ); + expect(payload.featureCollection.features).toHaveLength(1); + expect(payload.origin.longitude).toBe(-122.45); + + const structuredContent = ( + result as unknown as { structuredContent?: { isochrone?: unknown } } + ).structuredContent; + expect(structuredContent?.isochrone).toBeDefined(); + }); + + it('declares the MCP App resourceUri on meta', () => { + const { httpRequest } = setupHttpRequest(); + const tool = new IsochroneAppTool({ httpRequest }); + expect(tool.meta?.ui?.resourceUri).toBe( + 'ui://mapbox/isochrone-app/index.html' + ); + }); + + it('supports contours_meters and contours_colors', async () => { + const { httpRequest, mockHttpRequest } = setupHttpRequest( + makeOkResponse(fakeIsochroneResponse) + ); + + await new IsochroneAppTool({ httpRequest }).run({ + coordinates: { longitude: -122.45, latitude: 37.75 }, + contours_meters: [1000, 5000], + contours_colors: ['3b82f6', 'ef4444'], + profile: 'mapbox/walking' + }); + + const calledUrl = mockHttpRequest.mock.calls[0][0] as string; + expect(calledUrl).toContain('isochrone/v1/mapbox/walking/'); + expect(calledUrl).toContain('contours_meters=1000%2C5000'); + expect(calledUrl).toContain('contours_colors=3b82f6%2Cef4444'); + }); + + it('rejects input with neither contours_minutes nor contours_meters', async () => { + const { httpRequest } = setupHttpRequest(); + + const result = await new IsochroneAppTool({ httpRequest }).run({ + coordinates: { longitude: -122.45, latitude: 37.75 } + }); + + expect(result.isError).toBe(true); + }); + + it('returns an error when no contours are in the API response', async () => { + const { httpRequest } = setupHttpRequest( + makeOkResponse({ type: 'FeatureCollection', features: [] }) + ); + + const result = await new IsochroneAppTool({ httpRequest }).run({ + coordinates: { longitude: -122.45, latitude: 37.75 }, + contours_minutes: [15] + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('No isochrone contours'); + }); + + 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 IsochroneAppTool({ httpRequest }).run({ + coordinates: { longitude: -122.45, latitude: 37.75 }, + contours_minutes: [15] + }); + + expect(result.isError).toBe(true); + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('Isochrone API error'); + }); +}); From 9d89e281f594e1959bce81b674f25d5f3e1bc005 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 10:59:24 -0400 Subject: [PATCH 2/5] fix(isochrone_app): tighten camera padding so contours fill the viewport MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the directions_app padding change — directional padding so the summary chip stays clear at the top while the isochrone fills the rest. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/resources/ui-apps/IsochroneAppUIResource.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/resources/ui-apps/IsochroneAppUIResource.ts b/src/resources/ui-apps/IsochroneAppUIResource.ts index f43e3c4..3c867c8 100644 --- a/src/resources/ui-apps/IsochroneAppUIResource.ts +++ b/src/resources/ui-apps/IsochroneAppUIResource.ts @@ -364,7 +364,7 @@ function renderIsochroneAppHtml(params: { if (isFinite(minLng)) { map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { - padding: 60, + padding: { top: 70, bottom: 30, left: 30, right: 30 }, duration: 600 }); } From 3f170f11775bb13572449592ba280ef1b7cc6d5b Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Wed, 27 May 2026 11:43:19 -0400 Subject: [PATCH 3/5] fix(isochrone_app): defer fitBounds until after iframe resize Same pattern as directions: send size-changed first, wait 60ms, then map.resize() + fitBounds so the contours fill the final viewport. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ui-apps/IsochroneAppUIResource.ts | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/resources/ui-apps/IsochroneAppUIResource.ts b/src/resources/ui-apps/IsochroneAppUIResource.ts index 3c867c8..14d92e3 100644 --- a/src/resources/ui-apps/IsochroneAppUIResource.ts +++ b/src/resources/ui-apps/IsochroneAppUIResource.ts @@ -362,15 +362,20 @@ function renderIsochroneAppHtml(params: { .addTo(map); } + loadingEl.style.display = 'none'; + if (isFinite(minLng)) { - map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { - padding: { top: 70, bottom: 30, left: 30, right: 30 }, - duration: 600 - }); + requestSizeToFit(); + setTimeout(function() { + map.resize(); + map.fitBounds([[minLng, minLat], [maxLng, maxLat]], { + padding: { top: 70, bottom: 30, left: 30, right: 30 }, + duration: 600 + }); + }, 60); + } else { + requestSizeToFit(); } - - loadingEl.style.display = 'none'; - requestSizeToFit(); } })(); From 09d5ad72cb0bee16fbaa7846ec29d838759b1638 Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 16:13:22 -0400 Subject: [PATCH 4/5] refactor: fold MCP App support into isochrone_tool (drops isochrone_app_tool) Same pattern as #189: deletes the sibling isochrone_app_tool and lets the existing isochrone_tool emit both MCP App (meta.ui.resourceUri) and inline MCP-UI (createUIResource rawHtml) UI hints. One shared isochroneAppHtml.ts template renders the contours; the iframe handles both postMessage delivery and the >50KB temp-resource path via resources/read. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../ui-apps/IsochroneAppUIResource.ts | 313 +--------------- src/resources/ui-apps/isochroneAppHtml.ts | 350 ++++++++++++++++++ src/tools/isochrone-tool/IsochroneTool.ts | 117 +++++- 3 files changed, 454 insertions(+), 326 deletions(-) create mode 100644 src/resources/ui-apps/isochroneAppHtml.ts diff --git a/src/resources/ui-apps/IsochroneAppUIResource.ts b/src/resources/ui-apps/IsochroneAppUIResource.ts index 14d92e3..290109a 100644 --- a/src/resources/ui-apps/IsochroneAppUIResource.ts +++ b/src/resources/ui-apps/IsochroneAppUIResource.ts @@ -11,15 +11,13 @@ 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'; +import { renderIsochroneAppHtml } from './isochroneAppHtml.js'; /** - * Serves the HTML for the Isochrone App MCP App. - * - * Receives the isochrone FeatureCollection from `isochrone_app_tool` via the - * MCP Apps postMessage protocol and renders each contour as a translucent fill - * (and outline) layer on a live Mapbox GL JS map, with the origin point marked. + * 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'; @@ -57,10 +55,7 @@ export class IsochroneAppUIResource extends BaseResource { httpRequest: this.httpRequest }); - const html = renderIsochroneAppHtml({ - publicToken: publicToken ?? '', - glVersion: MAPBOX_GL_VERSION - }); + const html = renderIsochroneAppHtml({ publicToken: publicToken ?? '' }); return { contents: [ @@ -86,299 +81,3 @@ export class IsochroneAppUIResource extends BaseResource { }; } } - -function renderIsochroneAppHtml(params: { - publicToken: string; - glVersion: string; -}): string { - const { publicToken, glVersion } = params; - - return ` - - - - -Isochrone Preview - - - - - -
- -
Loading isochrone…
- - - - -`; -} diff --git a/src/resources/ui-apps/isochroneAppHtml.ts b/src/resources/ui-apps/isochroneAppHtml.ts new file mode 100644 index 0000000..ffbff9e --- /dev/null +++ b/src/resources/ui-apps/isochroneAppHtml.ts @@ -0,0 +1,350 @@ +// 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.ts b/src/tools/isochrone-tool/IsochroneTool.ts index 3348eda..eb3bd06 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({ @@ -178,29 +191,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 + } + }); } From 9f3ae3d42674bb880d23404ea7e3c18eb099f8cd Mon Sep 17 00:00:00 2001 From: Matthew Podwysocki Date: Mon, 1 Jun 2026 16:50:09 -0400 Subject: [PATCH 5/5] fix(isochrone_tool): address PR #190 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UI fixes in isochroneAppHtml.ts: - Guard origin marker on both longitude AND latitude being numeric (was longitude-only). A malformed postMessage payload with only one coordinate would have thrown mid-render and halted fitBounds. - Validate Mapbox API-supplied contour colors against the same hex-6 regex used in the schema; fall back to the default color instead of blindly prepending '#'. Protects against the API ever returning a CSS name or rgb form that would crash addLayer. Schema refinements in IsochroneTool.input.schema.ts — convert three constraints from generic Mapbox 422s into precise Zod errors: - Mutual exclusion: contours_minutes and contours_meters can't both be set (the API rejects this combo). - Ascending order: both contours_minutes and contours_meters must be strictly ascending (the descriptions already said so). - Length match: contours_colors length must equal the number of contours when both are provided. - Move the at-least-one check into a refine() and drop the now-dead runtime check from execute(). Four new tests cover each new refinement path. Co-Authored-By: Claude Opus 4.7 --- src/resources/ui-apps/isochroneAppHtml.ts | 15 +- .../IsochroneTool.input.schema.ts | 204 ++++++++++++------ src/tools/isochrone-tool/IsochroneTool.ts | 14 -- .../isochrone-tool/IsochroneTool.test.ts | 58 +++++ 4 files changed, 206 insertions(+), 85 deletions(-) diff --git a/src/resources/ui-apps/isochroneAppHtml.ts b/src/resources/ui-apps/isochroneAppHtml.ts index ffbff9e..3ecd5a1 100644 --- a/src/resources/ui-apps/isochroneAppHtml.ts +++ b/src/resources/ui-apps/isochroneAppHtml.ts @@ -296,9 +296,16 @@ ${initialDataScript} contourSourceIds = []; if (originMarker) { originMarker.remove(); originMarker = null; } + var HEX6_RE = /^[0-9a-fA-F]{6}$/; + function sanitizeHex(raw) { + if (typeof raw !== 'string') return '3b82f6'; + var bare = raw.replace(/^#/, ''); + return HEX6_RE.test(bare) ? bare : '3b82f6'; + } + features.forEach(function(feature, i) { var props = feature.properties || {}; - var color = '#' + (props.color || props.fillColor || '3b82f6').replace(/^#/, ''); + var color = '#' + sanitizeHex(props.color || props.fillColor); var fillOpacity = typeof props.fillOpacity === 'number' ? props.fillOpacity : 0.25; var sid = 'iso-source-' + i; @@ -319,7 +326,11 @@ ${initialDataScript} if (feature.geometry) walkCoords(feature.geometry.coordinates); }); - if (payload.origin && typeof payload.origin.longitude === 'number') { + if ( + payload.origin && + typeof payload.origin.longitude === 'number' && + typeof payload.origin.latitude === 'number' + ) { originMarker = new mapboxgl.Marker({ color: '#0f172a' }) .setLngLat([payload.origin.longitude, payload.origin.latitude]) .setPopup(new mapboxgl.Popup().setText('Origin')) diff --git a/src/tools/isochrone-tool/IsochroneTool.input.schema.ts b/src/tools/isochrone-tool/IsochroneTool.input.schema.ts index e1e20ab..56d75c0 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 eb3bd06..5a06e52 100644 --- a/src/tools/isochrone-tool/IsochroneTool.ts +++ b/src/tools/isochrone-tool/IsochroneTool.ts @@ -100,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', diff --git a/test/tools/isochrone-tool/IsochroneTool.test.ts b/test/tools/isochrone-tool/IsochroneTool.test.ts index d91eedb..6c6b09b 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' + ); + }); });