feat: isochrone_tool renders as a live GL JS map (MCP Apps + MCP-UI)#190
feat: isochrone_tool renders as a live GL JS map (MCP Apps + MCP-UI)#190mattpodwysocki wants to merge 8 commits into
Conversation
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>
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>
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) { |
There was a problem hiding this comment.
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 0 — iso-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.
There was a problem hiding this comment.
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' }) |
There was a problem hiding this comment.
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.
| 'Hex colors (no leading #) for each contour. Length must match the contour values if provided.' | ||
| ) | ||
| }) | ||
| .refine( |
There was a problem hiding this comment.
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:
- Ascending order: descriptions on lines 27 and 35 say "ascending", but
contours_minutes=[30,10]passes Zod and Mapbox 422s withcontours_minutes must be in increasing order. contours_colors.lengthmust match contours length: line 43 description says "Length must match the contour values", butcontours_minutes=[5,10]+contours_colors=['ff0000']passes Zod and Mapbox 422s withNumber of contours_colors must equal number of contours_*.- Mutual exclusion: Mapbox rejects when both
contours_minutesANDcontours_metersare 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.
There was a problem hiding this comment.
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') { |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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(/^#/, ''); |
There was a problem hiding this comment.
[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'.
There was a problem hiding this comment.
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.
# 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>
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>
|
Superseded by #199. This PR (and the rest of the per-tool app stack: #190, #191, #192, #193, #194, #195) declares its own
#199 funnels all rendering through one terminal |
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
🤖 Generated with Claude Code