feat: directions_app_tool — interactive GLJS map as MCP App#189
feat: directions_app_tool — interactive GLJS map as MCP App#189mattpodwysocki wants to merge 25 commits into
Conversation
Implements MCP server icons at the correct architectural level (server initialization) instead of at the tool level. Adds both light and dark theme variants of the Mapbox logo using base64-encoded SVG data URIs. - Add mapbox-logo-black.svg for light theme backgrounds - Add mapbox-logo-white.svg for dark theme backgrounds - Update server initialization to include icons array with theme property - Use 800x180 SVG logos embedded as base64 data URIs This replaces the previous incorrect approach of adding icons to individual tools, which was not aligned with the MCP specification. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Updates the MCP SDK from 1.25.1 to 1.25.2 and recreates the output validation patch for the new version. The patch continues to convert strict output schema validation errors to warnings, allowing tools to gracefully handle schema mismatches. Changes: - Update @modelcontextprotocol/sdk from ^1.25.1 to ^1.25.2 - Recreate SDK patch for version 1.25.2 - Remove obsolete 1.25.1 patch file - All 397 tests pass with new SDK version Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Proof-of-concept tool that renders a directions route on a live Mapbox GL JS map inside an MCP App iframe. Returns a self-contained rawHtml UI resource with the route line, start/end markers, and camera fit. Uses a separate MAPBOX_PUBLIC_TOKEN env var (public pk.* token) so the secret MAPBOX_ACCESS_TOKEN never leaves the server. The point of this tool is to probe whether GLJS works inside the sandboxed iframes that MCP App hosts (Claude Desktop, VS Code, Cursor) provide — when it works, the agent gets a fully interactive map; when it doesn't, the error surfaces in the iframe console rather than silently failing. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace the MAPBOX_PUBLIC_TOKEN env-var requirement with a smart token
resolver that calls GET /tokens/v2/{user}?default=true to fetch the
customer's default public (pk.*) token, caches it for an hour, and falls
back to the env var if the API call fails. Lets the tool work under
OAuth (tokens:read scope) without requiring operators to pre-configure
a separate public token.
Also adds _meta.ui.csp on the UI resource so MCP App hosts know to allow
blob workers and the Mapbox API connect/resource domains the GLJS bundle
needs to load tiles, fonts, sprites, and styles.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Claude Desktop and other MCP hosts only render HTML that's served via a properly-registered MCP App resource (mimeType: text/html;profile=mcp-app), not inline rawHtml on a tool result. Switch to that pattern: - DirectionsAppUIResource serves the GLJS HTML at ui://mapbox/directions-app/index.html with the public token baked in server-side. HTML implements the MCP App postMessage protocol and parses the route GeoJSON out of the tool result. - DirectionsAppTool drops the inline HTML / createUIResource entirely; it returns the route data as text and structured content, and points hosts to the resource via readonly meta.ui.resourceUri. - Public token resolution (Tokens API → env-var fallback) extracted to a shared mapboxPublicToken helper so both the resource and any future tool can reuse it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ewport The 60px symmetric padding left a lot of unused space around the route line. Switch to directional padding (top: 70, bottom/left/right: 30) so the summary chip still has room at the top but the route fills the rest of the viewport. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously fitBounds was called before requestSizeToFit, so the camera was computed against the iframe's initial (often smaller) size and ended up zoomed too far out. Send the size-changed notification first, wait a tick for the host to apply the resize, then map.resize() + fitBounds against the final viewport. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
zmofei
left a comment
There was a problem hiding this comment.
Nice tool — the public-token resolution and MCP App resource pattern look clean. A few things to discuss before merge:
Main question: workerDomains isn't in the current @modelcontextprotocol/ext-apps spec (verified through 1.7.2). You mention GeojsonPreviewUIResource as precedent, so this might be honored as a private extension somewhere — but the two manual checks in your test plan are still unchecked, so end-to-end behavior in Claude Desktop isn't verified yet. Could you confirm one way or the other before merge?
Real bugs worth fixing in this PR:
- Markers stack on iframe re-render (l327) — easy fix, hold refs and
.remove()first - driving-traffic profile caps at 3 waypoints but schema allows 25 — port the per-profile check from DirectionsTool
Out-of-PR regression: docs/importing-tools.md lines 119 and 152 still import the deleted pointInPolygon / PointInPolygonTool. Worth fixing in the same PR.
Two [nit] items inline for when you're touching the file.
| 'https://events.mapbox.com' | ||
| ], | ||
| resourceDomains: ['https://api.mapbox.com'], | ||
| workerDomains: ['blob:'] |
There was a problem hiding this comment.
Re-read the PR description and saw you reference hosted-mcp-server's GeojsonPreviewUIResource as precedent — two questions:
-
I checked
@modelcontextprotocol/ext-apps@1.7.2(latest) andworkerDomainsisn't in theMcpUiResourceCspinterface — onlyconnectDomains,resourceDomains,frameDomains,baseUriDomainsare declared. Is the Mapbox host honoringworkerDomainsas a private extension, or is it being recognized some other way? -
The 'verify rendered map loads in Claude Desktop' manual check in your test plan is still unchecked. The unit test (
DirectionsAppUIResource.test.ts:72) only asserts the field is present in the returned metadata, not that any host applies it. Since Mapbox GL JS won't initialize withoutworker-src blob:, would be good to confirm end-to-end before merge — if the spec doesn't haveworkerDomains, the map may be working in your test host for an unrelated reason (e.g. permissive default CSP).
| }); | ||
|
|
||
| var coords = route.geometry.coordinates; | ||
| new mapboxgl.Marker({ color: '#22c55e' }) |
There was a problem hiding this comment.
Markers aren't tracked — re-running the tool in the same iframe (host delivers a second ui/notifications/tool-result) leaves the previous start/end markers on the map, looking like a 4-stop route. The route-line layer + route source are cleaned up above (l311-312), but the Marker instances need to be held in module scope and .remove()d before adding new ones.
| coordinates: z | ||
| .array(coordinateSchema) | ||
| .min(2) | ||
| .max(25) |
There was a problem hiding this comment.
Mapbox Directions v5 caps mapbox/driving-traffic at 3 coordinates (the other profiles allow 25). Schema accepts up to 25 across all profiles, then the API rejects with a generic 422 that surfaces to the user as Directions API error: 422: ... with no hint that switching profile or trimming waypoints fixes it. Sibling DirectionsTool enforces this per-profile — consider porting the same logic or adding a .refine() that branches on routing_profile.
| }; | ||
| } | ||
|
|
||
| const distanceMiles = route.distance |
There was a problem hiding this comment.
[nit] Falsy-zero: route.distance ? ... : 'unknown' prints unknown when route.distance === 0 (and same for route.duration on the next line). Inputs where start and end are the same coordinate (or within snap radius) can legitimately return distance: 0, duration: 0, in which case the user sees Route: unknown, unknown instead of Route: 0.0 mi, 0 min. Use route.distance != null (or typeof route.distance === 'number').
| return defaultPk.token; | ||
| } | ||
| } | ||
| } catch { |
There was a problem hiding this comment.
[nit] Silent catch — network failures, JSON parse errors, and programming bugs all fall through to the env-var fallback with zero observability. The iframe then shows the generic 'No Mapbox public token available…' string, masking the real cause and making the issue undiagnosable from logs. At least a console.warn (or the project's log('warning', ...) helper) would help.
Summary
Adds a new `directions_app_tool` that renders a directions route on a live, interactive Mapbox GL JS map inside an MCP App iframe (rawHtml UI resource). When the user asks for a visual map of a route — rather than just turn-by-turn data — this tool returns an embeddable HTML app with the route drawn, start/end markers, and camera fit to the route bounds.
Why
`static_map_image_tool` produces a static image. `directions_tool` returns GeoJSON the agent has to describe in text. There's been no in-between for interactive rendering inside hosts like Claude Desktop, VS Code, and Cursor — this fills that gap.
How it gets a public token
Mapbox GL JS does not accept secret (`sk.`) tokens client-side, so the tool needs a public (`pk.`) one to embed in the HTML response. Resolution order:
If none of the above produces a `pk.*` token, the tool returns a clear error explaining how to fix it.
Iframe sandbox
Includes `_meta.ui.csp` on the UI resource so MCP App hosts grant the right iframe permissions:
This matches the CSP pattern hosted-mcp-server's GeojsonPreviewUIResource already uses successfully.
Companion PR
Pairs with mapbox/hosted-mcp-server#104, which grants `tokens:read` to the mcp-server's OAuth client. Without that scope, OAuth users will hit the env-var fallback path.
Test plan
🤖 Generated with Claude Code