Skip to content

feat: optimization_app_tool — TSP visit order on an interactive map#191

Open
mattpodwysocki wants to merge 3 commits into
feat/isochrone-app-toolfrom
feat/optimization-app-tool
Open

feat: optimization_app_tool — TSP visit order on an interactive map#191
mattpodwysocki wants to merge 3 commits into
feat/isochrone-app-toolfrom
feat/optimization-app-tool

Conversation

@mattpodwysocki
Copy link
Copy Markdown
Contributor

@mattpodwysocki mattpodwysocki commented May 27, 2026

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

  • `optimization_app_tool` accepts 2–12 stops + `profile`/`source`/`destination`/`roundtrip`, hits `/optimized-trips/v1/{profile}/{coords}`, normalizes the response into a clean `stops[]` array sorted by visit order
  • `OptimizationAppUIResource` (new) serves the HTML that draws the trip line and places a colored circular marker at each stop with its visit number — green for stop 1, red for the last stop on non-roundtrips, blue otherwise

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

  • 7 new tests (5 tool, 2 resource): happy path with re-sorted waypoint_indices, meta resourceUri, custom source/destination/roundtrip/profile params, API error code, non-2xx response, env-var fallback
  • Full suite: 738 tests passing across 61 files
  • Build clean (`npm run build`)
  • Lint + format clean
  • Manual: ask Claude Desktop to optimize a multi-stop trip and verify numbered markers + tighter camera
Screenshot 2026-05-27 at 11 05 30

🤖 Generated with Claude Code

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>
mattpodwysocki and others added 2 commits May 27, 2026 11:43
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) {
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.

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

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
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] 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)
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] 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 : '') +
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] 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.

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