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);
+ });
+});