Skip to content

feat: isochrone_tool renders as a live GL JS map (MCP Apps + MCP-UI)#190

Closed
mattpodwysocki wants to merge 8 commits into
feat/directions-app-toolfrom
feat/isochrone-app-tool
Closed

feat: isochrone_tool renders as a live GL JS map (MCP Apps + MCP-UI)#190
mattpodwysocki wants to merge 8 commits into
feat/directions-app-toolfrom
feat/isochrone-app-tool

Conversation

@mattpodwysocki

@mattpodwysocki mattpodwysocki commented May 27, 2026

Copy link
Copy Markdown
Contributor

Summary

Apply the same pattern as #189 to isochrone_tool: instead of adding a separate `isochrone_app_tool`, fold the MCP App rendering into the existing tool. Supports both MCP Apps (via `_meta.ui.resourceUri` → `IsochroneAppUIResource`) and legacy MCP-UI (via inline `rawHtml` UIResource gated by `ENABLE_MCP_UI`). One shared `renderIsochroneAppHtml` template renders each contour as a translucent fill + outline layer with the origin marked.

Bonus

The shared iframe also handles the >50KB temporary-resource path: when the tool offloads geometry to `mapbox://temp/isochrone-{id}`, the iframe automatically calls `resources/read` via the host bridge to fetch and render the full data.

Stacked on #189

Inherits the MCP App infrastructure (public-token resolver, BaseResource pattern, etc.). Will retarget to main after #189 lands.

Test plan

  • 713 tests pass across 57 files
  • Build, lint, format clean
  • Existing IsochroneTool tests unchanged (output shape unchanged for non-UI hosts)

🤖 Generated with Claude Code

Follow-up to directions_app_tool that applies the same MCP Apps Resource
pattern to isochrones. The tool calls Mapbox's Isochrone API and returns
the polygon FeatureCollection plus a meta.ui.resourceUri pointing to a
new IsochroneAppUIResource that renders each contour as a translucent
fill + outline on a live Mapbox GL JS map, marks the origin, and fits
the camera to the contours.

Reuses the resolveMapboxPublicToken helper and CSP iframe pattern from
the directions PR — once a host wires up MCP Apps for one of these
tools, every subsequent app tool gets it for free.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mattpodwysocki mattpodwysocki requested a review from a team as a code owner May 27, 2026 14:51
mattpodwysocki and others added 2 commits May 27, 2026 10:58
Matches the directions_app padding change — directional padding so the
summary chip stays clear at the top while the isochrone fills the rest.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
mattpodwysocki and others added 2 commits May 27, 2026 11:43
Same pattern as directions: send size-changed first, wait 60ms, then
map.resize() + fitBounds so the contours fill the final viewport.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
features.forEach(function(f) { if (f.geometry) walkCoords(f.geometry.coordinates); });

// Clean up any prior layers/sources
features.forEach(function(_, i) {

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.

The cleanup loop iterates over the current payload's features.length, so indices [previousLen..currentLen) from a prior render are never removed.

Example: first render with contours_minutes=[5,10,15,20] creates iso-fill-0..3. Second render with contours_minutes=[5] only cleans up index 0iso-fill-1, iso-fill-2, iso-fill-3 (and their sources/lines) stay on the map. The next time the user runs with 5 contours again, addSource('iso-source-1', ...) collides with the orphan and Mapbox GL throws 'Source iso-source-1 already exists'.

Fix: track previously-added layer IDs in module scope and clean from that list, not from the new payload.

DirectionsAppTool only had one route/layer so this didn't surface in #189.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9f3ae3d (predecessor of the most recent merge on this branch). The shared renderIsochroneAppHtml now tracks contourLayerIds / contourSourceIds in module scope and clears those before adding the new payload's sources/layers, so prior-render orphans are removed regardless of feature count. Also note: this PR has been refactored to fold MCP App support into the existing isochrone_tool rather than shipping a parallel isochrone_app_tool — the file referenced here is now src/resources/ui-apps/isochroneAppHtml.ts.

});

if (payload.origin && typeof payload.origin.longitude === 'number') {
new mapboxgl.Marker({ color: '#0f172a' })

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.

Origin marker is created with new mapboxgl.Marker(...).addTo(map) but the reference is never stored, so re-renders can't .remove() it. Same orphan pattern as the start/end markers I flagged in #189 — every tool-result stacks another marker at the new origin.

Fix: hold the marker in module scope and .remove() before adding a new one.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9f3ae3d. originMarker is now held in module scope and .remove()d before adding the new one (same as how I handled the start/end markers in #189). Lives in the shared renderIsochroneAppHtml module post-refactor.

'Hex colors (no leading #) for each contour. Length must match the contour values if provided.'
)
})
.refine(

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.

The .refine() checks at-least-one of contours_minutes/contours_meters is provided, but three other constraints the schema describes aren't actually enforced — each one currently surfaces to the user as a generic Isochrone API error: 422: ... from Mapbox:

  1. Ascending order: descriptions on lines 27 and 35 say "ascending", but contours_minutes=[30,10] passes Zod and Mapbox 422s with contours_minutes must be in increasing order.
  2. contours_colors.length must match contours length: line 43 description says "Length must match the contour values", but contours_minutes=[5,10] + contours_colors=['ff0000'] passes Zod and Mapbox 422s with Number of contours_colors must equal number of contours_*.
  3. Mutual exclusion: Mapbox rejects when both contours_minutes AND contours_meters are set; the current .refine() only checks at-least-one, so an LLM populating both fields produces a 422.

Adding three .refine() calls here would convert all three into precise schema errors before the network round-trip.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9f3ae3d. All three constraints converted into .refine() calls on IsochroneInputSchema: mutual exclusion of contours_minutes/contours_meters, strict ascending order on each, and contours_colors length matching the contours length. Also moved the at-least-one check into the schema and dropped the now-dead runtime check from execute(). Four new tests cover the new paths.

});
});

if (payload.origin && typeof payload.origin.longitude === 'number') {

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] Type guard checks typeof payload.origin.longitude === 'number' but not latitude. The tool's own output always emits both, so under normal flow this is safe — but the iframe accepts tool-result from any postMessage source (no origin check yet, see #189), so a malformed payload with longitude-only would pass the guard and setLngLat([lng, undefined]) would throw mid-render, halting fitBounds and leaving the camera unfit. Check both for symmetry.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9f3ae3d — now checks both longitude and latitude are typeof === 'number' before constructing the marker.


features.forEach(function(feature, i) {
var props = feature.properties || {};
var color = '#' + (props.color || props.fillColor || '3b82f6').replace(/^#/, '');

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] Color sanitization is '#' + (props.color || props.fillColor || '3b82f6').replace(/^#/, ''). The .replace(/^#/, '') only strips a leading # — it doesn't validate the rest is hex. User-supplied contours_colors is already regex-gated at schema l38, so today this only matters for props.color coming from the Mapbox API response, which docs say is hex-without-#. If the API ever returned a CSS name ('red') or rgb form, the result would be '#red' and Mapbox GL would throw inside addLayer, halting drawIsochrone partway. Cheap fix: re-validate with the same hex regex and fall back to '3b82f6'.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 9f3ae3d. Added a sanitizeHex(raw) helper that runs the same /^[0-9a-fA-F]{6}$/ regex used by the schema and falls back to '3b82f6' on any failure, so a non-hex props.color from the API can't crash addLayer.

mattpodwysocki and others added 2 commits June 1, 2026 16:10
# Conflicts:
#	CHANGELOG.md
#	src/tools/index.ts
#	src/tools/toolRegistry.ts
…pp_tool)

Same pattern as #189: deletes the sibling isochrone_app_tool and lets the
existing isochrone_tool emit both MCP App (meta.ui.resourceUri) and
inline MCP-UI (createUIResource rawHtml) UI hints. One shared
isochroneAppHtml.ts template renders the contours; the iframe handles
both postMessage delivery and the >50KB temp-resource path via
resources/read.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mattpodwysocki mattpodwysocki changed the title feat: isochrone_app_tool — interactive reachable-area MCP App feat: isochrone_tool renders as a live GL JS map (MCP Apps + MCP-UI) Jun 1, 2026
@mattpodwysocki mattpodwysocki marked this pull request as draft June 1, 2026 20:43
UI fixes in isochroneAppHtml.ts:
- Guard origin marker on both longitude AND latitude being numeric
  (was longitude-only). A malformed postMessage payload with only one
  coordinate would have thrown mid-render and halted fitBounds.
- Validate Mapbox API-supplied contour colors against the same hex-6
  regex used in the schema; fall back to the default color instead of
  blindly prepending '#'. Protects against the API ever returning a CSS
  name or rgb form that would crash addLayer.

Schema refinements in IsochroneTool.input.schema.ts — convert three
constraints from generic Mapbox 422s into precise Zod errors:
- Mutual exclusion: contours_minutes and contours_meters can't both
  be set (the API rejects this combo).
- Ascending order: both contours_minutes and contours_meters must be
  strictly ascending (the descriptions already said so).
- Length match: contours_colors length must equal the number of
  contours when both are provided.
- Move the at-least-one check into a refine() and drop the now-dead
  runtime check from execute().

Four new tests cover each new refinement path.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@mattpodwysocki

Copy link
Copy Markdown
Contributor Author

Superseded by #199.

This PR (and the rest of the per-tool app stack: #190, #191, #192, #193, #194, #195) declares its own meta.ui.resourceUri and emits inline createUIResource via @mcp-ui/server. In practice that approach has two blockers:

  1. Chained tool calls only render the last iframe. Claude Desktop dedupes/short-circuits earlier iframes in a chain, so geocode→directions or isochrone→union flows only paint the final tool's map.
  2. Depends on @mcp-ui/server, which feat: render_map_tool — single visualization primitive (server-side refs) #199 removes.

#199 funnels all rendering through one terminal render_map_tool primitive. Every data tool stores a MapAppPayload server-side and returns a mapbox://temp/map-payload-<uuid> ref; the LLM passes the refs to render_map_tool, which merges them and renders. Closing in favor of that approach.

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