feat: optimization_app_tool — TSP visit order on an interactive map#191
feat: optimization_app_tool — TSP visit order on an interactive map#191mattpodwysocki wants to merge 3 commits into
Conversation
Third app tool in the MCP Apps pattern series, following directions and isochrones. The tool calls Mapbox's Optimization API with 2-12 stops and returns the optimized visit order plus the route geometry; the OptimizationAppUIResource renders the trip line with numbered (1, 2, 3, ...) markers at each stop in visit order, with the first stop colored green and the last red (if not a roundtrip), and the camera fit to all stops. Tightens the camera padding (top: 70, bottom/left/right: 30) to match the directions and isochrone tools so the route fills the viewport without manual zoom. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Same pattern as directions/isochrone — fit AFTER the host applies the size-changed notification so the trip fills the final viewport. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
| }); | ||
|
|
||
| // Place numbered markers in optimized order | ||
| stops.forEach(function(stop, idx) { |
There was a problem hiding this comment.
Same marker-leak pattern as #189/#190, but N=12 markers per render here vs 2 in directions or 1 in isochrone — orphan stacks add up much faster. stops.forEach creates a mapboxgl.Marker per stop with no ref tracked. The trip-line layer/source are cleaned up correctly at l316-317, but a second ui/notifications/tool-result in the same iframe adds another 12 markers on top of the prior ones.
Fix: hold the marker array in module scope, forEach(m => m.remove()) and reset before the new loop.
| .describe( | ||
| '"last" pins the last input coordinate as the end; "any" lets the optimizer choose.' | ||
| ), | ||
| roundtrip: z |
There was a problem hiding this comment.
Per Mapbox Optimization API docs, roundtrip=false requires both source='first' AND destination='last' (you can't not-roundtrip if either endpoint is flexible). The schema doesn't enforce this — so { roundtrip: false } alone with defaults (source='any', destination='any') passes Zod and Mapbox 422s with a generic InvalidInput that surfaces as Optimization API error: ... to the user with no hint about which combo was wrong.
One .refine() checking roundtrip === false → source === 'first' && destination === 'last' would convert this into a precise schema error before the network round-trip.
| name: wp.name | ||
| })); | ||
|
|
||
| const durationMin = trip.duration |
There was a problem hiding this comment.
[nit] Same falsy-zero as DirectionsAppTool: trip.distance ? ... : 'unknown' reports unknown when distance/duration is exactly 0 (degenerate inputs like two identical coordinates with roundtrip=false). Use trip.distance != null (or typeof === 'number'). Same on the duration line just above.
| el.textContent = String(stop.order); | ||
|
|
||
| new mapboxgl.Marker({ element: el }) | ||
| .setLngLat(stop.location) |
There was a problem hiding this comment.
[nit] setLngLat(stop.location) has no guard for missing or non-array location. The tool's own output always emits [lng, lat] from validated input, but the iframe accepts tool-result from any postMessage source (no origin check yet, see #189). If one stop arrives with location: null or location: undefined, the forEach throws mid-loop — earlier markers render, later ones don't. The fitBounds block at l361 is outside the forEach so it still runs, but the loading/error UI state is left half-set. Cheap guard: if (!Array.isArray(stop.location) || stop.location.length < 2) return; inside the forEach.
| .setLngLat(stop.location) | ||
| .setPopup( | ||
| new mapboxgl.Popup().setText( | ||
| 'Stop ' + stop.order + (stop.name ? ' — ' + stop.name : '') + |
There was a problem hiding this comment.
[nit] The popup label 'Stop N — <stop.name>' uses wp.name from the Mapbox Optimization API response, which per docs is the road name the input coordinate was snapped to, not a place name. A user who passed coordinates for 'Starbucks on Main' and 'Trader Joe's on Oak' will see Stop 1 — Main Street / Stop 2 — Oak Avenue and likely read those as place identifiers (the tool 'identified my Starbucks as Main Street'). Two cheap options: (a) drop name and just show 'Stop N (input #N)', or (b) relabel it as 'on <road>' so it's clearly a road, not a place.
Summary
Third tool in the MCP Apps pattern series (`directions_app_tool` #189, `isochrone_app_tool` #190, this one). Solves the user's "I have 6 places to go, what order should I do them in?" — calls Mapbox's Optimization API, returns the optimized visit order plus the trip geometry, and renders it on a live Mapbox GL JS map with numbered markers showing the visit order.
What it does
Bonus polish
Also tightens the fitBounds padding across all three app tools (directions, isochrone, optimization) so the route/contours/trip fills the viewport without the user needing to manually zoom in. Was 60px symmetric; now `{ top: 70, bottom: 30, left: 30, right: 30 }` — keeps the summary chip clear at the top but uses the rest of the space.
Stacked on #190
Targets `feat/isochrone-app-tool` rather than `main` because it depends on the public-token resolver (`src/utils/mapboxPublicToken.ts`) introduced in #189. After #189 and #190 land, this will retarget to `main` automatically.
Test plan
🤖 Generated with Claude Code