Skip to content

feat: directions_app_tool — interactive GLJS map as MCP App#189

Open
mattpodwysocki wants to merge 25 commits into
mainfrom
feat/directions-app-tool
Open

feat: directions_app_tool — interactive GLJS map as MCP App#189
mattpodwysocki wants to merge 25 commits into
mainfrom
feat/directions-app-tool

Conversation

@mattpodwysocki
Copy link
Copy Markdown
Contributor

@mattpodwysocki mattpodwysocki commented May 27, 2026

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:

  1. If the server's access token is already `pk.*`, use it directly
  2. If a cached `pk.*` token has >5 min TTL remaining, reuse it (1h cache)
  3. If the server token is `sk.*`, call `GET /tokens/v2/{user}?default=true` to fetch the user's default public token — requires `tokens:read` scope (already on devkit's OAuth client; CFN PR mapbox/hosted-mcp-server#104 adds it to the regular mcp-server)
  4. Fall back to optional `MAPBOX_PUBLIC_TOKEN` env var

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:

  • `workerDomains: ['blob:']` — GLJS uses blob workers
  • `resourceDomains: ['https://api.mapbox.com']` — tile, style, font, sprite fetches
  • `connectDomains: ['https://*.mapbox.com', 'https://events.mapbox.com']` — telemetry

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

  • All 716 tests pass (`npm test`) — 7 new tests cover the four resolution paths plus CSP metadata
  • Build succeeds (`npm run build`)
  • Lint and format clean
  • Manual: set `MAPBOX_PUBLIC_TOKEN` locally and verify the rendered map loads in Claude Desktop
  • Manual (after CFN merge): verify OAuth path works without env var
Screenshot 2026-05-27 at 10 43 40

🤖 Generated with Claude Code

mattpodwysocki and others added 22 commits January 12, 2026 16:18
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>
@mattpodwysocki mattpodwysocki requested a review from a team as a code owner May 27, 2026 14:32
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>
Copy link
Copy Markdown
Member

@zmofei zmofei left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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:']
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-read the PR description and saw you reference hosted-mcp-server's GeojsonPreviewUIResource as precedent — two questions:

  1. I checked @modelcontextprotocol/ext-apps@1.7.2 (latest) and workerDomains isn't in the McpUiResourceCsp interface — only connectDomains, resourceDomains, frameDomains, baseUriDomains are declared. Is the Mapbox host honoring workerDomains as a private extension, or is it being recognized some other way?

  2. 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 without worker-src blob:, would be good to confirm end-to-end before merge — if the spec doesn't have workerDomains, 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' })
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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 {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants