diff --git a/CHANGELOG.md b/CHANGELOG.md index fc5d7f78..69babce6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,33 @@ ## Unreleased +### New Features + +- **`render_map_tool` — single visualization primitive** for Mapbox MCP. Takes + a `MapAppPayload` and displays a live Mapbox GL JS map. All other geo + tools (directions, isochrone, optimization, search, map-matching, + ground-location, polygon-ops) return a ready-to-render `_mapApp` payload + on their `structuredContent`; the LLM passes it to `render_map_tool` to + show a map. This is the only tool that declares `_meta.ui.resourceUri`, + so MCP App hosts (which only fully render the iframe for the last tool + in a chained sequence) always render successfully — the visualization + step is terminal by design. +- **`MapAppPayload` schema** (`src/utils/mapAppPayload.ts`) — the wire + format between data tools and `render_map_tool`. Thin pass-through over + Mapbox Style spec `paint`/`layout` objects so any layer/marker/legend + combination expressible in GL JS is expressible in the payload. +- **Per-tool payload builders** — `buildDirectionsMapPayload`, + `buildIsochroneMapPayload`, `buildOptimizationMapPayload`, + `buildSearchMapPayload`, `buildMapMatchingPayload`, + `buildGroundLocationPayload`, `buildPolygonOpsMapPayload`. Each is a + pure function over its tool's response: ~20-80 lines, no HTML, no + iframe wiring. +- **Shared `renderMapAppHtml`** (`src/resources/ui-apps/mapAppHtml.ts`) — + one ~330-line iframe template that consumes any `MapAppPayload`. Used + by both the MCP Apps resource (`MapAppUIResource`) and any client that + wants to bake initial data in. +- **Polyline decoding moves tool-side** via `decodePolyline` / + `decodePolylineWithFallback` so the iframe only ever receives GeoJSON. + ### Security - chore: upgrade @opentelemetry/\* packages to latest (fixes protobufjs GHSA-xq3m-2v4x-88gg critical CVE) (#183) diff --git a/package-lock.json b/package-lock.json index eb482ab4..e69d1c5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,6 @@ "hasInstallScript": true, "license": "MIT", "dependencies": { - "@mcp-ui/server": "^6.1.0", "@modelcontextprotocol/ext-apps": "^1.1.1", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.1", @@ -1436,59 +1435,6 @@ "url": "https://opencollective.com/js-sdsl" } }, - "node_modules/@mcp-ui/server": { - "version": "6.1.0", - "resolved": "https://registry.npmjs.org/@mcp-ui/server/-/server-6.1.0.tgz", - "integrity": "sha512-uxv9JrzEzHfdGo/V/KTFKp5WeCOsaiAoQZ3V88jw1dSXUofONEw4rMwEvVP3612aj/unS2TIeM0o1I0asfIxtw==", - "license": "Apache-2.0", - "dependencies": { - "@modelcontextprotocol/ext-apps": "^0.3.1", - "@modelcontextprotocol/sdk": "^1.25.1" - } - }, - "node_modules/@mcp-ui/server/node_modules/@modelcontextprotocol/ext-apps": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-0.3.1.tgz", - "integrity": "sha512-Iivz2KwWK8xlRbiWwFB/C4NXqE8VJBoRCbBkJCN98ST2UbQvA6kfyebcLsypiqylJS467XOOaBcI9DeQ3t+zqA==", - "hasInstallScript": true, - "license": "MIT", - "workspaces": [ - "examples/*" - ], - "optionalDependencies": { - "@oven/bun-darwin-aarch64": "^1.2.21", - "@oven/bun-darwin-x64": "^1.2.21", - "@oven/bun-darwin-x64-baseline": "^1.2.21", - "@oven/bun-linux-aarch64": "^1.2.21", - "@oven/bun-linux-aarch64-musl": "^1.2.21", - "@oven/bun-linux-x64": "^1.2.21", - "@oven/bun-linux-x64-baseline": "^1.2.21", - "@oven/bun-linux-x64-musl": "^1.2.21", - "@oven/bun-linux-x64-musl-baseline": "^1.2.21", - "@oven/bun-windows-x64": "^1.2.21", - "@oven/bun-windows-x64-baseline": "^1.2.21", - "@rollup/rollup-darwin-arm64": "^4.53.3", - "@rollup/rollup-darwin-x64": "^4.53.3", - "@rollup/rollup-linux-arm64-gnu": "^4.53.3", - "@rollup/rollup-linux-x64-gnu": "^4.53.3", - "@rollup/rollup-win32-arm64-msvc": "^4.53.3", - "@rollup/rollup-win32-x64-msvc": "^4.53.3" - }, - "peerDependencies": { - "@modelcontextprotocol/sdk": "^1.24.0", - "react": "^17.0.0 || ^18.0.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0", - "zod": "^3.25.0 || ^4.0.0" - }, - "peerDependenciesMeta": { - "react": { - "optional": true - }, - "react-dom": { - "optional": true - } - } - }, "node_modules/@modelcontextprotocol/ext-apps": { "version": "1.5.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/ext-apps/-/ext-apps-1.5.0.tgz", @@ -2918,149 +2864,6 @@ "@opentelemetry/api": "^1.1.0" } }, - "node_modules/@oven/bun-darwin-aarch64": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-aarch64/-/bun-darwin-aarch64-1.3.12.tgz", - "integrity": "sha512-b6CQgT28Jx7uDwMTcGo7WFqUd1+wWTdp8XyPi/4LRcL/R4deKT7cLx/Q2ZCWAiK6ZU7yexoCaIaKun6azjRLVA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64/-/bun-darwin-x64-1.3.12.tgz", - "integrity": "sha512-//6W21c+GinAMMmxD2hFrFmJH+ZlEwJYbLzAGqp0mLFTli9y74RMtDgI2n9pCupXSpU1Kr1sSylVW9yNbAG9Xg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-darwin-x64-baseline": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-darwin-x64-baseline/-/bun-darwin-x64-baseline-1.3.12.tgz", - "integrity": "sha512-9jKJNOc9ID3BxPBPR4r1Mp1Wqde89Twi5zo2LoEMLMKbqpvEM/WUGdJ0Vv7OX1QPEqVblFO6NMky5yY7rjDI2w==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@oven/bun-linux-aarch64": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64/-/bun-linux-aarch64-1.3.12.tgz", - "integrity": "sha512-eTru6tk3K4Ya3SSkUqq/LbdEjwPqLlfINmIhRORrCExBdB1tQbk+WYYflaymO61fkrjnMAjmLTGqk/K37RMIGA==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-aarch64-musl": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-aarch64-musl/-/bun-linux-aarch64-musl-1.3.12.tgz", - "integrity": "sha512-HWIwFzm5fALd9Lli0CgaKb6xOGqODYyHpUTgkn/IHHuS/f3XDCu71+GgkyvfgCYbPoBSgBOfp5TzhRehPcgxow==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64/-/bun-linux-x64-1.3.12.tgz", - "integrity": "sha512-H75bcEn46lMDxd+P+R6Q/jlIKl/YO0ZXaalSyWhQHr7qNmFhQt3rOHurFoCxuwQeqFoToh0JpWVyMVzByZqgBQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-baseline": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-baseline/-/bun-linux-x64-baseline-1.3.12.tgz", - "integrity": "sha512-0y+lUiQsPvSGsyM/10KtxhVAQ20p6/D+vj01l6vo9gHpYUpyc1L9pSgaPa7SC9TuaiGASlM3Cb62bmSKW0E/3Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl/-/bun-linux-x64-musl-1.3.12.tgz", - "integrity": "sha512-Zb7T3JxWlArSe44ATO5mtjLCBCt7kenWPl9CYD+zeqq9kHswMv8Cd3h/9uzdv2PA4Flrq57J5XBSuRdStTCXCw==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-linux-x64-musl-baseline": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-linux-x64-musl-baseline/-/bun-linux-x64-musl-baseline-1.3.12.tgz", - "integrity": "sha512-jdsnuFD3H0l4AHtf1nInRHYWIMTWqok0aW8WysjzN5Isn6rBTBGK/ZWX6XjdTgDgcuVbVOYHiLUHHrvT9N6psA==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@oven/bun-windows-x64": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64/-/bun-windows-x64-1.3.12.tgz", - "integrity": "sha512-veSntY7pDLDh4XmxZMwTqxfoEVp0BDdeqCBoWL46/TigtniPtDFSTIWBxa6l/RcGzklUA/uqLqmsK/9cBZAm8Q==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@oven/bun-windows-x64-baseline": { - "version": "1.3.12", - "resolved": "https://registry.npmjs.org/@oven/bun-windows-x64-baseline/-/bun-windows-x64-baseline-1.3.12.tgz", - "integrity": "sha512-rV21md7QWnu3r/shev7IFMh6hX8BJHwofxESAofUT4yH866oCIbcNbzp6+fxrj4oGD8uisP6WoaTCboijv9yYg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@oxc-project/types": { "version": "0.124.0", "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.124.0.tgz", @@ -3412,84 +3215,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", - "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", - "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", - "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", - "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", - "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", - "cpu": [ - "arm64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", - "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", - "cpu": [ - "x64" - ], - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, "node_modules/@standard-schema/spec": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", diff --git a/package.json b/package.json index af627255..23948154 100644 --- a/package.json +++ b/package.json @@ -76,7 +76,6 @@ "mcp" ], "dependencies": { - "@mcp-ui/server": "^6.1.0", "@modelcontextprotocol/ext-apps": "^1.1.1", "@modelcontextprotocol/sdk": "^1.29.0", "@opentelemetry/api": "^1.9.1", diff --git a/src/config/toolConfig.ts b/src/config/toolConfig.ts index 7f6802c3..75881b70 100644 --- a/src/config/toolConfig.ts +++ b/src/config/toolConfig.ts @@ -6,18 +6,12 @@ import type { ToolInstance } from '../tools/toolRegistry.js'; export interface ToolConfig { enabledTools?: string[]; disabledTools?: string[]; - enableMcpUi?: boolean; } export function parseToolConfigFromArgs(): ToolConfig { const args = process.argv.slice(2); const config: ToolConfig = {}; - // Check environment variable first (takes precedence) - if (process.env.ENABLE_MCP_UI !== undefined) { - config.enableMcpUi = process.env.ENABLE_MCP_UI === 'true'; - } - for (let i = 0; i < args.length; i++) { const arg = args[i]; @@ -31,19 +25,9 @@ export function parseToolConfigFromArgs(): ToolConfig { if (value) { config.disabledTools = value.split(',').map((t) => t.trim()); } - } else if (arg === '--disable-mcp-ui') { - // Command-line flag can disable it if env var not set - if (config.enableMcpUi === undefined) { - config.enableMcpUi = false; - } } } - // Default to true if not set (enabled by default) - if (config.enableMcpUi === undefined) { - config.enableMcpUi = true; - } - return config; } @@ -72,27 +56,3 @@ export function filterTools( return filteredTools; } - -/** - * Check if MCP-UI support is enabled. - * MCP-UI is enabled by default and can be explicitly disabled via: - * - Environment variable: ENABLE_MCP_UI=false - * - Command-line flag: --disable-mcp-ui - * - * @returns true if MCP-UI is enabled (default), false if explicitly disabled - */ -export function isMcpUiEnabled(): boolean { - // Check environment variable first (takes precedence) - if (process.env.ENABLE_MCP_UI === 'false') { - return false; - } - - // Check command-line arguments - const args = process.argv.slice(2); - if (args.includes('--disable-mcp-ui')) { - return false; - } - - // Default to enabled - return true; -} diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 69a81f03..7f6a768c 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -5,7 +5,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 { MapAppUIResource } from './ui-apps/MapAppUIResource.js'; import { VersionResource } from './version/VersionResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -15,7 +15,8 @@ export const ALL_RESOURCES = [ new CategoryListResource({ httpRequest }), new TemporaryDataResource(), new StaticMapUIResource(), - new DirectionsAppUIResource({ httpRequest }), + // Single shared map renderer, targeted exclusively by render_map_tool. + new MapAppUIResource({ httpRequest }), new VersionResource() ] as const; diff --git a/src/resources/ui-apps/DirectionsAppUIResource.ts b/src/resources/ui-apps/MapAppUIResource.ts similarity index 69% rename from src/resources/ui-apps/DirectionsAppUIResource.ts rename to src/resources/ui-apps/MapAppUIResource.ts index f2fc361b..0e4bf41c 100644 --- a/src/resources/ui-apps/DirectionsAppUIResource.ts +++ b/src/resources/ui-apps/MapAppUIResource.ts @@ -11,23 +11,22 @@ 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 { renderDirectionsAppHtml } from './directionsAppHtml.js'; +import { renderMapAppHtml } from './mapAppHtml.js'; /** - * MCP Apps resource for `directions_tool` — serves the HTML at - * `ui://mapbox/directions-app/index.html`. The iframe waits for the host to - * deliver the tool result via the `ui/notifications/tool-result` postMessage - * event and renders the route from `structuredContent.routes[0]`. + * Single Mapbox MCP App resource targeted by `render_map_tool`. * - * The legacy MCP-UI pathway (inline `rawHtml` on the tool result) uses the - * same HTML template via `renderDirectionsAppHtml` with geometry baked in at - * tool-execute time. + * Only one tool (`render_map_tool`) points `_meta.ui.resourceUri` at this + * resource. All other Mapbox tools just return data; the LLM passes their + * `mapboxRender` payload to `render_map_tool` to display it. This sidesteps the + * chain-position rendering quirk in MCP App hosts (where intermediate + * tools in a chain don't get to render their own iframe). */ -export class DirectionsAppUIResource extends BaseResource { - readonly name = 'Directions App UI'; - readonly uri = 'ui://mapbox/directions-app/index.html'; +export class MapAppUIResource extends BaseResource { + readonly name = 'Mapbox Map App UI'; + readonly uri = 'ui://mapbox/map-app/index.html'; readonly description = - 'Interactive UI for visualizing a Mapbox directions route with Mapbox GL JS (MCP Apps)'; + 'Generic Mapbox GL JS renderer driven by render_map_tool payloads (MCP Apps)'; readonly mimeType = RESOURCE_MIME_TYPE; private readonly httpRequest: HttpRequest; @@ -59,9 +58,7 @@ export class DirectionsAppUIResource extends BaseResource { httpRequest: this.httpRequest }); - const html = renderDirectionsAppHtml({ - publicToken: publicToken ?? '' - }); + const html = renderMapAppHtml({ publicToken: publicToken ?? '' }); return { contents: [ diff --git a/src/resources/ui-apps/directionsAppHtml.ts b/src/resources/ui-apps/directionsAppHtml.ts deleted file mode 100644 index 6883ea7a..00000000 --- a/src/resources/ui-apps/directionsAppHtml.ts +++ /dev/null @@ -1,472 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -/** - * Render the directions MCP App HTML. - * - * The same template is consumed by two ingress paths: - * - * 1. **MCP Apps spec** — `DirectionsAppUIResource` reads this resource at - * `ui://mapbox/directions-app/index.html`. The iframe loads, the agent's - * tool result is delivered via the `ui/notifications/tool-result` - * postMessage event, and `extractRoute()` pulls the route out of - * `structuredContent.routes[0]`. - * - * 2. **Legacy MCP-UI spec** — `directions_tool` inlines a `rawHtml` - * UIResource into its content array (gated by `isMcpUiEnabled()`). The - * HTML is generated at tool-execute time with the route geometry already - * baked in as an `initialData` script block, so the iframe renders - * immediately without needing the host to deliver the tool result. - * - * One source of truth for the rendering logic; two slim entry conditions. - */ - -export const MAPBOX_GL_VERSION = '3.12.0'; - -export interface DirectionsAppInitialData { - geometry: { type: string; coordinates: [number, number][] }; - summary?: string; -} - -export function renderDirectionsAppHtml(params: { - publicToken: string; - glVersion?: string; - initialData?: DirectionsAppInitialData; -}): string { - const { publicToken, initialData } = params; - const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; - - const initialDataScript = initialData - ? `` - : ''; - - return ` - - - - -Directions Preview - - - - - -
- -
Loading directions…
- -${initialDataScript} - - - -`; -} - -function escapeForScript(s: string): string { - // Prevent inside JSON from breaking out of the script tag. - return s.replace(/<\/script>/gi, '<\\/script>'); -} diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts new file mode 100644 index 00000000..78cc06f2 --- /dev/null +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -0,0 +1,484 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { MapAppPayload } from '../../utils/mapAppPayload.js'; + +/** + * Render the generic Mapbox MCP App HTML — used by both the MCP Apps + * resource (postMessage delivery) and any tool's inline MCP-UI rawHtml + * block (initial-data baked in). + * + * The iframe is a thin renderer over Mapbox GL JS. Tools produce a + * `MapAppPayload` (see src/utils/mapAppPayload.ts) and the iframe + * translates each layer/marker/legend entry into the corresponding + * GL JS call. No tool-specific code lives in this file. + */ + +export const MAPBOX_GL_VERSION = '3.12.0'; + +export function renderMapAppHtml(params: { + publicToken: string; + glVersion?: string; + initialData?: MapAppPayload; +}): string { + const { publicToken, initialData } = params; + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + + const initialDataScript = initialData + ? `` + : ''; + + return ` + + + + +Map + + + + + +
+ + +
Loading…
+ +${initialDataScript} + + + +`; +} + +function escapeForScript(s: string): string { + return s.replace(/<\/script>/gi, '<\\/script>'); +} diff --git a/src/tools/category-search-tool/CategorySearchTool.output.schema.ts b/src/tools/category-search-tool/CategorySearchTool.output.schema.ts index 9aec7c60..790728a8 100644 --- a/src/tools/category-search-tool/CategorySearchTool.output.schema.ts +++ b/src/tools/category-search-tool/CategorySearchTool.output.schema.ts @@ -189,11 +189,14 @@ const FeatureSchema = z .passthrough(); // Main Search Box API Category Search response schema (FeatureCollection) +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + export const CategorySearchResponseSchema = z .object({ type: z.literal('FeatureCollection'), features: z.array(FeatureSchema), - attribution: z.string() + attribution: z.string(), + mapboxRender: MapAppRefSchema.optional() }) .passthrough(); diff --git a/src/tools/category-search-tool/CategorySearchTool.ts b/src/tools/category-search-tool/CategorySearchTool.ts index c190cae5..5e9922d9 100644 --- a/src/tools/category-search-tool/CategorySearchTool.ts +++ b/src/tools/category-search-tool/CategorySearchTool.ts @@ -11,6 +11,8 @@ import type { MapboxFeatureCollection, MapboxFeature } from '../../schemas/geojson.js'; +import { buildSearchMapPayload } from '../search-and-geocode-tool/buildSearchMapPayload.js'; +import { storeMapPayload, renderHint } from '../../utils/storeMapPayload.js'; // API Documentation: https://docs.mapbox.com/api/search/search-box/#category-search @@ -28,7 +30,6 @@ export class CategorySearchTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; - constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: CategorySearchInputSchema, @@ -181,18 +182,41 @@ export class CategorySearchTool extends MapboxApiBasedTool< data = rawData as MapboxFeatureCollection; } - if (input.format === 'json_string') { - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: data as unknown as Record, - isError: false - }; - } else { - return { - content: [{ type: 'text', text: this.formatGeoJsonToText(data) }], - structuredContent: data as unknown as Record, - isError: false - }; + const baseText = + input.format === 'json_string' + ? JSON.stringify(data, null, 2) + : this.formatGeoJsonToText(data); + + const proximity = + input.proximity && + typeof (input.proximity as { longitude?: number }).longitude === 'number' + ? (input.proximity as { longitude: number; latitude: number }) + : undefined; + const payload = buildSearchMapPayload({ + data, + query: input.category, + proximity + }); + + const sc: Record = { + ...(data as unknown as Record) + }; + let textOut = baseText; + if (payload) { + const ref = storeMapPayload(payload); + sc.mapboxRender = { ref }; + // Don't append the human-readable hint when the user requested JSON + // output — it would break round-trip parsing. Callers that pass + // json_string usually already know about render_map_tool. + if (input.format !== 'json_string') { + textOut += renderHint(ref); + } } + + return { + content: [{ type: 'text', text: textOut }], + structuredContent: sc, + isError: false + }; } } diff --git a/src/tools/difference-tool/DifferenceTool.output.schema.ts b/src/tools/difference-tool/DifferenceTool.output.schema.ts index 548d5ad0..c09c365f 100644 --- a/src/tools/difference-tool/DifferenceTool.output.schema.ts +++ b/src/tools/difference-tool/DifferenceTool.output.schema.ts @@ -3,18 +3,23 @@ import { z } from 'zod'; -export const DifferenceOutputSchema = z.object({ - has_difference: z - .boolean() - .describe( - 'Whether any area remains after subtracting polygon2 from polygon1' - ), - geometry: z - .record(z.string(), z.unknown()) - .nullable() - .describe( - 'GeoJSON geometry of the remaining area (polygon1 minus polygon2), or null if polygon2 fully covers polygon1' - ) -}); +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + +export const DifferenceOutputSchema = z + .object({ + has_difference: z + .boolean() + .describe( + 'Whether any area remains after subtracting polygon2 from polygon1' + ), + geometry: z + .record(z.string(), z.unknown()) + .nullable() + .describe( + 'GeoJSON geometry of the remaining area (polygon1 minus polygon2), or null if polygon2 fully covers polygon1' + ), + mapboxRender: MapAppRefSchema.optional() + }) + .passthrough(); export type DifferenceOutput = z.infer; diff --git a/src/tools/difference-tool/DifferenceTool.ts b/src/tools/difference-tool/DifferenceTool.ts index 0f120684..9d25beb3 100644 --- a/src/tools/difference-tool/DifferenceTool.ts +++ b/src/tools/difference-tool/DifferenceTool.ts @@ -11,6 +11,8 @@ import { type DifferenceOutput } from './DifferenceTool.output.schema.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { buildPolygonOpsMapPayload } from '../union-tool/buildPolygonOpsMapPayload.js'; +import { storeMapPayload, renderHint } from '../../utils/storeMapPayload.js'; export class DifferenceTool extends BaseTool< typeof DifferenceInputSchema, @@ -21,7 +23,9 @@ export class DifferenceTool extends BaseTool< 'Subtract one polygon from another, returning the area in polygon1 that is not covered by polygon2. ' + 'Useful for computing exclusion zones, finding uncovered service areas, or "what is in zone A but not zone B?". ' + 'Returns null geometry if polygon2 fully covers polygon1. ' + - 'Works offline without API calls.'; + 'Works offline without API calls. ' + + 'INPUT SHAPE: `polygon1` and `polygon2` are each an array of rings; each ring is an array of [lng, lat] pairs. ' + + 'When chaining with isochrone_tool, extract `feature.geometry.coordinates` from each isochrone Feature (with `polygons=true`).'; readonly annotations = { title: 'Difference of Polygons', @@ -63,12 +67,36 @@ export class DifferenceTool extends BaseTool< ? `Difference computed (area in polygon1 not covered by polygon2).\nGeometry:\n${JSON.stringify(validated.geometry, null, 2)}` : 'No difference: polygon2 fully covers polygon1.'; + const mapPayload = buildPolygonOpsMapPayload({ + operation: 'difference', + inputs: [poly1, poly2] as Array<{ + type: 'Feature'; + geometry: unknown; + }>, + result: (result ?? null) as { + type: 'Feature'; + geometry: unknown; + } | null, + summary: validated.has_difference + ? 'Difference of two polygons (polygon1 minus polygon2)' + : 'polygon2 fully covers polygon1 (no difference)' + }); + const sc: Record = { + ...(validated as unknown as Record) + }; + let textOut = text; + if (mapPayload) { + const ref = storeMapPayload(mapPayload); + sc.mapboxRender = { ref }; + textOut += renderHint(ref); + } + toolContext.span.setStatus({ code: SpanStatusCode.OK }); toolContext.span.end(); return { - content: [{ type: 'text' as const, text }], - structuredContent: validated, + content: [{ type: 'text' as const, text: textOut }], + structuredContent: sc, isError: false }; } catch (error) { diff --git a/src/tools/directions-tool/DirectionsTool.output.schema.ts b/src/tools/directions-tool/DirectionsTool.output.schema.ts index 3e4016d5..80f5cd1b 100644 --- a/src/tools/directions-tool/DirectionsTool.output.schema.ts +++ b/src/tools/directions-tool/DirectionsTool.output.schema.ts @@ -367,13 +367,20 @@ const CleanedWaypointSchema = z.object({ metadata: WaypointMetadataSchema.nullable().optional() }); -// Main Directions API response schema -export const DirectionsResponseSchema = z.object({ - routes: z.array(RouteSchema).optional(), // Can be missing if no route found - waypoints: z.array(CleanedWaypointSchema).optional(), // Modified waypoints with renamed fields - code: z.string().optional(), // Removed by cleanResponseData for token efficiency - uuid: z.string().optional() // Removed by cleanResponseData for token efficiency -}); +// Main Directions API response schema. `mapboxRender` is declared so hosts that +// strictly validate tool results against the published JSON Schema don't +// flag the response as malformed when the field is attached. +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + +export const DirectionsResponseSchema = z + .object({ + routes: z.array(RouteSchema).optional(), // Can be missing if no route found + waypoints: z.array(CleanedWaypointSchema).optional(), // Modified waypoints with renamed fields + code: z.string().optional(), // Removed by cleanResponseData for token efficiency + uuid: z.string().optional(), // Removed by cleanResponseData for token efficiency + mapboxRender: MapAppRefSchema.optional() + }) + .passthrough(); export type DirectionsResponse = z.infer; export type Route = z.infer; diff --git a/src/tools/directions-tool/DirectionsTool.ts b/src/tools/directions-tool/DirectionsTool.ts index e7490706..33c75cc8 100644 --- a/src/tools/directions-tool/DirectionsTool.ts +++ b/src/tools/directions-tool/DirectionsTool.ts @@ -2,9 +2,8 @@ // Licensed under the MIT License. import { URLSearchParams } from 'node:url'; -import { randomBytes, randomUUID } from 'node:crypto'; +import { randomBytes } 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 { cleanResponseData } from './cleanResponseData.js'; @@ -16,9 +15,11 @@ import { } from './DirectionsTool.output.schema.js'; import type { HttpRequest } from '../..//utils/types.js'; import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js'; -import { isMcpUiEnabled } from '../../config/toolConfig.js'; -import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; -import { renderDirectionsAppHtml } from '../../resources/ui-apps/directionsAppHtml.js'; +import { + decodePolylineWithFallback, + type MapAppPayload +} from '../../utils/mapAppPayload.js'; +import { storeMapPayload, renderHint } from '../../utils/storeMapPayload.js'; // Docs: https://docs.mapbox.com/api/navigation/directions/ @@ -39,16 +40,6 @@ export class DirectionsTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; - readonly meta = { - ui: { - resourceUri: 'ui://mapbox/directions-app/index.html', - csp: { - connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], - resourceDomains: ['https://api.mapbox.com'] - } - } - }; - constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: DirectionsInputSchema, @@ -292,6 +283,10 @@ export class DirectionsTool extends MapboxApiBasedTool< const responseText = JSON.stringify(validatedData, null, 2); const responseSize = responseText.length; + // Build the map-app payload from the full geometry before we conditionally + // strip it for the large-response path — the iframe needs the route line. + const mapPayloadFull = buildDirectionsMapPayload(validatedData); + if (responseSize > RESPONSE_SIZE_THRESHOLD) { // Create temporary resource for large response const resourceId = randomBytes(16).toString('hex'); @@ -318,7 +313,7 @@ Waypoints: ${waypointCount} ${responseSize > RESPONSE_SIZE_THRESHOLD ? `\n⚠️ Full response (${Math.round(responseSize / 1024)}KB) exceeds context limit.\n\nFull geometry and details stored as temporary resource.\nResource URI: ${resourceUri}\nTTL: 30 minutes\n\nUse the MCP resource API to retrieve full details if needed.\nOr ask to read the resource by its URI.` : ''}`; // Create minimal structured content for validation (without large geometry) - const summaryStructuredContent = { + const summaryStructuredContent: Record = { ...validatedData, routes: validatedData.routes?.map((route) => ({ distance: route.distance, @@ -337,102 +332,109 @@ ${responseSize > RESPONSE_SIZE_THRESHOLD ? `\n⚠️ Full response (${Math.round legs: undefined })) }; + // Stash the map payload server-side and only return a short ref so + // the LLM doesn't have to re-emit thousands of coordinate pairs as + // input to render_map_tool. Echo the ref in the visible text so the + // LLM doesn't hallucinate the URI. + let largeText = summaryText; + if (mapPayloadFull) { + const ref = storeMapPayload(mapPayloadFull); + summaryStructuredContent.mapboxRender = { ref }; + largeText += renderHint(ref); + } return { - content: [{ type: 'text', text: summaryText }], + content: [{ type: 'text', text: largeText }], structuredContent: summaryStructuredContent, isError: false }; } - // Small response - return normally - const content: CallToolResult['content'] = [ - { type: 'text', text: responseText } - ]; - - // Legacy MCP-UI: inline a rawHtml UIResource so non-MCP-Apps clients - // also get a live GL JS map. Only works when the response actually - // carries a renderable GeoJSON geometry — i.e. geometries=geojson. - if (isMcpUiEnabled()) { - const inlineHtml = await tryRenderInlineUiHtml( - validatedData, - accessToken, - this.httpRequest - ); - if (inlineHtml) { - content.push( - createUIResource({ - uri: `ui://mapbox/directions/${randomUUID()}`, - content: { type: 'rawHtml', htmlString: inlineHtml }, - encoding: 'text', - uiMetadata: { - 'preferred-frame-size': ['100%', '500px'] - } - }) - ); - } - } - + // Small response - return normally. The map payload is stored + // server-side; structuredContent.mapboxRender carries a short ref the LLM + // can pass to `render_map_tool` to display the route on a live Mapbox + // GL JS map (avoids re-emitting the full polyline through the model). + const mapPayload = mapPayloadFull; + const smallRef = mapPayload ? storeMapPayload(mapPayload) : null; return { - content, - structuredContent: validatedData, + content: [ + { + type: 'text', + text: responseText + (smallRef ? renderHint(smallRef) : '') + } + ], + structuredContent: smallRef + ? { ...validatedData, mapboxRender: { ref: smallRef } } + : validatedData, isError: false }; } } /** - * Try to render the same DirectionsAppHtml as the MCP Apps resource, but - * with the route geometry baked in so MCP-UI clients (which don't fetch - * external resources) can render inline. Returns undefined when the - * response has no GeoJSON geometry to render or no public token can be - * resolved — the caller falls back to text-only output. + * Build a generic `MapAppPayload` from a Directions API response: + * - one `line` layer for the route + * - start/end markers (badge style) + * - summary chip with miles + minutes + * + * Returns null when the response has no renderable geometry (e.g. + * `geometries=none` was requested or the polyline failed to decode). */ -async function tryRenderInlineUiHtml( - data: DirectionsResponse, - accessToken: string, - httpRequest: HttpRequest -): Promise { +function buildDirectionsMapPayload( + data: DirectionsResponse +): MapAppPayload | null { const route = data.routes?.[0]; - const geometry = route?.geometry; - // Accept either a GeoJSON LineString object or a polyline string — the - // iframe normalizes both shapes before rendering. - const hasGeojson = - geometry && - typeof geometry === 'object' && - (geometry as { type?: string }).type === 'LineString' && - Array.isArray((geometry as { coordinates?: unknown }).coordinates); - const hasPolyline = typeof geometry === 'string' && geometry.length > 0; - if (!hasGeojson && !hasPolyline) { - return undefined; + if (!route) return null; + + // Normalize geometry to GeoJSON LineString — handles both + // geometries=geojson (object) and geometries=polyline/polyline6 (string). + let coords: [number, number][] | null = null; + const g = route.geometry as unknown; + if ( + g && + typeof g === 'object' && + (g as { type?: string }).type === 'LineString' && + Array.isArray((g as { coordinates?: unknown }).coordinates) + ) { + coords = (g as { coordinates: [number, number][] }).coordinates; + } else if (typeof g === 'string' && g.length > 0) { + coords = decodePolylineWithFallback(g); } - - const publicToken = await resolveMapboxPublicToken({ - accessToken, - apiEndpoint: MapboxApiBasedTool.mapboxApiEndpoint, - httpRequest - }); - if (!publicToken) return undefined; + if (!coords || coords.length === 0) return null; const summaryParts: string[] = []; - if (typeof route?.distance === 'number') { + if (typeof route.distance === 'number') { summaryParts.push(`${(route.distance / 1609.34).toFixed(1)} mi`); } - if (typeof route?.duration === 'number') { + if (typeof route.duration === 'number') { summaryParts.push(`${Math.round(route.duration / 60)} min`); } const summary = summaryParts.length ? `Route: ${summaryParts.join(', ')}` : 'Route'; - return renderDirectionsAppHtml({ - publicToken, - initialData: { - geometry: geometry as unknown as { - type: string; - coordinates: [number, number][]; - }, - summary - } - }); + return { + summary, + layers: [ + { + id: 'route', + type: 'line', + data: { + type: 'Feature', + geometry: { type: 'LineString', coordinates: coords }, + properties: {} + }, + paint: { 'line-color': '#3b82f6', 'line-width': 5 }, + layout: { 'line-join': 'round', 'line-cap': 'round' } + } + ], + markers: [ + { coordinates: coords[0], style: 'start', popup: 'Start' }, + { + coordinates: coords[coords.length - 1], + style: 'end', + popup: 'End' + } + ] + }; } diff --git a/src/tools/ground-location-tool/GroundLocationTool.output.schema.ts b/src/tools/ground-location-tool/GroundLocationTool.output.schema.ts index 182273dc..51159f7b 100644 --- a/src/tools/ground-location-tool/GroundLocationTool.output.schema.ts +++ b/src/tools/ground-location-tool/GroundLocationTool.output.schema.ts @@ -18,23 +18,28 @@ export const IsochroneSummarySchema = z.object({ contour_areas_sqkm: z.array(z.number()).optional() }); -export const GroundLocationOutputSchema = z.object({ - place: z - .string() - .describe('Human-readable place name from reverse geocoding'), - full_address: z.string().optional().describe('Full address if available'), - longitude: z.number(), - latitude: z.number(), - nearby_pois: z - .array(PoiSchema) - .optional() - .describe('Nearby points of interest matching the query'), - isochrone: IsochroneSummarySchema.optional().describe( - 'Travel-time reachability summary' - ), - citations: z - .array(z.string()) - .describe('Mapbox APIs used to produce this grounded response') -}); +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + +export const GroundLocationOutputSchema = z + .object({ + place: z + .string() + .describe('Human-readable place name from reverse geocoding'), + full_address: z.string().optional().describe('Full address if available'), + longitude: z.number(), + latitude: z.number(), + nearby_pois: z + .array(PoiSchema) + .optional() + .describe('Nearby points of interest matching the query'), + isochrone: IsochroneSummarySchema.optional().describe( + 'Travel-time reachability summary' + ), + citations: z + .array(z.string()) + .describe('Mapbox APIs used to produce this grounded response'), + mapboxRender: MapAppRefSchema.optional() + }) + .passthrough(); export type GroundLocationOutput = z.infer; diff --git a/src/tools/ground-location-tool/GroundLocationTool.ts b/src/tools/ground-location-tool/GroundLocationTool.ts index 86f5bfa8..e394458f 100644 --- a/src/tools/ground-location-tool/GroundLocationTool.ts +++ b/src/tools/ground-location-tool/GroundLocationTool.ts @@ -10,6 +10,8 @@ import { GroundLocationOutputSchema, type GroundLocationOutput } from './GroundLocationTool.output.schema.js'; +import type { MapAppPayload } from '../../utils/mapAppPayload.js'; +import { storeMapPayload, renderHint } from '../../utils/storeMapPayload.js'; type GroundingStrategy = 'neighborhood' | 'routing' | 'poi' | 'region'; @@ -79,7 +81,6 @@ export class GroundLocationTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; - constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: GroundLocationInputSchema, @@ -377,10 +378,62 @@ export class GroundLocationTool extends MapboxApiBasedTool< const validated = GroundLocationOutputSchema.safeParse(result); const output = validated.success ? validated.data : result; + const mapPayload = buildGroundLocationPayload(output); + const sc: Record = { + ...(output as unknown as Record) + }; + let textOut = this.formatOutput(output, strategy); + if (mapPayload) { + const ref = storeMapPayload(mapPayload); + sc.mapboxRender = { ref }; + textOut += renderHint(ref); + } + return { - content: [{ type: 'text', text: this.formatOutput(output, strategy) }], - structuredContent: output as unknown as Record, + content: [{ type: 'text', text: textOut }], + structuredContent: sc, isError: false }; } } + +/** + * Build a payload showing the grounded origin marker + nearby POIs (numbered + * orange pins). Isochrone polygons aren't included inline because the tool + * only stores a summary (contour minutes) — the full polygons live in the + * separate isochrone tool's response if the user calls it. + */ +function buildGroundLocationPayload( + out: GroundLocationOutput +): MapAppPayload | null { + const markers: MapAppPayload['markers'] = [ + { + coordinates: [out.longitude, out.latitude], + style: 'pin', + color: '#0f172a', + popup: out.place + } + ]; + + if (out.nearby_pois && out.nearby_pois.length > 0) { + out.nearby_pois.forEach((poi, i) => { + const parts = [`${i + 1}. ${poi.name}`]; + if (poi.address) parts.push(poi.address); + if (poi.distance_meters) + parts.push(`${Math.round(poi.distance_meters)} m`); + markers.push({ + coordinates: [poi.longitude, poi.latitude], + style: 'numbered', + label: String(i + 1), + color: '#f97316', + popup: parts.join(' — ') + }); + }); + } + + return { + summary: out.place, + layers: [], + markers + }; +} diff --git a/src/tools/intersect-tool/IntersectTool.output.schema.ts b/src/tools/intersect-tool/IntersectTool.output.schema.ts index 791d7713..e410d454 100644 --- a/src/tools/intersect-tool/IntersectTool.output.schema.ts +++ b/src/tools/intersect-tool/IntersectTool.output.schema.ts @@ -3,12 +3,17 @@ import { z } from 'zod'; -export const IntersectOutputSchema = z.object({ - intersects: z.boolean().describe('Whether the two polygons overlap'), - geometry: z - .record(z.string(), z.unknown()) - .nullable() - .describe('GeoJSON geometry of the intersection, or null if no overlap') -}); +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + +export const IntersectOutputSchema = z + .object({ + intersects: z.boolean().describe('Whether the two polygons overlap'), + geometry: z + .record(z.string(), z.unknown()) + .nullable() + .describe('GeoJSON geometry of the intersection, or null if no overlap'), + mapboxRender: MapAppRefSchema.optional() + }) + .passthrough(); export type IntersectOutput = z.infer; diff --git a/src/tools/intersect-tool/IntersectTool.ts b/src/tools/intersect-tool/IntersectTool.ts index 690c23bd..ed69a954 100644 --- a/src/tools/intersect-tool/IntersectTool.ts +++ b/src/tools/intersect-tool/IntersectTool.ts @@ -11,6 +11,8 @@ import { type IntersectOutput } from './IntersectTool.output.schema.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { buildPolygonOpsMapPayload } from '../union-tool/buildPolygonOpsMapPayload.js'; +import { storeMapPayload, renderHint } from '../../utils/storeMapPayload.js'; export class IntersectTool extends BaseTool< typeof IntersectInputSchema, @@ -21,7 +23,9 @@ export class IntersectTool extends BaseTool< 'Find the intersection geometry of two polygons — the area they share in common. ' + 'Useful for coverage overlap analysis, finding shared service areas, or zone overlap. ' + 'Returns null geometry if the polygons do not overlap. ' + - 'Works offline without API calls.'; + 'Works offline without API calls. ' + + 'INPUT SHAPE: `polygon1` and `polygon2` are each an array of rings; each ring is an array of [lng, lat] pairs. ' + + 'When chaining with isochrone_tool, extract `feature.geometry.coordinates` from each isochrone Feature (with `polygons=true`).'; readonly annotations = { title: 'Intersect Polygons', @@ -63,12 +67,36 @@ export class IntersectTool extends BaseTool< ? `The polygons intersect.\nIntersection geometry:\n${JSON.stringify(validated.geometry, null, 2)}` : 'The polygons do not intersect.'; + const mapPayload = buildPolygonOpsMapPayload({ + operation: 'intersect', + inputs: [poly1, poly2] as Array<{ + type: 'Feature'; + geometry: unknown; + }>, + result: (result ?? null) as { + type: 'Feature'; + geometry: unknown; + } | null, + summary: validated.intersects + ? 'Intersection of two polygons' + : 'Polygons do not intersect' + }); + const sc: Record = { + ...(validated as unknown as Record) + }; + let textOut = text; + if (mapPayload) { + const ref = storeMapPayload(mapPayload); + sc.mapboxRender = { ref }; + textOut += renderHint(ref); + } + toolContext.span.setStatus({ code: SpanStatusCode.OK }); toolContext.span.end(); return { - content: [{ type: 'text' as const, text }], - structuredContent: validated, + content: [{ type: 'text' as const, text: textOut }], + structuredContent: sc, isError: false }; } catch (error) { diff --git a/src/tools/isochrone-tool/IsochroneTool.output.schema.ts b/src/tools/isochrone-tool/IsochroneTool.output.schema.ts index a4ac146b..b9155c16 100644 --- a/src/tools/isochrone-tool/IsochroneTool.output.schema.ts +++ b/src/tools/isochrone-tool/IsochroneTool.output.schema.ts @@ -53,10 +53,15 @@ export const IsochroneFeatureSchema = z.object({ * Complete Isochrone API response * Returns a GeoJSON FeatureCollection containing isochrone contours */ -export const IsochroneResponseSchema = z.object({ - type: z.literal('FeatureCollection'), - features: z.array(IsochroneFeatureSchema) -}); +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + +export const IsochroneResponseSchema = z + .object({ + type: z.literal('FeatureCollection'), + features: z.array(IsochroneFeatureSchema), + mapboxRender: MapAppRefSchema.optional() + }) + .passthrough(); export type IsochroneResponse = z.infer; export type IsochroneFeature = z.infer; diff --git a/src/tools/isochrone-tool/IsochroneTool.ts b/src/tools/isochrone-tool/IsochroneTool.ts index 3348eda6..0aa7d429 100644 --- a/src/tools/isochrone-tool/IsochroneTool.ts +++ b/src/tools/isochrone-tool/IsochroneTool.ts @@ -12,6 +12,15 @@ import { type IsochroneResponse } from './IsochroneTool.output.schema.js'; import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js'; +import type { MapAppPayload } from '../../utils/mapAppPayload.js'; +import { storeMapPayload, renderHint } from '../../utils/storeMapPayload.js'; + +const HEX6_RE = /^[0-9a-fA-F]{6}$/; +function sanitizeHex(raw: unknown, fallback: string): string { + if (typeof raw !== 'string') return fallback; + const bare = raw.replace(/^#/, ''); + return HEX6_RE.test(bare) ? `#${bare}` : fallback; +} export class IsochroneTool extends MapboxApiBasedTool< typeof IsochroneInputSchema, @@ -30,7 +39,6 @@ export class IsochroneTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; - constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: IsochroneInputSchema, @@ -58,7 +66,6 @@ export class IsochroneTool extends MapboxApiBasedTool< } else if (props.metric === 'distance') { description += ' meters distance'; } else { - // Fallback - try to infer from contour value description += props.contour <= 60 ? ' minutes' : ' meters'; } @@ -152,11 +159,12 @@ export class IsochroneTool extends MapboxApiBasedTool< const data = await response.json(); - // Check response size and conditionally create temporary resource - const RESPONSE_SIZE_THRESHOLD = 50 * 1024; // 50KB + const RESPONSE_SIZE_THRESHOLD = 50 * 1024; const responseText = JSON.stringify(data, null, 2); const responseSize = responseText.length; + const mapPayload = buildIsochroneMapPayload(data, input); + if (responseSize > RESPONSE_SIZE_THRESHOLD) { const resourceId = randomBytes(16).toString('hex'); const resourceUri = `mapbox://temp/isochrone-${resourceId}`; @@ -170,37 +178,142 @@ export class IsochroneTool extends MapboxApiBasedTool< (data as { features?: unknown[] }).features?.length ?? 0; const summaryText = `Isochrone computed: ${contourCount} contour${contourCount !== 1 ? 's' : ''}\n\n⚠️ Full response (${Math.round(responseSize / 1024)}KB) exceeds context limit.\n\nFull GeoJSON stored as temporary resource.\nResource URI: ${resourceUri}\nTTL: 30 minutes\n\nUse the MCP resource API to retrieve full GeoJSON if needed.`; + const summaryStructured: Record = {}; + let largeText = summaryText; + if (mapPayload) { + const ref = storeMapPayload(mapPayload); + summaryStructured.mapboxRender = { ref }; + largeText += renderHint(ref); + } return { - content: [{ type: 'text', text: summaryText }], + content: [{ type: 'text', text: largeText }], + structuredContent: summaryStructured, isError: false }; } - // Validate the response against our schema const parsedData = IsochroneResponseSchema.safeParse(data); + const validated = parsedData.success + ? parsedData.data + : (data as IsochroneResponse); - 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 sc: Record = { + ...(validated as unknown as Record) + }; + let smallText = text; + if (mapPayload) { + const ref = storeMapPayload(mapPayload); + sc.mapboxRender = { ref }; + smallText += renderHint(ref); + } + + return { + content: [{ type: 'text', text: smallText }], + structuredContent: sc, + isError: false + }; + } +} + +/** + * Build a `MapAppPayload` from a Mapbox Isochrone API response. Each contour + * becomes a fill+line layer pair colored per the API-supplied `color`/`fillColor` + * (or a teal default), with the origin marked. + */ +function buildIsochroneMapPayload( + data: unknown, + input: z.infer +): MapAppPayload | null { + const fc = data as + | { + type?: string; + features?: Array<{ + geometry?: { type?: string; coordinates?: unknown }; + properties?: Record; + }>; + } + | null + | undefined; + if ( + !fc || + fc.type !== 'FeatureCollection' || + !Array.isArray(fc.features) || + fc.features.length === 0 + ) { + return null; + } + + // Render contours largest-first → smallest-on-top for a clean layered look. + const ordered = fc.features.slice().reverse(); + const layers: MapAppPayload['layers'] = []; + ordered.forEach((feature, i) => { + const props = feature.properties ?? {}; + const color = sanitizeHex( + (props as { color?: unknown; fillColor?: unknown }).color ?? + (props as { fillColor?: unknown }).fillColor, + '#3b82f6' + ); + const fillOpacity = + typeof props.fillOpacity === 'number' ? props.fillOpacity : 0.25; + + if (feature.geometry?.type === 'Polygon' && feature.geometry.coordinates) { + layers.push({ + id: `iso-fill-${i}`, + type: 'fill', + data: { + type: 'Feature', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + geometry: feature.geometry as any, + properties: {} + }, + paint: { 'fill-color': color, 'fill-opacity': fillOpacity } + }); + } + layers.push({ + id: `iso-line-${i}`, + type: 'line', + data: { + type: 'Feature', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + geometry: feature.geometry as any, + properties: {} + }, + paint: { 'line-color': color, 'line-width': 2, 'line-opacity': 0.9 }, + layout: { 'line-join': 'round', 'line-cap': 'round' } + }); + }); + + 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 { + summary, + layers, + markers: [ + { + coordinates: [input.coordinates.longitude, input.coordinates.latitude], + style: 'pin', + color: '#0f172a', + popup: 'Origin' + } + ] + }; } diff --git a/src/tools/map-matching-tool/MapMatchingTool.output.schema.ts b/src/tools/map-matching-tool/MapMatchingTool.output.schema.ts index 4288514d..3cb68d46 100644 --- a/src/tools/map-matching-tool/MapMatchingTool.output.schema.ts +++ b/src/tools/map-matching-tool/MapMatchingTool.output.schema.ts @@ -46,11 +46,17 @@ const MatchingSchema = z.object({ .optional() }); -// Main output schema -export const MapMatchingOutputSchema = z.object({ - code: z.string(), - matchings: z.array(MatchingSchema), - tracepoints: z.array(TracepointSchema.nullable()) -}); +// Main output schema. `mapboxRender` is declared so strict client-side validators +// don't flag the response as malformed. +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + +export const MapMatchingOutputSchema = z + .object({ + code: z.string(), + matchings: z.array(MatchingSchema), + tracepoints: z.array(TracepointSchema.nullable()), + mapboxRender: MapAppRefSchema.optional() + }) + .passthrough(); export type MapMatchingOutput = z.infer; diff --git a/src/tools/map-matching-tool/MapMatchingTool.ts b/src/tools/map-matching-tool/MapMatchingTool.ts index 4760d9b9..6b1d6453 100644 --- a/src/tools/map-matching-tool/MapMatchingTool.ts +++ b/src/tools/map-matching-tool/MapMatchingTool.ts @@ -11,6 +11,11 @@ import { type MapMatchingOutput } from './MapMatchingTool.output.schema.js'; import type { HttpRequest } from '../../utils/types.js'; +import { + decodePolylineWithFallback, + type MapAppPayload +} from '../../utils/mapAppPayload.js'; +import { storeMapPayload, renderHint } from '../../utils/storeMapPayload.js'; // Docs: https://docs.mapbox.com/api/navigation/map-matching/ @@ -32,7 +37,6 @@ export class MapMatchingTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; - constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: MapMatchingInputSchema, @@ -119,29 +123,103 @@ export class MapMatchingTool extends MapboxApiBasedTool< const data = (await response.json()) as MapMatchingOutput; - // Validate the response against our output schema + let validatedData: MapMatchingOutput; try { - const validatedData = MapMatchingOutputSchema.parse(data); - - return { - content: [ - { type: 'text', text: JSON.stringify(validatedData, null, 2) } - ], - structuredContent: validatedData, - isError: false - }; + validatedData = MapMatchingOutputSchema.parse(data); } catch (validationError) { - // If validation fails, return the raw result anyway with a warning this.log( 'warning', `Schema validation warning: ${validationError instanceof Error ? validationError.message : String(validationError)}` ); + validatedData = data; + } - return { - content: [{ type: 'text', text: JSON.stringify(data, null, 2) }], - structuredContent: data, - isError: false - }; + const mapPayload = buildMapMatchingPayload(validatedData, input); + const sc: Record = { + ...(validatedData as unknown as Record) + }; + let textOut = JSON.stringify(validatedData, null, 2); + if (mapPayload) { + const ref = storeMapPayload(mapPayload); + sc.mapboxRender = { ref }; + textOut += renderHint(ref); } + + return { + content: [{ type: 'text', text: textOut }], + structuredContent: sc, + isError: false + }; } } + +/** + * Build a payload showing the raw GPS trace as a dashed orange line and + * the matched route as a solid blue line, with a legend explaining both. + */ +function buildMapMatchingPayload( + data: MapMatchingOutput, + input: z.infer +): MapAppPayload | null { + const match = data.matchings?.[0]; + if (!match) return null; + + let matchedCoords: [number, number][] | null = null; + const g = match.geometry as unknown; + if ( + g && + typeof g === 'object' && + (g as { type?: string }).type === 'LineString' && + Array.isArray((g as { coordinates?: unknown }).coordinates) + ) { + matchedCoords = (g as { coordinates: [number, number][] }).coordinates; + } else if (typeof g === 'string' && g.length > 0) { + matchedCoords = decodePolylineWithFallback(g); + } + if (!matchedCoords || matchedCoords.length === 0) return null; + + const rawCoords: [number, number][] = input.coordinates.map((c) => [ + c.longitude, + c.latitude + ]); + + const matched = data.tracepoints?.filter((t) => t != null).length ?? 0; + const total = data.tracepoints?.length ?? input.coordinates.length; + + return { + summary: `Matched ${matched}/${total} GPS points (confidence ${(match.confidence * 100).toFixed(0)}%)`, + layers: [ + { + id: 'raw-trace', + type: 'line', + data: { + type: 'Feature', + geometry: { type: 'LineString', coordinates: rawCoords }, + properties: {} + }, + paint: { + 'line-color': '#f97316', + 'line-width': 2, + 'line-dasharray': [2, 2], + 'line-opacity': 0.8 + }, + layout: { 'line-join': 'round', 'line-cap': 'round' } + }, + { + id: 'matched-route', + type: 'line', + data: { + type: 'Feature', + geometry: { type: 'LineString', coordinates: matchedCoords }, + properties: {} + }, + paint: { 'line-color': '#3b82f6', 'line-width': 4 }, + layout: { 'line-join': 'round', 'line-cap': 'round' } + } + ], + legend: [ + { label: 'Raw trace', color: '#f97316' }, + { label: 'Matched route', color: '#3b82f6' } + ] + }; +} diff --git a/src/tools/optimization-tool/OptimizationTool.input.schema.ts b/src/tools/optimization-tool/OptimizationTool.input.schema.ts index aad2b5ee..4da84894 100644 --- a/src/tools/optimization-tool/OptimizationTool.input.schema.ts +++ b/src/tools/optimization-tool/OptimizationTool.input.schema.ts @@ -20,65 +20,85 @@ 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. ' + + 'IMPORTANT: If you set roundtrip=false you MUST set source="first" (and destination="last"). ' + + 'The Mapbox V1 API rejects roundtrip=false with source="any". ' + + '"any" allows the optimizer to pick any coordinate as the start (only valid when roundtrip=true).' + ), + destination: z + .enum(['any', 'last']) + .optional() + .default('any') + .describe( + 'Location to end the trip. ' + + 'IMPORTANT: If you set roundtrip=false you MUST set destination="last" (and source="first"). ' + + 'The Mapbox V1 API rejects roundtrip=false with destination="any". ' + + '"any" allows the optimizer to pick any coordinate as the end (only valid when roundtrip=true).' + ), + roundtrip: z + .boolean() + .optional() + .default(true) + .describe( + 'Whether to return to the starting point. ' + + 'Default true returns a closed loop through the coordinates. ' + + 'Set false for one-way trips — REQUIRES source="first" AND destination="last" together. ' + + 'Passing roundtrip=false without both endpoint constraints will fail.' + ), + 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 V1 Optimization API requires both endpoints fixed for one-way trips).", + path: ['roundtrip'] + } + ); export type OptimizationInput = z.infer; diff --git a/src/tools/optimization-tool/OptimizationTool.output.schema.ts b/src/tools/optimization-tool/OptimizationTool.output.schema.ts index d1f6fa4b..665bb666 100644 --- a/src/tools/optimization-tool/OptimizationTool.output.schema.ts +++ b/src/tools/optimization-tool/OptimizationTool.output.schema.ts @@ -73,6 +73,8 @@ const tripSchema = z * * Returns the optimized trip through all input coordinates. */ +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + export const OptimizationOutputSchema = z .object({ code: z @@ -89,7 +91,11 @@ export const OptimizationOutputSchema = z 'Array containing the optimized trip (typically 1 trip for all waypoints)' ), // Error response fields - message: z.string().optional().describe('Error message if code is not "Ok"') + message: z + .string() + .optional() + .describe('Error message if code is not "Ok"'), + mapboxRender: MapAppRefSchema.optional() }) .passthrough(); diff --git a/src/tools/optimization-tool/OptimizationTool.ts b/src/tools/optimization-tool/OptimizationTool.ts index ca640f58..2211ab84 100644 --- a/src/tools/optimization-tool/OptimizationTool.ts +++ b/src/tools/optimization-tool/OptimizationTool.ts @@ -14,6 +14,11 @@ import { import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; import type { ToolExecutionContext } from '../../utils/tracing.js'; import type { HttpRequest } from '../../utils/types.js'; +import { + decodePolylineWithFallback, + type MapAppPayload +} from '../../utils/mapAppPayload.js'; +import { storeMapPayload, renderHint } from '../../utils/storeMapPayload.js'; /** * OptimizationTool - Find optimal route through multiple coordinates (V1 API) @@ -38,7 +43,6 @@ export class OptimizationTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; - constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: OptimizationInputSchema, @@ -165,9 +169,20 @@ export class OptimizationTool extends MapboxApiBasedTool< validatedResult.waypoints.length ); + const mapPayload = buildOptimizationMapPayload(validatedResult); + const sc: Record = { + ...(validatedResult as unknown as Record) + }; + let textOut = text; + if (mapPayload) { + const ref = storeMapPayload(mapPayload); + sc.mapboxRender = { ref }; + textOut += renderHint(ref); + } + return { - content: [{ type: 'text' as const, text }], - structuredContent: validatedResult, + content: [{ type: 'text' as const, text: textOut }], + structuredContent: sc, isError: false }; } catch (error) { @@ -187,3 +202,78 @@ export class OptimizationTool extends MapboxApiBasedTool< } } } + +/** + * Build a `MapAppPayload` from an Optimization API response: a single trip + * line plus numbered visit-order markers (start=green, end=red, middle=blue). + * Polyline-encoded geometries are decoded tool-side so the iframe only ever + * receives GeoJSON. + */ +function buildOptimizationMapPayload( + result: OptimizationOutput +): MapAppPayload | null { + const trip = result.trips?.[0]; + if (!trip) return null; + + let coords: [number, number][] | null = null; + const g = trip.geometry as unknown; + if ( + g && + typeof g === 'object' && + (g as { type?: string }).type === 'LineString' && + Array.isArray((g as { coordinates?: unknown }).coordinates) + ) { + coords = (g as { coordinates: [number, number][] }).coordinates; + } else if (typeof g === 'string' && g.length > 0) { + coords = decodePolylineWithFallback(g); + } + if (!coords || coords.length === 0) return null; + + // Stops in optimized visit order: sort waypoints by waypoint_index. + const ordered = (result.waypoints ?? []) + .map((wp, inputIndex) => ({ wp, inputIndex })) + .sort((a, b) => a.wp.waypoint_index - b.wp.waypoint_index); + + const markers: MapAppPayload['markers'] = ordered.map((entry, i) => { + const isStart = i === 0; + const isEnd = i === ordered.length - 1; + const label = String(i + 1); + const color = isStart ? '#22c55e' : isEnd ? '#ef4444' : '#2563eb'; + const popupParts = [`Stop ${i + 1} (input #${entry.inputIndex})`]; + // wp.name is the snapped road name, not a place name — label accordingly. + if (entry.wp.name) popupParts.push(`on ${entry.wp.name}`); + return { + coordinates: entry.wp.location as [number, number], + style: 'numbered', + label, + color, + popup: popupParts.join(' — ') + }; + }); + + const miles = (trip.distance / 1609.34).toFixed(1); + const minutes = Math.round(trip.duration / 60); + const summary = `Optimized trip: ${miles} mi, ${minutes} min`; + + return { + summary, + layers: [ + { + id: 'trip', + type: 'line', + data: { + type: 'Feature', + geometry: { type: 'LineString', coordinates: coords }, + properties: {} + }, + paint: { + 'line-color': '#3b82f6', + 'line-width': 5, + 'line-opacity': 0.85 + }, + layout: { 'line-join': 'round', 'line-cap': 'round' } + } + ], + markers + }; +} diff --git a/src/tools/render-map-tool/RenderMapTool.input.schema.ts b/src/tools/render-map-tool/RenderMapTool.input.schema.ts new file mode 100644 index 00000000..ee3f3590 --- /dev/null +++ b/src/tools/render-map-tool/RenderMapTool.input.schema.ts @@ -0,0 +1,124 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +/** + * Input schema for `render_map_tool` — a `MapAppPayload` describing what to + * draw. The LLM either passes through the `mapboxRender` field returned by any + * Mapbox geo tool, or composes a payload from raw GeoJSON. + * + * Mirrors the runtime `MapAppPayload` type (src/utils/mapAppPayload.ts) but + * declared as a Zod schema for input validation. The two MUST stay in sync. + */ + +const layerSchema = z.object({ + id: z.string().describe('Unique layer/source id within the payload'), + type: z + .enum(['fill', 'line', 'circle', 'symbol']) + .describe('Mapbox GL layer type'), + data: z + .unknown() + .describe( + 'GeoJSON Feature or FeatureCollection. Geometry must be one of: Point, LineString, Polygon, MultiPolygon. Coordinates are [longitude, latitude] order.' + ), + paint: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Mapbox Style Spec paint object passed through to addLayer (e.g. { "line-color": "#3b82f6", "line-width": 5 })' + ), + layout: z + .record(z.string(), z.unknown()) + .optional() + .describe( + 'Mapbox Style Spec layout object (e.g. { "line-join": "round", "line-cap": "round" })' + ) +}); + +const markerSchema = z.object({ + coordinates: z + .array(z.number()) + .min(2) + .max(2) + .describe('[longitude, latitude]'), + style: z + .enum(['pin', 'numbered', 'start', 'end']) + .optional() + .describe( + 'Visual style. "pin" is the default Mapbox marker. "numbered" is a circular badge containing `label`. "start"/"end" are green/red badges for route endpoints.' + ), + label: z + .string() + .optional() + .describe('Required when style="numbered" (e.g. visit order "1", "2", …)'), + color: z + .string() + .optional() + .describe('Optional CSS color override (defaults are style-derived)'), + popup: z.string().optional().describe('Popup text shown on marker click') +}); + +const legendEntrySchema = z.object({ + label: z.string(), + color: z.string().describe('CSS color of the swatch'), + opacity: z.number().min(0).max(1).optional() +}); + +const cameraSchema = z.object({ + center: z.array(z.number()).min(2).max(2).optional(), + zoom: z.number().optional(), + bounds: z + .array(z.array(z.number()).min(2).max(2)) + .min(2) + .max(2) + .optional() + .describe( + '[[minLng, minLat], [maxLng, maxLat]]. If set, takes precedence over center/zoom and auto-fit.' + ) +}); + +export const RenderMapInputSchema = z.object({ + /** + * Preferred way to pass data from another Mapbox tool. Every geo tool + * stashes its map payload server-side and returns a short ref in + * `structuredContent.mapboxRender.ref` — pass that ref (or several, to merge + * multiple datasets onto one map) here. Avoids streaming thousands of + * coordinate pairs back through the model. + */ + payload_refs: z + .array(z.string()) + .optional() + .describe( + 'Array of map-payload URIs returned by other Mapbox tools in their structuredContent.mapboxRender.ref field. Pass one ref to render a single tool result; pass multiple to merge several datasets onto one map (e.g. an isochrone + a route).' + ), + summary: z + .string() + .optional() + .describe( + 'Short header chip shown in the top-left of the map (e.g. "Route: 12.4 mi, 23 min"). Overrides any summary in payload_refs.' + ), + layers: z + .array(layerSchema) + .optional() + .describe( + 'Inline layers to add to the map. Use this only when composing a payload from raw GeoJSON; for tool results, pass payload_refs instead.' + ), + markers: z + .array(markerSchema) + .optional() + .describe( + 'Inline point markers (start/end, numbered visits, POI pins, etc.). Use only for hand-composed payloads.' + ), + legend: z + .array(legendEntrySchema) + .optional() + .describe('Inline legend rows. Use only for hand-composed payloads.'), + camera: cameraSchema + .optional() + .describe( + 'Initial camera position. If omitted, the map auto-fits to the union of all data.' + ) +}); + +export type RenderMapInput = z.infer; diff --git a/src/tools/render-map-tool/RenderMapTool.output.schema.ts b/src/tools/render-map-tool/RenderMapTool.output.schema.ts new file mode 100644 index 00000000..fef202c9 --- /dev/null +++ b/src/tools/render-map-tool/RenderMapTool.output.schema.ts @@ -0,0 +1,24 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + +/** + * Output schema for `render_map_tool`. `mapboxRender.ref` is declared because + * strict client-side validators may strip undeclared fields from the + * structuredContent — even with `.passthrough()` on the Zod side — and the + * iframe needs to see the ref to fetch the merged payload via + * `resources/read`. + */ +export const RenderMapOutputSchema = z + .object({ + rendered: z.boolean().describe('Always true when the call succeeded.'), + layer_count: z.number().int(), + marker_count: z.number().int(), + summary: z.string().optional(), + mapboxRender: MapAppRefSchema.optional() + }) + .passthrough(); + +export type RenderMapOutput = z.infer; diff --git a/src/tools/render-map-tool/RenderMapTool.ts b/src/tools/render-map-tool/RenderMapTool.ts new file mode 100644 index 00000000..e60855dd --- /dev/null +++ b/src/tools/render-map-tool/RenderMapTool.ts @@ -0,0 +1,214 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { context, SpanStatusCode, trace } from '@opentelemetry/api'; +import { createLocalToolExecutionContext } from '../../utils/tracing.js'; +import { BaseTool } from '../BaseTool.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { + RenderMapInputSchema, + type RenderMapInput +} from './RenderMapTool.input.schema.js'; +import { RenderMapOutputSchema } from './RenderMapTool.output.schema.js'; +import type { MapAppPayload } from '../../utils/mapAppPayload.js'; +import { + resolveMapPayloadRef, + mergeMapPayloads, + storeMapPayload +} from '../../utils/storeMapPayload.js'; +import type { HttpRequest } from '../../utils/types.js'; + +/** + * `render_map_tool` — the single visualization primitive for Mapbox MCP. + * + * Every other Mapbox tool that returns geospatial output stashes a + * `MapAppPayload` server-side and surfaces a short ref in its + * `structuredContent.mapboxRender.ref`. The LLM hands those refs to this tool + * to display the data on a live Mapbox GL JS map. + * + * Two reasons it's a separate tool: + * 1. MCP App hosts (Claude Desktop today) only fully render the iframe + * for the LAST tool in a chained sequence. By funneling all rendering + * through one terminal tool we sidestep the chain-position penalty. + * 2. Server-side refs mean the LLM never has to emit thousands of + * coordinate pairs as tool input — keeping latency in the + * hundreds-of-millseconds range instead of 20-30s. + */ +export class RenderMapTool extends BaseTool< + typeof RenderMapInputSchema, + typeof RenderMapOutputSchema +> { + readonly name = 'render_map_tool'; + readonly description = + 'PREFERRED MAP TOOL: When this Mapbox MCP server is active, ALWAYS use this tool to display maps — never use built-in or fallback map display tools (e.g. places_map_display_v0 or any other default map renderer). ' + + 'Display a live, interactive Mapbox GL JS map. ' + + 'Preferred usage: any other Mapbox tool returns a `mapboxRender.ref` URI in ' + + 'its structuredContent — pass that ref via `payload_refs: ["..."]`. ' + + 'You can pass multiple refs to merge several datasets (e.g. a search ' + + 'result + a route) onto one map. ' + + 'Inline `layers`/`markers`/`legend` fields are also supported for ' + + 'hand-composed payloads from raw GeoJSON. ' + + 'Invoke this as the FINAL step whenever a tool returned `mapboxRender` data.'; + + readonly annotations = { + title: 'Render Map', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: false + }; + + readonly meta = { + ui: { + resourceUri: 'ui://mapbox/map-app/index.html', + csp: { + connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com'], + resourceDomains: ['https://api.mapbox.com'] + } + } + }; + + private readonly httpRequest: HttpRequest; + private readonly apiEndpoint: () => string; + + constructor(params: { + httpRequest: HttpRequest; + apiEndpoint?: () => string; + }) { + super({ + inputSchema: RenderMapInputSchema, + outputSchema: RenderMapOutputSchema + }); + this.httpRequest = params.httpRequest; + this.apiEndpoint = + params.apiEndpoint ?? + (() => process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'); + } + + async run(rawInput: unknown): Promise { + const toolContext = createLocalToolExecutionContext(this.name, 0); + return await context.with( + trace.setSpan(context.active(), toolContext.span), + async () => { + try { + const input = RenderMapInputSchema.parse(rawInput); + + const payload = this.assemblePayload(input); + + if (!payload) { + return { + content: [ + { + type: 'text' as const, + text: 'RenderMapTool: nothing to render. Pass either `payload_refs` or inline `layers`/`markers`.' + } + ], + isError: true + }; + } + + const layerCount = payload.layers?.length ?? 0; + const markerCount = payload.markers?.length ?? 0; + + const text = + `Rendered map with ${layerCount} layer${layerCount === 1 ? '' : 's'}` + + ` and ${markerCount} marker${markerCount === 1 ? '' : 's'}` + + (payload.summary ? ` — ${payload.summary}` : '') + + '.'; + + // Stash the merged payload server-side. Claude Desktop strips + // structuredContent from MCP App iframe postMessages and + // replaces large content[] payloads with placeholder text, so + // the iframe must extract the ref from a tiny sentinel-tagged + // text item in content[]. Keep the total response tiny: + // no inline rawHtml (Claude Desktop already opens the iframe + // via meta.ui.resourceUri, so the rawHtml duplicate just bloats + // the response and trips the host's "too large" trim). + const mergedRef = storeMapPayload(payload); + + const content: CallToolResult['content'] = [ + { type: 'text' as const, text }, + { + type: 'text' as const, + text: `[[MAPBOX_RENDER_REF]] ${mergedRef}` + } + ]; + + toolContext.span.setStatus({ code: SpanStatusCode.OK }); + toolContext.span.end(); + + return { + content, + structuredContent: { + rendered: true, + layer_count: layerCount, + marker_count: markerCount, + summary: payload.summary, + mapboxRender: { ref: mergedRef } + }, + isError: false + }; + } catch (error) { + const errorMessage = + error instanceof Error ? error.message : String(error); + toolContext.span.setStatus({ + code: SpanStatusCode.ERROR, + message: errorMessage + }); + toolContext.span.end(); + return { + content: [ + { + type: 'text' as const, + text: `RenderMapTool: ${errorMessage}` + } + ], + isError: true + }; + } + } + ); + } + + /** + * Resolve `payload_refs` and merge with any inline layers/markers/legend. + * Inline `summary` (when set) overrides any summary from the refs. + * Returns null if nothing renderable is provided. + */ + private assemblePayload(input: RenderMapInput): MapAppPayload | null { + const fromRefs: MapAppPayload[] = []; + if (Array.isArray(input.payload_refs)) { + for (const ref of input.payload_refs) { + const resolved = resolveMapPayloadRef(ref); + if (resolved) fromRefs.push(resolved); + } + } + + const inline: MapAppPayload = { + // RenderMapInput's `data: z.unknown()` widens layer geometry beyond + // MapAppPayload's strict Geometry union, so cast to assemble the + // payload object that flows to the iframe renderer. + layers: (input.layers ?? []) as MapAppPayload['layers'], + markers: input.markers as MapAppPayload['markers'], + legend: input.legend, + camera: input.camera as MapAppPayload['camera'], + summary: input.summary + }; + const hasInlineContent = + (inline.layers && inline.layers.length > 0) || + (inline.markers && inline.markers.length > 0); + + const all: MapAppPayload[] = [...fromRefs]; + if (hasInlineContent || inline.summary || inline.legend) all.push(inline); + if (all.length === 0) return null; + + const merged = mergeMapPayloads(all); + // Inline summary/camera/legend take precedence when provided. + if (input.summary) merged.summary = input.summary; + if (input.camera) merged.camera = inline.camera; + if (input.legend) merged.legend = input.legend; + return merged; + } +} + +export type { RenderMapInput }; diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts index 46e59111..68b7fe9a 100644 --- a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.output.schema.ts @@ -149,10 +149,13 @@ const SearchBoxFeatureSchema = z.object({ }); // Main Search Box API response schema +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + export const SearchBoxResponseSchema = z.object({ type: z.literal('FeatureCollection'), features: z.array(SearchBoxFeatureSchema), - attribution: z.string().optional() + attribution: z.string().optional(), + mapboxRender: MapAppRefSchema.optional() }); export type SearchBoxResponse = z.infer; diff --git a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts index 682b8318..58755286 100644 --- a/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts +++ b/src/tools/search-and-geocode-tool/SearchAndGeocodeTool.ts @@ -14,6 +14,8 @@ import type { MapboxFeature } from '../../schemas/geojson.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { buildSearchMapPayload } from './buildSearchMapPayload.js'; +import { storeMapPayload, renderHint } from '../../utils/storeMapPayload.js'; // API Documentation: https://docs.mapbox.com/api/search/search-box/#search-request @@ -31,7 +33,6 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< idempotentHint: true, openWorldHint: true }; - constructor(params: { httpRequest: HttpRequest }) { super({ inputSchema: SearchAndGeocodeInputSchema, @@ -105,11 +106,6 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< input: z.infer, accessToken: string ): Promise { - this.log( - 'info', - `SearchAndGeocodeTool: Starting search with input: ${JSON.stringify(input)}` - ); - const url = new URL( `${MapboxApiBasedTool.mapboxApiEndpoint}search/searchbox/v1/forward` ); @@ -172,11 +168,6 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< ); } - this.log( - 'info', - `SearchAndGeocodeTool: Fetching from URL: ${url.toString().replace(accessToken, '[REDACTED]')}` - ); - const response = await this.httpRequest(url.toString()); if (!response.ok) { @@ -211,11 +202,6 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< data = rawData as SearchBoxResponse; } - this.log( - 'info', - `SearchAndGeocodeTool: Successfully completed search, found ${data.features?.length || 0} results` - ); - // Check if we have multiple results that might be ambiguous if ( this.server && @@ -270,18 +256,22 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< features: [selectedFeature] }; - return { - content: [ - { - type: 'text', - text: this.formatGeoJsonToText( - singleResult as MapboxFeatureCollection - ) - } - ], - structuredContent: singleResult, - isError: false - }; + return this.withMapPayload( + { + content: [ + { + type: 'text', + text: this.formatGeoJsonToText( + singleResult as MapboxFeatureCollection + ) + } + ], + structuredContent: singleResult, + isError: false + }, + singleResult, + input + ); } else if (result.action === 'decline') { // User declined to select - return all results as before this.log( @@ -289,25 +279,65 @@ export class SearchAndGeocodeTool extends MapboxApiBasedTool< 'SearchAndGeocodeTool: User declined to select a specific result' ); } - } catch (elicitError) { - // If elicitation fails, fall back to returning all results - this.log( - 'warning', - `SearchAndGeocodeTool: Elicitation failed: ${elicitError instanceof Error ? elicitError.message : 'Unknown error'}` - ); + } catch { + // Elicitation isn't supported by every MCP client (Claude Desktop + // doesn't, for example). Falling back to "return all results" is the + // expected behavior — silent, since Claude Desktop's UI flags tool + // calls that emit notifications/message at any level as visually + // failed even when the JSON-RPC response is isError: false. } } // Default behavior: return all results - return { - content: [ - { - type: 'text', - text: this.formatGeoJsonToText(data as MapboxFeatureCollection) - } - ], - structuredContent: data, - isError: false + return this.withMapPayload( + { + content: [ + { + type: 'text', + text: this.formatGeoJsonToText(data as MapboxFeatureCollection) + } + ], + structuredContent: data, + isError: false + }, + data, + input + ); + } + + /** + * Attach a `mapboxRender` payload to structuredContent so the LLM can pass it + * to `render_map_tool` to visualize results on a live Mapbox GL JS map. + */ + private withMapPayload( + base: CallToolResult, + data: unknown, + input: z.infer + ): CallToolResult { + const proximity = + input.proximity && + typeof (input.proximity as { longitude?: number }).longitude === 'number' + ? (input.proximity as { longitude: number; latitude: number }) + : undefined; + const payload = buildSearchMapPayload({ + data, + query: input.q, + proximity + }); + if (!payload) return base; + + const ref = storeMapPayload(payload); + const sc = { + ...((base.structuredContent ?? {}) as Record), + mapboxRender: { ref } }; + // Append the render hint to the first text content so the LLM sees the + // exact ref string and doesn't hallucinate a URI. + const content = (base.content ?? []).map((c, i) => + i === 0 && c.type === 'text' + ? { ...c, text: (c.text as string) + renderHint(ref) } + : c + ); + return { ...base, content, structuredContent: sc }; } } diff --git a/src/tools/search-and-geocode-tool/buildSearchMapPayload.ts b/src/tools/search-and-geocode-tool/buildSearchMapPayload.ts new file mode 100644 index 00000000..90f03b62 --- /dev/null +++ b/src/tools/search-and-geocode-tool/buildSearchMapPayload.ts @@ -0,0 +1,112 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { MapAppPayload } from '../../utils/mapAppPayload.js'; + +/** + * Build a `MapAppPayload` for search-style responses (search_and_geocode_tool, + * category_search_tool). Each result becomes a pin marker; the LLM-facing + * summary echoes the result count. + * + * Returns null if the response has no point-typed features to plot. + */ +export function buildSearchMapPayload(params: { + data: unknown; + query?: string; + proximity?: { longitude: number; latitude: number }; +}): MapAppPayload | null { + const { data, query, proximity } = params; + const fc = data as + | { + type?: string; + features?: Array<{ + geometry?: { type?: string; coordinates?: [number, number] }; + properties?: { + name?: string; + full_address?: string; + place_formatted?: string; + distance?: number; + }; + }>; + } + | null + | undefined; + if (!fc || !Array.isArray(fc.features)) return null; + + const points = fc.features.filter( + (f) => + f.geometry?.type === 'Point' && + Array.isArray(f.geometry.coordinates) && + typeof f.geometry.coordinates[0] === 'number' && + typeof f.geometry.coordinates[1] === 'number' + ); + if (points.length === 0 && !proximity) return null; + + const markers: MapAppPayload['markers'] = []; + + if (proximity) { + markers.push({ + coordinates: [proximity.longitude, proximity.latitude], + style: 'pin', + color: '#0f172a', + popup: 'Search center' + }); + } + + // Also emit a circle layer underneath the markers so the payload has + // both `layers` and `markers` populated (some MCP hosts render the card + // differently when layers is empty even if markers aren't). + const features: Array<{ + type: 'Feature'; + geometry: { type: 'Point'; coordinates: [number, number] }; + properties: { idx: number; label: string }; + }> = []; + + points.forEach((f, i) => { + const props = f.properties ?? {}; + const popupParts = [`${i + 1}. ${props.name ?? 'Result'}`]; + const addr = props.full_address ?? props.place_formatted; + if (addr) popupParts.push(addr); + if (typeof props.distance === 'number') { + popupParts.push(`${Math.round(props.distance)} m`); + } + const coords = f.geometry!.coordinates as [number, number]; + markers.push({ + coordinates: coords, + style: 'numbered', + label: String(i + 1), + color: '#f97316', + popup: popupParts.join(' — ') + }); + features.push({ + type: 'Feature', + geometry: { type: 'Point', coordinates: coords }, + properties: { idx: i + 1, label: props.name ?? `Result ${i + 1}` } + }); + }); + + const summary = query + ? `${points.length} result${points.length !== 1 ? 's' : ''} for "${query}"` + : `${points.length} result${points.length !== 1 ? 's' : ''}`; + + const layers: MapAppPayload['layers'] = + features.length > 0 + ? [ + { + id: 'search-results', + type: 'circle', + data: { type: 'FeatureCollection', features }, + paint: { + 'circle-radius': 6, + 'circle-color': '#f97316', + 'circle-opacity': 0.15, + 'circle-stroke-width': 1, + 'circle-stroke-color': '#f97316', + 'circle-stroke-opacity': 0.4 + } + } + ] + : []; + + return { summary, layers, markers }; +} diff --git a/src/tools/shared/polygonSchema.ts b/src/tools/shared/polygonSchema.ts index 9da7d44c..71b59b61 100644 --- a/src/tools/shared/polygonSchema.ts +++ b/src/tools/shared/polygonSchema.ts @@ -8,9 +8,33 @@ export const CoordinateSchema = z .length(2) .describe('Coordinate as [longitude, latitude]'); +const RingSchema = z + .array(CoordinateSchema) + .min(4) + .describe( + 'A closed linear ring: 4+ [longitude, latitude] coordinate pairs where the first and last pair are identical (e.g. [[-77, 38], [-76, 38], [-76, 39], [-77, 39], [-77, 38]]).' + ); + +/** + * GeoJSON Polygon coordinates — exactly 3 levels of nesting: + * polygon ::= Array + * ring ::= Array<[lng, lat]> (first is outer, rest are holes) + * coord ::= [longitude, latitude] + * + * Example for a 4-corner box outer ring with no holes: + * [[[-77,38],[-76,38],[-76,39],[-77,39],[-77,38]]] + * + * If you have a GeoJSON Feature whose `geometry.type === "Polygon"`, use + * `feature.geometry.coordinates` directly — that's already the right shape. + * + * For multi-polygon results (e.g. an isochrone with multiple disconnected + * regions), pass each polygon separately rather than nesting a MultiPolygon. + */ export const PolygonSchema = z - .array(z.array(CoordinateSchema)) + .array(RingSchema) .min(1) .describe( - 'Polygon coordinates as array of rings (first is outer, rest are holes). Each ring is [longitude, latitude] pairs.' + 'GeoJSON Polygon coordinates: an array of linear rings. The FIRST ring is the outer boundary; subsequent rings are interior holes. Each ring is an array of 4+ [longitude, latitude] coordinate pairs where the first and last are identical (closed ring). ' + + 'Example with one outer ring and no holes: [[[-77,38],[-76,38],[-76,39],[-77,39],[-77,38]]]. ' + + 'If you have a GeoJSON Feature with type=Polygon, pass `feature.geometry.coordinates` directly.' ); diff --git a/src/tools/static-map-image-tool/StaticMapImageTool.ts b/src/tools/static-map-image-tool/StaticMapImageTool.ts index b8d38075..df5c649a 100644 --- a/src/tools/static-map-image-tool/StaticMapImageTool.ts +++ b/src/tools/static-map-image-tool/StaticMapImageTool.ts @@ -3,13 +3,11 @@ import { randomUUID, randomBytes } from 'node:crypto'; import type { z } from 'zod'; -import { createUIResource } from '@mcp-ui/server'; import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; import type { HttpRequest } from '../../utils/types.js'; import { StaticMapImageInputSchema } from './StaticMapImageTool.input.schema.js'; import type { OverlaySchema } from './StaticMapImageTool.input.schema.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { isMcpUiEnabled } from '../../config/toolConfig.js'; import { temporaryResourceManager } from '../../utils/temporaryResourceManager.js'; // Images larger than this threshold are stored as temporary resources instead @@ -163,22 +161,6 @@ export class StaticMapImageTool extends MapboxApiBasedTool< content.push({ type: 'image', data: base64Data, mimeType }); } - // Conditionally add MCP-UI resource if enabled (backward compatibility) - if (isMcpUiEnabled()) { - const uiResource = createUIResource({ - uri: `ui://mapbox/static-map/${input.style}/${lng},${lat},${input.zoom}`, - content: { - type: 'externalUrl', - iframeUrl: url - }, - encoding: 'text', - uiMetadata: { - 'preferred-frame-size': [`${width}px`, `${height}px`] - } - }); - content.push(uiResource); - } - return { content, isError: false, diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 75babfd1..22552513 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -28,6 +28,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 { RenderMapTool } from './render-map-tool/RenderMapTool.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'; @@ -65,6 +66,7 @@ export const CORE_TOOLS = [ new MapMatchingTool({ httpRequest }), new MatrixTool({ httpRequest }), new OptimizationTool({ httpRequest }), + new RenderMapTool({ httpRequest }), new ReverseGeocodeTool({ httpRequest }), new StaticMapImageTool({ httpRequest }), new SearchAndGeocodeTool({ httpRequest }) diff --git a/src/tools/union-tool/UnionTool.output.schema.ts b/src/tools/union-tool/UnionTool.output.schema.ts index 8802c7ab..71a0daf9 100644 --- a/src/tools/union-tool/UnionTool.output.schema.ts +++ b/src/tools/union-tool/UnionTool.output.schema.ts @@ -3,13 +3,18 @@ import { z } from 'zod'; -export const UnionOutputSchema = z.object({ - geometry: z - .record(z.string(), z.unknown()) - .describe( - 'GeoJSON geometry of the merged polygon (Polygon or MultiPolygon)' - ), - type: z.string().describe('Geometry type: Polygon or MultiPolygon') -}); +import { MapAppRefSchema } from '../../utils/storeMapPayload.js'; + +export const UnionOutputSchema = z + .object({ + geometry: z + .record(z.string(), z.unknown()) + .describe( + 'GeoJSON geometry of the merged polygon (Polygon or MultiPolygon)' + ), + type: z.string().describe('Geometry type: Polygon or MultiPolygon'), + mapboxRender: MapAppRefSchema.optional() + }) + .passthrough(); export type UnionOutput = z.infer; diff --git a/src/tools/union-tool/UnionTool.ts b/src/tools/union-tool/UnionTool.ts index 7c735809..6f77e5ce 100644 --- a/src/tools/union-tool/UnionTool.ts +++ b/src/tools/union-tool/UnionTool.ts @@ -11,6 +11,8 @@ import { type UnionOutput } from './UnionTool.output.schema.js'; import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { buildPolygonOpsMapPayload } from './buildPolygonOpsMapPayload.js'; +import { storeMapPayload, renderHint } from '../../utils/storeMapPayload.js'; export class UnionTool extends BaseTool< typeof UnionInputSchema, @@ -21,7 +23,10 @@ export class UnionTool extends BaseTool< 'Merge two or more polygons into a single unified geometry. ' + 'Useful for combining service areas, delivery zones, isochrones, or coverage regions. ' + 'Returns a Polygon or MultiPolygon if the inputs do not overlap. ' + - 'Works offline without API calls.'; + 'Works offline without API calls. ' + + 'INPUT SHAPE: pass `polygons` as an array of polygons. Each polygon is an array of rings; each ring is an array of [lng, lat] pairs. ' + + 'When chaining with isochrone_tool, extract `feature.geometry.coordinates` from each isochrone Feature — that is already a valid Polygon value. ' + + 'Skip features whose `geometry.type === "MultiPolygon"` (pass each inner Polygon separately) or whose `geometry.type === "LineString"` (set isochrone_tool `polygons=true` to get Polygon output instead).'; readonly annotations = { title: 'Union Polygons', @@ -30,7 +35,6 @@ export class UnionTool extends BaseTool< idempotentHint: true, openWorldHint: false }; - constructor() { super({ inputSchema: UnionInputSchema, outputSchema: UnionOutputSchema }); } @@ -60,12 +64,28 @@ export class UnionTool extends BaseTool< `Result type: ${validated.type}\n` + `GeoJSON geometry:\n${JSON.stringify(validated.geometry, null, 2)}`; + const mapPayload = buildPolygonOpsMapPayload({ + operation: 'union', + inputs: polys as Array<{ type: 'Feature'; geometry: unknown }>, + result: merged as { type: 'Feature'; geometry: unknown }, + summary: `Union of ${input.polygons.length} polygons` + }); + const sc: Record = { + ...(validated as unknown as Record) + }; + let textOut = text; + if (mapPayload) { + const ref = storeMapPayload(mapPayload); + sc.mapboxRender = { ref }; + textOut += renderHint(ref); + } + toolContext.span.setStatus({ code: SpanStatusCode.OK }); toolContext.span.end(); return { - content: [{ type: 'text' as const, text }], - structuredContent: validated, + content: [{ type: 'text' as const, text: textOut }], + structuredContent: sc, isError: false }; } catch (error) { diff --git a/src/tools/union-tool/buildPolygonOpsMapPayload.ts b/src/tools/union-tool/buildPolygonOpsMapPayload.ts new file mode 100644 index 00000000..e97eae59 --- /dev/null +++ b/src/tools/union-tool/buildPolygonOpsMapPayload.ts @@ -0,0 +1,82 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { MapAppPayload } from '../../utils/mapAppPayload.js'; + +export type PolygonOperation = 'union' | 'intersect' | 'difference'; + +const RESULT_COLORS: Record = { + union: '#22c55e', // green + intersect: '#8b5cf6', // purple + difference: '#f97316' // orange +}; + +/** + * Build a `MapAppPayload` for the offline polygon-op tools + * (union/intersect/difference). Renders input polygons in muted blue and the + * result in an operation-keyed color. Used by all three tools — color and + * summary are the only operation-dependent fields. + */ +export function buildPolygonOpsMapPayload(params: { + operation: PolygonOperation; + inputs: Array<{ type: 'Feature'; geometry: unknown }>; + result: { type: 'Feature'; geometry: unknown } | null; + summary: string; +}): MapAppPayload | null { + const { operation, inputs, result, summary } = params; + if (inputs.length === 0) return null; + + const resultColor = RESULT_COLORS[operation]; + const layers: MapAppPayload['layers'] = []; + + inputs.forEach((feature, i) => { + layers.push({ + id: `input-fill-${i}`, + type: 'fill', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: feature as any, + paint: { 'fill-color': '#3b82f6', 'fill-opacity': 0.25 } + }); + layers.push({ + id: `input-line-${i}`, + type: 'line', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: feature as any, + paint: { + 'line-color': '#3b82f6', + 'line-width': 2, + 'line-opacity': 0.7 + }, + layout: { 'line-join': 'round', 'line-cap': 'round' } + }); + }); + + if (result && result.geometry) { + layers.push({ + id: 'result-fill', + type: 'fill', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: result as any, + paint: { 'fill-color': resultColor, 'fill-opacity': 0.45 } + }); + layers.push({ + id: 'result-line', + type: 'line', + // eslint-disable-next-line @typescript-eslint/no-explicit-any + data: result as any, + paint: { 'line-color': resultColor, 'line-width': 3 }, + layout: { 'line-join': 'round', 'line-cap': 'round' } + }); + } + + return { + summary, + layers, + legend: [ + { label: 'Inputs', color: '#3b82f6', opacity: 0.35 }, + ...(result + ? [{ label: `${operation} result`, color: resultColor, opacity: 0.7 }] + : []) + ] + }; +} diff --git a/src/utils/mapAppPayload.ts b/src/utils/mapAppPayload.ts new file mode 100644 index 00000000..4b4b5e5c --- /dev/null +++ b/src/utils/mapAppPayload.ts @@ -0,0 +1,157 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +/** + * Payload format for the generic Mapbox MCP App (`ui://mapbox/map-app/...`). + * + * Tools that want to render their result on a live Mapbox GL JS map produce + * a `MapAppPayload` and attach it to the tool result at `_meta.ui.payload`. + * The shared iframe template reads it and translates each entry into + * `map.addSource`/`map.addLayer`/`new mapboxgl.Marker` calls. + * + * The payload is intentionally a thin pass-through to Mapbox Style spec + * `paint` and `layout` objects rather than its own DSL — the iframe just + * forwards them to GL JS. That keeps the spec surface small and avoids + * reinventing a chunk of the style spec over postMessage. + */ + +export type Geometry = + | { type: 'Point'; coordinates: [number, number] } + | { type: 'LineString'; coordinates: [number, number][] } + | { type: 'Polygon'; coordinates: [number, number][][] } + | { type: 'MultiPolygon'; coordinates: [number, number][][][] }; + +export type Feature = { + type: 'Feature'; + geometry: Geometry; + properties?: Record; +}; + +export type FeatureCollection = { + type: 'FeatureCollection'; + features: Feature[]; +}; + +export interface MapAppLayer { + /** Unique within the payload — used as both source id and layer id. */ + id: string; + type: 'fill' | 'line' | 'circle' | 'symbol'; + data: Feature | FeatureCollection; + /** Mapbox Style spec paint object, passed through to addLayer. */ + paint?: Record; + /** Mapbox Style spec layout object, passed through to addLayer. */ + layout?: Record; +} + +export interface MapAppMarker { + coordinates: [number, number]; + /** + * Visual style: + * - `pin`: default Mapbox marker + * - `numbered`: circular badge containing `label` (e.g. visit order) + * - `start`/`end`: green/red circular badge (route endpoints) + */ + style?: 'pin' | 'numbered' | 'start' | 'end'; + /** Required when style === 'numbered'. */ + label?: string; + /** Optional CSS color override; defaults are style-derived. */ + color?: string; + /** Popup text (optional). */ + popup?: string; +} + +export interface MapAppLegendEntry { + label: string; + color: string; + opacity?: number; +} + +export interface MapAppCamera { + center?: [number, number]; + zoom?: number; + /** If set, takes precedence over center/zoom and over auto-fit. */ + bounds?: [[number, number], [number, number]]; +} + +/** + * For responses >50KB where the geometry is offloaded to a temp resource: + * the iframe will fetch this URI via `resources/read` (host bridge) and + * merge the returned GeoJSON into the named layer. + */ +export interface MapAppDeferredLayer { + resourceUri: string; + layerId: string; +} + +export interface MapAppPayload { + /** Short header chip shown in the top-left of the iframe. */ + summary?: string; + /** Layers to add to the map (in order). */ + layers: MapAppLayer[]; + markers?: MapAppMarker[]; + /** Bottom-left legend rows (color swatch + label). */ + legend?: MapAppLegendEntry[]; + /** Optional initial camera; otherwise auto-fits to the union of all data. */ + camera?: MapAppCamera; + /** Optional fetch-on-render hook for large geometries. */ + defer?: MapAppDeferredLayer; +} + +/** + * Decode a Mapbox polyline string (precision 5 by default) to a GeoJSON + * LineString. Used tool-side so the iframe never has to do this — the + * generic renderer only ever sees GeoJSON. + * + * Returns null if the decoded coordinates fall outside lng/lat bounds, + * which can happen if the precision is wrong (the Directions API can emit + * polyline6 when `geometries=polyline6`). Callers should try precision 6 + * as a fallback. + */ +export function decodePolyline( + str: string, + precision = 5 +): [number, number][] | null { + if (!str || typeof str !== 'string') return null; + const factor = Math.pow(10, precision); + const coords: [number, number][] = []; + let lat = 0; + let lng = 0; + let i = 0; + while (i < str.length) { + let shift = 0; + let result = 0; + let b: number; + do { + b = str.charCodeAt(i++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20 && i < str.length); + lat += result & 1 ? ~(result >> 1) : result >> 1; + shift = 0; + result = 0; + do { + b = str.charCodeAt(i++) - 63; + result |= (b & 0x1f) << shift; + shift += 5; + } while (b >= 0x20 && i < str.length); + lng += result & 1 ? ~(result >> 1) : result >> 1; + const lngOut = lng / factor; + const latOut = lat / factor; + if (lngOut < -180 || lngOut > 180 || latOut < -90 || latOut > 90) { + return null; + } + coords.push([lngOut, latOut]); + } + return coords; +} + +/** + * Convenience: decode polyline with precision-5 then precision-6 fallback. + * If both produce out-of-range coordinates, returns null and the caller + * should skip emitting a map-app payload (the geometry can't be drawn). + */ +export function decodePolylineWithFallback( + str: string +): [number, number][] | null { + return decodePolyline(str, 5) ?? decodePolyline(str, 6); +} diff --git a/src/utils/storeMapPayload.ts b/src/utils/storeMapPayload.ts new file mode 100644 index 00000000..34c64c0b --- /dev/null +++ b/src/utils/storeMapPayload.ts @@ -0,0 +1,109 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { randomUUID } from 'node:crypto'; +import { z } from 'zod'; +import { temporaryResourceManager } from './temporaryResourceManager.js'; +import type { MapAppPayload } from './mapAppPayload.js'; + +/** + * Schema for the `mapboxRender` field that data tools attach to their + * structuredContent. Each tool declares this on its output schema so + * Claude Desktop (and any other host that strictly validates tool results + * against the published output schema) doesn't flag the response. + */ +export const MapAppRefSchema = z + .object({ + ref: z + .string() + .describe( + 'Server-side payload reference. Pass to `render_map_tool` via `payload_refs: [""]` to display the data on a live Mapbox GL JS map.' + ) + }) + .describe( + 'Map-payload reference for `render_map_tool`. Surfaced so the LLM can chain the next call without re-emitting geometry.' + ); + +export type MapAppRef = z.infer; + +const TEMP_URI_PREFIX = 'mapbox://temp/map-payload-'; + +/** + * Stash a `MapAppPayload` server-side and return a short reference the LLM + * can pass to `render_map_tool` instead of inlining the whole payload as + * tool-call arguments. With detailed geometry (a directions polyline can + * easily be 5–50 KB), having the LLM emit the payload token-by-token costs + * 20–30 seconds per render and risks Anthropic API timeouts on big + * payloads (e.g. polygon-op chains). Passing a ref instead keeps the LLM's + * emission down to a few tokens. + */ +export function storeMapPayload(payload: MapAppPayload): string { + const id = randomUUID(); + const uri = `${TEMP_URI_PREFIX}${id}`; + // 30-minute TTL is the temporaryResourceManager default — same as the + // directions/isochrone large-response stash, so the lifetime story is + // consistent across uses. + temporaryResourceManager.create(id, uri, payload, { + toolName: 'map-payload' + }); + return uri; +} + +/** + * One-line render hint that data tools append to their text output. Tells the + * LLM the EXACT shape of the next call so it doesn't hallucinate a ref URI. + * (Seen in the wild: Sonnet inventing `mapbox-isochrone-tool-result://0` when + * only structuredContent.mapboxRender.ref had the real `mapbox://temp/map-payload-…` + * URI — including the literal string in the visible text fixes that.) + */ +export function renderHint(ref: string): string { + return ( + `\n\n📍 To show this on a live Mapbox GL JS map, call:\n` + + ` render_map_tool({ "payload_refs": ["${ref}"] })` + ); +} + +/** + * Resolve a `mapbox://temp/map-payload-...` ref back to its `MapAppPayload`. + * Returns null if the ref is unknown or expired. + */ +export function resolveMapPayloadRef(ref: string): MapAppPayload | null { + if (!ref.startsWith(TEMP_URI_PREFIX)) return null; + const entry = temporaryResourceManager.get(ref); + if (!entry || !entry.data) return null; + return entry.data as MapAppPayload; +} + +/** + * Merge multiple `MapAppPayload`s into one. Layer/marker IDs are + * deduplicated by appending an index suffix when collisions happen. + * Summaries are joined with " · ". Legends are concatenated. + */ +export function mergeMapPayloads(payloads: MapAppPayload[]): MapAppPayload { + const seenLayerIds = new Set(); + const layers: MapAppPayload['layers'] = []; + const markers: NonNullable = []; + const legend: NonNullable = []; + const summaries: string[] = []; + + payloads.forEach((p, payloadIdx) => { + if (p.summary) summaries.push(p.summary); + if (Array.isArray(p.layers)) { + for (const layer of p.layers) { + let id = layer.id; + if (seenLayerIds.has(id)) id = `${layer.id}-${payloadIdx}`; + seenLayerIds.add(id); + layers.push({ ...layer, id }); + } + } + if (Array.isArray(p.markers)) markers.push(...p.markers); + if (Array.isArray(p.legend)) legend.push(...p.legend); + }); + + return { + summary: summaries.length > 0 ? summaries.join(' · ') : undefined, + layers, + markers: markers.length > 0 ? markers : undefined, + legend: legend.length > 0 ? legend : undefined + }; +} diff --git a/test/config/toolConfig.test.ts b/test/config/toolConfig.test.ts index 0a8e891d..3d9c1670 100644 --- a/test/config/toolConfig.test.ts +++ b/test/config/toolConfig.test.ts @@ -1,15 +1,7 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. -import { - describe, - it, - expect, - beforeEach, - afterEach, - afterAll, - vi -} from 'vitest'; +import { describe, it, expect, beforeEach, afterAll, vi } from 'vitest'; import type { ToolConfig } from '../../src/config/toolConfig.js'; import { parseToolConfigFromArgs, @@ -45,16 +37,13 @@ describe('Tool Configuration', () => { it('should return empty config when no arguments provided', () => { process.argv = ['node', 'index.js']; const config = parseToolConfigFromArgs(); - expect(config).toEqual({ enableMcpUi: true }); + expect(config).toEqual({}); }); it('should parse --enable-tools with single tool', () => { process.argv = ['node', 'index.js', '--enable-tools', 'version_tool']; const config = parseToolConfigFromArgs(); - expect(config).toEqual({ - enabledTools: ['version_tool'], - enableMcpUi: true - }); + expect(config).toEqual({ enabledTools: ['version_tool'] }); }); it('should parse --enable-tools with multiple tools', () => { @@ -66,8 +55,7 @@ describe('Tool Configuration', () => { ]; const config = parseToolConfigFromArgs(); expect(config).toEqual({ - enabledTools: ['version_tool', 'directions_tool', 'matrix_tool'], - enableMcpUi: true + enabledTools: ['version_tool', 'directions_tool', 'matrix_tool'] }); }); @@ -80,8 +68,7 @@ describe('Tool Configuration', () => { ]; const config = parseToolConfigFromArgs(); expect(config).toEqual({ - enabledTools: ['version_tool', 'directions_tool', 'matrix_tool'], - enableMcpUi: true + enabledTools: ['version_tool', 'directions_tool', 'matrix_tool'] }); }); @@ -93,10 +80,7 @@ describe('Tool Configuration', () => { 'static_map_image_tool' ]; const config = parseToolConfigFromArgs(); - expect(config).toEqual({ - disabledTools: ['static_map_image_tool'], - enableMcpUi: true - }); + expect(config).toEqual({ disabledTools: ['static_map_image_tool'] }); }); it('should parse --disable-tools with multiple tools', () => { @@ -108,8 +92,7 @@ describe('Tool Configuration', () => { ]; const config = parseToolConfigFromArgs(); expect(config).toEqual({ - disabledTools: ['static_map_image_tool', 'matrix_tool'], - enableMcpUi: true + disabledTools: ['static_map_image_tool', 'matrix_tool'] }); }); @@ -125,21 +108,20 @@ describe('Tool Configuration', () => { const config = parseToolConfigFromArgs(); expect(config).toEqual({ enabledTools: ['version_tool'], - disabledTools: ['matrix_tool'], - enableMcpUi: true + disabledTools: ['matrix_tool'] }); }); it('should handle missing value for --enable-tools', () => { process.argv = ['node', 'index.js', '--enable-tools']; const config = parseToolConfigFromArgs(); - expect(config).toEqual({ enableMcpUi: true }); + expect(config).toEqual({}); }); it('should handle missing value for --disable-tools', () => { process.argv = ['node', 'index.js', '--disable-tools']; const config = parseToolConfigFromArgs(); - expect(config).toEqual({ enableMcpUi: true }); + expect(config).toEqual({}); }); it('should ignore unknown arguments', () => { @@ -152,10 +134,7 @@ describe('Tool Configuration', () => { 'version_tool' ]; const config = parseToolConfigFromArgs(); - expect(config).toEqual({ - enabledTools: ['version_tool'], - enableMcpUi: true - }); + expect(config).toEqual({ enabledTools: ['version_tool'] }); }); }); @@ -233,44 +212,4 @@ describe('Tool Configuration', () => { expect(filtered).toEqual(mockTools); }); }); - - describe('MCP-UI Configuration', () => { - afterEach(() => { - // Clean up environment variables - delete process.env.ENABLE_MCP_UI; - // Reset argv to avoid affecting other tests - process.argv = ['node', 'index.js']; - }); - - it('should default MCP-UI to enabled', () => { - process.argv = ['node', 'index.js']; - const config = parseToolConfigFromArgs(); - expect(config.enableMcpUi).toBe(true); - }); - - it('should disable MCP-UI via environment variable', () => { - process.env.ENABLE_MCP_UI = 'false'; - const config = parseToolConfigFromArgs(); - expect(config.enableMcpUi).toBe(false); - }); - - it('should enable MCP-UI via environment variable', () => { - process.env.ENABLE_MCP_UI = 'true'; - const config = parseToolConfigFromArgs(); - expect(config.enableMcpUi).toBe(true); - }); - - it('should disable MCP-UI via command-line flag', () => { - process.argv = ['node', 'index.js', '--disable-mcp-ui']; - const config = parseToolConfigFromArgs(); - expect(config.enableMcpUi).toBe(false); - }); - - it('should prioritize environment variable over command-line flag', () => { - process.env.ENABLE_MCP_UI = 'true'; - process.argv = ['node', 'index.js', '--disable-mcp-ui']; - const config = parseToolConfigFromArgs(); - expect(config.enableMcpUi).toBe(true); - }); - }); }); diff --git a/test/resources/ui-apps/DirectionsAppUIResource.test.ts b/test/resources/ui-apps/DirectionsAppUIResource.test.ts deleted file mode 100644 index 135c4631..00000000 --- a/test/resources/ui-apps/DirectionsAppUIResource.test.ts +++ /dev/null @@ -1,124 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -// JWT with payload {"sub":"test","u":"testuser"} (base64) -const SK_TOKEN = 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; - -import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; -import { DirectionsAppUIResource } from '../../../src/resources/ui-apps/DirectionsAppUIResource.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('DirectionsAppUIResource', () => { - 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 DirectionsAppUIResource({ httpRequest }); - - const result = await resource.read( - 'ui://mapbox/directions-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/directions-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'); - - const meta = (entry as { _meta?: unknown })._meta as - | { ui?: { csp?: { workerDomains?: string[] } } } - | undefined; - expect(meta?.ui?.csp?.workerDomains).toContain('blob:'); - - // Confirm the tokens endpoint was hit with default=true - const tokensCall = httpRequest.mock.calls.find((c) => - (c[0] as string).includes('tokens/v2/') - ); - expect(tokensCall?.[0]).toContain('tokens/v2/testuser'); - expect(tokensCall?.[0]).toContain('default=true'); - }); - - 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 DirectionsAppUIResource({ httpRequest }); - - const result = await resource.read( - 'ui://mapbox/directions-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'); - }); - - it('still returns HTML (with empty token) when no token can be resolved', async () => { - const httpRequest = vi.fn( - async () => ({ ok: false, status: 403 }) as Response - ); - - const resource = new DirectionsAppUIResource({ httpRequest }); - - const result = await resource.read( - 'ui://mapbox/directions-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 - ); - - // HTML is still served — the iframe will surface a friendly error to the user - expect(result.contents[0].text as string).toContain( - 'No Mapbox public token available' - ); - }); -}); diff --git a/test/resources/ui-apps/MapAppUIResource.test.ts b/test/resources/ui-apps/MapAppUIResource.test.ts new file mode 100644 index 00000000..86fba3f1 --- /dev/null +++ b/test/resources/ui-apps/MapAppUIResource.test.ts @@ -0,0 +1,89 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +// JWT with payload {"sub":"test","u":"testuser"} (base64) +const SK_TOKEN = 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; +import { MapAppUIResource } from '../../../src/resources/ui-apps/MapAppUIResource.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('MapAppUIResource', () => { + beforeEach(() => { + __resetMapboxPublicTokenCache(); + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('serves HTML at ui://mapbox/map-app/index.html with the mcp-app mime type', 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 MapAppUIResource({ httpRequest }); + + const result = await resource.read('ui://mapbox/map-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/map-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'); + + const meta = (entry as { _meta?: unknown })._meta as + | { ui?: { csp?: { workerDomains?: string[] } } } + | undefined; + expect(meta?.ui?.csp?.workerDomains).toContain('blob:'); + }); + + it('still returns HTML when no token can be resolved', async () => { + const httpRequest = vi.fn( + async () => ({ ok: false, status: 403 }) as Response + ); + + const resource = new MapAppUIResource({ httpRequest }); + + const result = await resource.read('ui://mapbox/map-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( + 'No Mapbox public token available' + ); + }); +}); diff --git a/test/tools/annotations.test.ts b/test/tools/annotations.test.ts index 0a5448fa..5c54c44c 100644 --- a/test/tools/annotations.test.ts +++ b/test/tools/annotations.test.ts @@ -80,7 +80,8 @@ describe('Tool Annotations', () => { 'destination_tool', 'length_tool', 'nearest_point_on_line_tool', - 'convex_tool' + 'convex_tool', + 'render_map_tool' ]; const apiTools = tools.filter((tool) => !offlineTools.includes(tool.name)); @@ -109,7 +110,8 @@ describe('Tool Annotations', () => { 'destination_tool', 'length_tool', 'nearest_point_on_line_tool', - 'convex_tool' + 'convex_tool', + 'render_map_tool' ].includes(tool.name) ); diff --git a/test/tools/category-search-tool/CategorySearchTool.test.ts b/test/tools/category-search-tool/CategorySearchTool.test.ts index a7bd39d4..39ce4c98 100644 --- a/test/tools/category-search-tool/CategorySearchTool.test.ts +++ b/test/tools/category-search-tool/CategorySearchTool.test.ts @@ -438,4 +438,43 @@ describe('CategorySearchTool', () => { expect(tool.outputSchema).toBeDefined(); expect(tool.outputSchema).toBeTruthy(); }); + + it('stores a mapboxRender payload with numbered POI markers', async () => { + const fakeResp = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + mapbox_id: 'id-1', + name: 'Cafe Reveille', + feature_type: 'poi', + context: { country: { name: 'United States' } }, + coordinates: { longitude: -122.41, latitude: 37.78 } + }, + geometry: { type: 'Point', coordinates: [-122.41, 37.78] } + } + ], + attribution: '© Mapbox' + }; + const { httpRequest } = setupHttpRequest({ + ok: true, + json: async () => fakeResp + }); + const result = await new CategorySearchTool({ httpRequest }).run({ + category: 'cafe', + proximity: { longitude: -122.42, latitude: 37.78 } + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { mapboxRender?: { ref?: string } }; + expect(sc.mapboxRender?.ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const payload = resolveMapPayloadRef(sc.mapboxRender!.ref!); + // Search-center pin + 1 numbered marker + expect(payload?.markers?.[0]?.style).toBe('pin'); + expect(payload?.markers?.[1]?.style).toBe('numbered'); + }); }); diff --git a/test/tools/difference-tool/DifferenceTool.test.ts b/test/tools/difference-tool/DifferenceTool.test.ts index 84de1d7f..09b237d3 100644 --- a/test/tools/difference-tool/DifferenceTool.test.ts +++ b/test/tools/difference-tool/DifferenceTool.test.ts @@ -177,4 +177,36 @@ describe('DifferenceTool', () => { 'fully covers' ); }); + + it('stores a mapboxRender payload with input fills + difference result', async () => { + const result = await tool.run({ + polygon1: [ + [ + [0, 0], + [4, 0], + [4, 4], + [0, 4], + [0, 0] + ] + ], + polygon2: [ + [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + [1, 1] + ] + ] + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { mapboxRender?: { ref?: string } }; + expect(sc.mapboxRender?.ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const payload = resolveMapPayloadRef(sc.mapboxRender!.ref!); + expect(payload?.legend?.[1]?.label).toBe('difference result'); + }); }); diff --git a/test/tools/directions-tool/DirectionsTool.test.ts b/test/tools/directions-tool/DirectionsTool.test.ts index 027de31b..cfae49ac 100644 --- a/test/tools/directions-tool/DirectionsTool.test.ts +++ b/test/tools/directions-tool/DirectionsTool.test.ts @@ -1065,16 +1065,16 @@ describe('DirectionsTool', () => { }); }); - describe('MCP App + MCP-UI integration', () => { - it('declares meta.ui.resourceUri pointing to the directions-app resource', () => { + describe('Map payload integration', () => { + it('does not declare meta.ui.resourceUri (rendering goes through render_map_tool)', () => { const { httpRequest } = setupHttpRequest(); const tool = new DirectionsTool({ httpRequest }); - expect(tool.meta?.ui?.resourceUri).toBe( - 'ui://mapbox/directions-app/index.html' - ); + expect( + (tool as { meta?: { ui?: { resourceUri?: string } } }).meta + ).toBeUndefined(); }); - it('adds an inline MCP-UI rawHtml resource for small geojson responses', async () => { + it('attaches mapboxRender payload to structuredContent for small geojson responses', async () => { const fakeResponse = { routes: [ { @@ -1096,66 +1096,51 @@ describe('DirectionsTool', () => { ], code: 'Ok' }; - const tokensListResponse = [ - { - usage: 'pk', - default: true, - token: 'pk.fake-public-token' - } - ]; - const httpRequestFn = vi.fn(async (url: string) => { - if (url.includes('tokens/v2/')) { - return { + const httpRequestFn = vi.fn( + async () => + ({ ok: true, status: 200, statusText: 'OK', - json: async () => tokensListResponse, - text: async () => JSON.stringify(tokensListResponse) - } as Response; - } - return { - ok: true, - status: 200, - statusText: 'OK', - json: async () => fakeResponse, - text: async () => JSON.stringify(fakeResponse) - } as Response; - }); - - // Mapbox sk.* tokens are 3 dot-segments: sk.. - const realToken = process.env.MAPBOX_ACCESS_TOKEN; - const payload = Buffer.from(JSON.stringify({ u: 'testuser' })).toString( - 'base64' + json: async () => fakeResponse, + text: async () => JSON.stringify(fakeResponse) + }) as Response ); - process.env.MAPBOX_ACCESS_TOKEN = `sk.${payload}.signature`; - try { - const result = await new DirectionsTool({ - httpRequest: httpRequestFn - }).run({ - coordinates: [ - { longitude: -74.0, latitude: 40.7 }, - { longitude: -74.01, latitude: 40.71 } - ], - geometries: 'geojson' - }); + const result = await new DirectionsTool({ + httpRequest: httpRequestFn + }).run({ + coordinates: [ + { longitude: -74.0, latitude: 40.7 }, + { longitude: -74.01, latitude: 40.71 } + ], + geometries: 'geojson' + }); - expect(result.isError).toBe(false); - // Expect at least the response text + the MCP-UI resource block - expect(result.content.length).toBeGreaterThanOrEqual(2); - const uiBlock = result.content.find( - (c) => (c as { type?: string }).type === 'resource' - ) as { resource?: { text?: string; mimeType?: string } } | undefined; - expect(uiBlock).toBeDefined(); - expect(uiBlock?.resource?.text).toContain('mapbox-gl.js'); - expect(uiBlock?.resource?.text).toContain('pk.fake-public-token'); - // Initial-data block should carry the baked-in geometry - expect(uiBlock?.resource?.text).toContain('initial-data'); - expect(uiBlock?.resource?.text).toContain('LineString'); - } finally { - process.env.MAPBOX_ACCESS_TOKEN = realToken; - } + expect(result.isError).toBe(false); + // No inline UI block — content is text-only; rendering is the LLM's + // job via render_map_tool with the mapboxRender ref passed through. + expect(result.content.length).toBe(1); + expect((result.content[0] as { type: string }).type).toBe('text'); + + // The full payload is stashed server-side; the tool only surfaces a + // short ref the LLM can pass to render_map_tool. + const sc = result.structuredContent as + | { mapboxRender?: { ref?: string } } + | undefined; + const ref = sc?.mapboxRender?.ref; + expect(typeof ref).toBe('string'); + expect(ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + + // Dereferencing the ref returns the original payload. + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const payload = resolveMapPayloadRef(ref!); + expect(payload?.layers?.[0]?.id).toBe('route'); + expect(payload?.layers?.[0]?.type).toBe('line'); + expect(payload?.markers?.map((m) => m.style)).toEqual(['start', 'end']); + expect(payload?.summary).toMatch(/mi/); }); }); }); diff --git a/test/tools/ground-location-tool/GroundLocationTool.test.ts b/test/tools/ground-location-tool/GroundLocationTool.test.ts index 9ccf192a..b818809b 100644 --- a/test/tools/ground-location-tool/GroundLocationTool.test.ts +++ b/test/tools/ground-location-tool/GroundLocationTool.test.ts @@ -267,4 +267,32 @@ describe('GroundLocationTool', () => { ); expect(categoryCall?.[0]).toContain('limit=15'); }); + + it('stores a mapboxRender payload with origin pin and POI markers', async () => { + const { tool } = setupMockHttp({ + 'geocode/v6/reverse': geocodeResponse, + 'search/searchbox/v1/category': categoryResponse, + 'isochrone/v1': isochroneResponse + }); + + const result = await tool.run({ + longitude: -122.419, + latitude: 37.759, + query: 'coffee' + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { mapboxRender?: { ref?: string } }; + expect(sc.mapboxRender?.ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const payload = resolveMapPayloadRef(sc.mapboxRender!.ref!); + // First marker is the grounded origin; subsequent are numbered POIs. + expect(payload?.markers?.[0]?.style).toBe('pin'); + expect(payload?.markers?.[0]?.popup).toBe('Mission District'); + expect(payload?.markers?.slice(1).map((m) => m.style)).toEqual([ + 'numbered' + ]); + }); }); diff --git a/test/tools/intersect-tool/IntersectTool.test.ts b/test/tools/intersect-tool/IntersectTool.test.ts index 1a2441aa..e2bb85f4 100644 --- a/test/tools/intersect-tool/IntersectTool.test.ts +++ b/test/tools/intersect-tool/IntersectTool.test.ts @@ -177,4 +177,36 @@ describe('IntersectTool', () => { 'do not intersect' ); }); + + it('stores a mapboxRender payload with input fills + intersection result', async () => { + const result = await tool.run({ + polygon1: [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0] + ] + ], + polygon2: [ + [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + [1, 1] + ] + ] + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { mapboxRender?: { ref?: string } }; + expect(sc.mapboxRender?.ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const payload = resolveMapPayloadRef(sc.mapboxRender!.ref!); + expect(payload?.legend?.[1]?.label).toBe('intersect result'); + }); }); diff --git a/test/tools/isochrone-tool/IsochroneTool.test.ts b/test/tools/isochrone-tool/IsochroneTool.test.ts index d91eedb9..21b071f7 100644 --- a/test/tools/isochrone-tool/IsochroneTool.test.ts +++ b/test/tools/isochrone-tool/IsochroneTool.test.ts @@ -103,7 +103,11 @@ describe('IsochroneTool', () => { assertHeadersSent(mockHttpRequest); expect(result.content[0].type).toEqual('text'); if (result.content[0].type == 'text') { - expect(result.content[0].text).toEqual(JSON.stringify(geojson, null, 2)); + // The tool may append a render-map hint pointing at the stored payload; + // assert the body starts with the JSON-stringified response. + expect(result.content[0].text).toContain( + JSON.stringify(geojson, null, 2) + ); } }); @@ -131,4 +135,62 @@ describe('IsochroneTool', () => { expect(result.content[0].type).toEqual('text'); expect(result.isError).toBe(true); }); + + it('stores a mapboxRender payload that includes a fill+line per contour', async () => { + const isochrone = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + contour: 10, + fillColor: '6b7280', + fillOpacity: 0.3, + metric: 'time' + }, + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-74.01, 40.71], + [-74.0, 40.71], + [-74.0, 40.72], + [-74.01, 40.72], + [-74.01, 40.71] + ] + ] + } + } + ] + }; + const { httpRequest } = setupHttpRequest({ + ok: true, + json: async () => isochrone + }); + + const result = await new IsochroneTool({ httpRequest }).run({ + coordinates: { longitude: -74.006, latitude: 40.7128 }, + profile: 'mapbox/driving', + contours_minutes: [10], + polygons: true, + generalize: 1000 + }); + + expect(result.isError).toBe(false); + + const sc = result.structuredContent as { mapboxRender?: { ref?: string } }; + expect(sc.mapboxRender?.ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain('render_map_tool'); + expect(text).toContain(sc.mapboxRender!.ref!); + + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const payload = resolveMapPayloadRef(sc.mapboxRender!.ref!); + // One fill + one line per polygon contour, plus origin marker. + expect(payload?.layers?.map((l) => l.type)).toEqual(['fill', 'line']); + expect(payload?.markers).toHaveLength(1); + expect(payload?.markers?.[0].coordinates).toEqual([-74.006, 40.7128]); + }); }); diff --git a/test/tools/map-matching-tool/MapMatchingTool.test.ts b/test/tools/map-matching-tool/MapMatchingTool.test.ts index 0c403aab..66fe6a92 100644 --- a/test/tools/map-matching-tool/MapMatchingTool.test.ts +++ b/test/tools/map-matching-tool/MapMatchingTool.test.ts @@ -245,4 +245,66 @@ describe('MapMatchingTool', () => { const callUrl = mockHttpRequest.mock.calls[0][0] as string; expect(callUrl).toContain('geometries=geojson'); }); + + it('stores a mapboxRender payload with raw + matched line layers', async () => { + const fakeResp = { + code: 'Ok', + matchings: [ + { + confidence: 0.9, + distance: 200, + duration: 60, + geometry: { + type: 'LineString', + coordinates: [ + [-122.4194, 37.7749], + [-122.4195, 37.775] + ] + } + } + ], + tracepoints: [ + { + name: '', + location: [-122.4194, 37.7749], + matchings_index: 0, + alternatives_count: 0 + }, + { + name: '', + location: [-122.4195, 37.775], + matchings_index: 0, + alternatives_count: 0 + } + ] + }; + const { httpRequest } = setupHttpRequest({ + ok: true, + json: async () => fakeResp + }); + + const result = await new MapMatchingTool({ httpRequest }).run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 } + ], + profile: 'driving' + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { mapboxRender?: { ref?: string } }; + expect(sc.mapboxRender?.ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const payload = resolveMapPayloadRef(sc.mapboxRender!.ref!); + expect(payload?.layers?.map((l) => l.id)).toEqual([ + 'raw-trace', + 'matched-route' + ]); + expect(payload?.legend?.map((e) => e.label)).toEqual([ + 'Raw trace', + 'Matched route' + ]); + }); }); diff --git a/test/tools/optimization-tool/OptimizationTool.test.ts b/test/tools/optimization-tool/OptimizationTool.test.ts index a347edab..32d99419 100644 --- a/test/tools/optimization-tool/OptimizationTool.test.ts +++ b/test/tools/optimization-tool/OptimizationTool.test.ts @@ -271,4 +271,42 @@ describe('OptimizationTool V1 API', () => { expect(text).toContain('km'); expect(text).toContain('0 → 1 → 2'); }); + + it('stores a mapboxRender payload with the trip line and numbered visit markers', async () => { + const { httpRequest } = setupHttpRequest({ + ok: true, + status: 200, + json: async () => sampleV1Response + }); + + const tool = new OptimizationTool({ httpRequest }); + const result = await tool.run({ + coordinates: [ + { longitude: -122.4194, latitude: 37.7749 }, + { longitude: -122.4195, latitude: 37.775 }, + { longitude: -122.4197, latitude: 37.7751 } + ] + }); + + expect(result.isError).toBe(false); + + const sc = result.structuredContent as { mapboxRender?: { ref?: string } }; + expect(sc.mapboxRender?.ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain('render_map_tool'); + expect(text).toContain(sc.mapboxRender!.ref!); + + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const payload = resolveMapPayloadRef(sc.mapboxRender!.ref!); + expect(payload?.layers?.[0]?.id).toBe('trip'); + expect(payload?.layers?.[0]?.type).toBe('line'); + expect(payload?.markers?.map((m) => m.style)).toEqual([ + 'numbered', + 'numbered', + 'numbered' + ]); + expect(payload?.markers?.map((m) => m.label)).toEqual(['1', '2', '3']); + }); }); diff --git a/test/tools/render-map-tool/RenderMapTool.test.ts b/test/tools/render-map-tool/RenderMapTool.test.ts new file mode 100644 index 00000000..8e9aa70e --- /dev/null +++ b/test/tools/render-map-tool/RenderMapTool.test.ts @@ -0,0 +1,190 @@ +// 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 { RenderMapTool } from '../../../src/tools/render-map-tool/RenderMapTool.js'; + +describe('RenderMapTool', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('declares meta.ui.resourceUri targeting the shared map-app resource', () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + expect(tool.meta?.ui?.resourceUri).toBe('ui://mapbox/map-app/index.html'); + }); + + it('echoes layer + marker counts in the result', async () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run({ + summary: 'Test trip', + layers: [ + { + id: 'route', + type: 'line', + data: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-77, 38], + [-76, 39] + ] + }, + properties: {} + }, + paint: { 'line-color': '#3b82f6', 'line-width': 5 } + } + ], + markers: [ + { coordinates: [-77, 38], style: 'start' }, + { coordinates: [-76, 39], style: 'end' } + ] + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { + rendered: boolean; + layer_count: number; + marker_count: number; + mapboxRender?: { ref?: string }; + }; + expect(sc.rendered).toBe(true); + expect(sc.layer_count).toBe(1); + expect(sc.marker_count).toBe(2); + // The merged payload is stashed server-side; structuredContent only + // surfaces a ref so even a 300KB payload round-trips through the host + // bridge without truncation. + expect(sc.mapboxRender?.ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const stored = resolveMapPayloadRef(sc.mapboxRender!.ref!); + expect(stored?.layers).toHaveLength(1); + expect(stored?.markers).toHaveLength(2); + }); + + it('rejects coordinates that are not [lng, lat] pairs', async () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run({ + markers: [{ coordinates: [-77], style: 'pin' }] + }); + expect(result.isError).toBe(true); + }); + + it('accepts a payload with only markers (no layers)', async () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run({ + summary: 'Search results', + markers: [ + { coordinates: [-77, 38], style: 'pin', popup: 'Result 1' }, + { coordinates: [-76, 39], style: 'pin', popup: 'Result 2' } + ] + }); + expect(result.isError).toBe(false); + const sc = result.structuredContent as { layer_count: number }; + expect(sc.layer_count).toBe(0); + }); + + it('resolves a payload_ref into a renderable payload', async () => { + const { storeMapPayload } = + await import('../../../src/utils/storeMapPayload.js'); + const ref = storeMapPayload({ + summary: 'Cached route', + layers: [ + { + id: 'route', + type: 'line', + data: { + type: 'Feature', + geometry: { + type: 'LineString', + coordinates: [ + [-77, 38], + [-76, 39] + ] + }, + properties: {} + } + } + ] + }); + + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run({ payload_refs: [ref] }); + expect(result.isError).toBe(false); + const sc = result.structuredContent as { + layer_count: number; + summary?: string; + }; + expect(sc.layer_count).toBe(1); + expect(sc.summary).toBe('Cached route'); + }); + + it('merges multiple payload_refs into a single map', async () => { + const { storeMapPayload } = + await import('../../../src/utils/storeMapPayload.js'); + const a = storeMapPayload({ + summary: 'Iso A', + layers: [ + { + id: 'a', + type: 'fill', + data: { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-77, 38], + [-76, 38], + [-76, 39], + [-77, 39], + [-77, 38] + ] + ] + }, + properties: {} + } + } + ] + }); + const b = storeMapPayload({ + summary: 'Iso B', + layers: [ + { + id: 'a', // colliding id → should be renamed during merge + type: 'fill', + data: { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [ + [ + [-78, 38], + [-77, 38], + [-77, 39], + [-78, 39], + [-78, 38] + ] + ] + }, + properties: {} + } + } + ] + }); + + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run({ payload_refs: [a, b] }); + expect(result.isError).toBe(false); + const sc = result.structuredContent as { + layer_count: number; + summary?: string; + }; + expect(sc.layer_count).toBe(2); + expect(sc.summary).toBe('Iso A · Iso B'); + }); +}); diff --git a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts index a9dac939..b4a1c0ed 100644 --- a/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts +++ b/test/tools/search-and-geocode-tool/SearchAndGeocodeTool.test.ts @@ -617,4 +617,62 @@ describe('SearchAndGeocodeTool', () => { ]); }); }); + + describe('map render payload', () => { + it('stores a mapboxRender payload with numbered POI markers and search-center pin', async () => { + const fakeResp = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { + name: 'Blue Bottle', + full_address: '66 Mint St', + feature_type: 'poi', + context: {} + }, + geometry: { type: 'Point', coordinates: [-122.39, 37.78] } + }, + { + type: 'Feature', + properties: { + name: 'Sightglass', + full_address: '270 7th St', + feature_type: 'poi', + context: {} + }, + geometry: { type: 'Point', coordinates: [-122.41, 37.77] } + } + ] + }; + const { httpRequest } = setupHttpRequest({ + ok: true, + json: async () => fakeResp + }); + const result = await new SearchAndGeocodeTool({ httpRequest }).run({ + q: 'coffee', + proximity: { longitude: -122.4194, latitude: 37.7749 } + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { + mapboxRender?: { ref?: string }; + }; + expect(sc.mapboxRender?.ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + + const text = (result.content[0] as { text: string }).text; + expect(text).toContain('render_map_tool'); + expect(text).toContain(sc.mapboxRender!.ref!); + + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const payload = resolveMapPayloadRef(sc.mapboxRender!.ref!); + // First marker is the search-center pin; rest are numbered POIs. + expect(payload?.markers?.[0]?.style).toBe('pin'); + expect(payload?.markers?.slice(1).map((m) => m.style)).toEqual([ + 'numbered', + 'numbered' + ]); + }); + }); }); diff --git a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts index 6e175814..1ec0b8db 100644 --- a/test/tools/static-map-image-tool/StaticMapImageTool.test.ts +++ b/test/tools/static-map-image-tool/StaticMapImageTool.test.ts @@ -722,8 +722,8 @@ describe('StaticMapImageTool', () => { }); }); - describe('MCP-UI support', () => { - it('includes UIResource when MCP-UI is enabled (default)', async () => { + describe('content shape', () => { + it('returns URL text + base64 image (no MCP-UI fallback)', async () => { const { httpRequest } = setupHttpRequest(); const result = await new StaticMapImageTool({ httpRequest }).run({ @@ -734,70 +734,9 @@ describe('StaticMapImageTool', () => { }); expect(result.isError).toBe(false); - expect(result.content).toHaveLength(3); // URL + image + UIResource + expect(result.content).toHaveLength(2); expect(result.content[0].type).toBe('text'); expect(result.content[1].type).toBe('image'); - expect(result.content[2].type).toBe('resource'); - if (result.content[2].type === 'resource') { - expect(result.content[2].resource.uri).toMatch( - /^ui:\/\/mapbox\/static-map\// - ); - } - }); - - it('does not include UIResource when MCP-UI is disabled', async () => { - // Set environment variable to disable MCP-UI - const originalEnv = process.env.ENABLE_MCP_UI; - process.env.ENABLE_MCP_UI = 'false'; - - try { - const { httpRequest } = setupHttpRequest(); - - const result = await new StaticMapImageTool({ httpRequest }).run({ - center: { longitude: -74.006, latitude: 40.7128 }, - zoom: 12, - size: { width: 600, height: 400 }, - style: 'mapbox/streets-v12' - }); - - expect(result.isError).toBe(false); - expect(result.content).toHaveLength(2); // URL + image, no UIResource - expect(result.content[0].type).toBe('text'); - } finally { - // Restore environment variable - if (originalEnv !== undefined) { - process.env.ENABLE_MCP_UI = originalEnv; - } else { - delete process.env.ENABLE_MCP_UI; - } - } - }); - - it('UIResource includes correct iframe URL and dimensions', async () => { - const { httpRequest } = setupHttpRequest(); - - const result = await new StaticMapImageTool({ httpRequest }).run({ - center: { longitude: -122.4194, latitude: 37.7749 }, - zoom: 13, - size: { width: 800, height: 600 }, - style: 'mapbox/satellite-streets-v12' - }); - - expect(result.isError).toBe(false); - // UIResource is at index 2 (after URL text and base64 image) - if (result.content[2]?.type === 'resource') { - expect(result.content[2].resource.uri).toContain( - '-122.4194,37.7749,13' - ); - // Check that UIMetadata has preferred dimensions - if ('uiMetadata' in result.content[2].resource) { - const metadata = result.content[2].resource.uiMetadata as Record< - string, - unknown - >; - expect(metadata['preferred-frame-size']).toEqual(['800px', '600px']); - } - } }); }); }); diff --git a/test/tools/union-tool/UnionTool.test.ts b/test/tools/union-tool/UnionTool.test.ts index d71548e1..2be8be9e 100644 --- a/test/tools/union-tool/UnionTool.test.ts +++ b/test/tools/union-tool/UnionTool.test.ts @@ -161,4 +161,40 @@ describe('UnionTool', () => { expect(result.isError).toBe(true); }); + + it('stores a mapboxRender payload with input fills + result fill', async () => { + const result = await tool.run({ + polygons: [ + [ + [ + [0, 0], + [2, 0], + [2, 2], + [0, 2], + [0, 0] + ] + ], + [ + [ + [1, 1], + [3, 1], + [3, 3], + [1, 3], + [1, 1] + ] + ] + ] + }); + + expect(result.isError).toBe(false); + const sc = result.structuredContent as { mapboxRender?: { ref?: string } }; + expect(sc.mapboxRender?.ref).toMatch(/^mapbox:\/\/temp\/map-payload-/); + + const { resolveMapPayloadRef } = + await import('../../../src/utils/storeMapPayload.js'); + const payload = resolveMapPayloadRef(sc.mapboxRender!.ref!); + // 2 inputs × (fill + line) + result (fill + line) = 6 layers + expect(payload?.layers).toHaveLength(6); + expect(payload?.legend?.[1]?.label).toBe('union result'); + }); }); diff --git a/test/utils/mapAppPayload.test.ts b/test/utils/mapAppPayload.test.ts new file mode 100644 index 00000000..9c528482 --- /dev/null +++ b/test/utils/mapAppPayload.test.ts @@ -0,0 +1,37 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { + decodePolyline, + decodePolylineWithFallback +} from '../../src/utils/mapAppPayload.js'; + +describe('decodePolyline', () => { + // Reference encoding from the Google Encoded Polyline format docs: + // coordinates [[-120.2, 38.5], [-120.95, 40.7], [-126.453, 43.252]] + // precision 5 => "_p~iF~ps|U_ulLnnqC_mqNvxq`@" + const ENC_5 = '_p~iF~ps|U_ulLnnqC_mqNvxq`@'; + + it('decodes a precision-5 polyline to GeoJSON-ordered coordinates', () => { + const out = decodePolyline(ENC_5, 5); + expect(out).not.toBeNull(); + expect(out!.length).toBe(3); + expect(out![0][0]).toBeCloseTo(-120.2, 4); + expect(out![0][1]).toBeCloseTo(38.5, 4); + expect(out![2][0]).toBeCloseTo(-126.453, 4); + expect(out![2][1]).toBeCloseTo(43.252, 4); + }); + + it('returns null for empty or non-string input', () => { + expect(decodePolyline('', 5)).toBeNull(); + expect(decodePolyline(null as unknown as string, 5)).toBeNull(); + }); + + it('decodePolylineWithFallback returns the precision-5 decode when it succeeds', () => { + const out = decodePolylineWithFallback(ENC_5); + expect(out).not.toBeNull(); + expect(out!.length).toBe(3); + expect(out![0][0]).toBeCloseTo(-120.2, 4); + }); +});