Skip to content

feat: consolidate GeoJSON preview and style preview into one MCP App - #135

Merged
mattpodwysocki merged 4 commits into
mainfrom
feat/consolidate-preview-ui-resources
Aug 24, 2026
Merged

feat: consolidate GeoJSON preview and style preview into one MCP App#135
mattpodwysocki merged 4 commits into
mainfrom
feat/consolidate-preview-ui-resources

Conversation

@mattpodwysocki

Copy link
Copy Markdown
Contributor

What changed

Consolidates GeojsonPreviewUIResource and PreviewStyleUIResource into one MapPreviewUIResource. geojson_preview_tool and preview_style_tool now both point their _meta.ui.resourceUri at ui://mapbox/map-preview/index.html. Neither tool's own input/output contract changed — this only touches which resource renders their result.

Why

Raised in Slack: three of DevKit's tools each render a live GL JS map (GeoJSON preview, style preview, style comparison), each with its own hand-written MCP Apps template. On inspection, two of the three (GeojsonPreviewUIResource 397 lines, PreviewStyleUIResource 268 lines) were genuinely the same thing done twice — the MCP-Apps postMessage handshake, fullscreen/open-link controls, and resize handling were copy-pasted byte-for-byte, and they differed only in what got drawn on the map once a tool result arrived (a GeoJSON overlay on the default Standard style, vs. swapping to an arbitrary preview style).

style_comparison_tool's StyleComparisonUIResource stays separate on purpose — it renders two synced mapboxgl.Map instances under a swipe/compare slider (via the mapboxgl.Compare plugin), a genuinely different UI shape from "one map, different content," not just a third mode to fold in.

How the merged resource works

The shared template dispatches on the shape of the tool-result URL it receives, rather than each resource assuming a fixed mode:

  • A geojson.io/?data=data:application/json,... URL (from geojson_preview_tool) → draws a GeoJSON overlay on the default Standard style.
  • A Styles API .../styles/v1/{user}/{style}.html?access_token=... URL (from preview_style_tool) → swaps to that style.

The GeoJSON path keeps its existing eager bootstrap: it draws the default Standard style immediately (using a token minted server-side from the caller's sk.*, same as before) so an overlay has something to render onto as soon as data arrives, rather than waiting. The style-preview path now reuses that same map instance via setStyle() when it exists, swapping mapboxgl.accessToken to the token embedded in its own URL first — falling back to constructing a fresh mapboxgl.Map (as PreviewStyleUIResource always did) only when no eager map exists (e.g. no sk.*/pk.* was available to mint from).

Breaking change

@mapbox/mcp-devkit-server/resources: previewStyleUI/geojsonPreviewUI named exports and the GeojsonPreviewUIResource/PreviewStyleUIResource classes are replaced by mapPreviewUI/MapPreviewUIResource.

Verification

  • Migrated the existing GeojsonPreviewUIResource.test.ts AGI-905 cross-account token-leak regression suite onto the merged resource unchanged (same assertions, same scenarios) — this is a security-sensitive test suite and I didn't want to weaken it while moving it.
  • Added new coverage for the merged dispatch logic — routing a GeoJSON URL vs. a style-preview URL, the setStyle()-reuse path vs. the fresh-map fallback, and the unrecognized-URL error case — via a node:vm sandbox running the actual embedded <script>, mirroring the pattern the sibling mcp-server repo already uses for this kind of test.
  • Live-verified against the real Mapbox API in a real browser: dispatched a real GeoJSON point through the resource's postMessage handler (bootstrapped map → overlay added, fitBounds'd to SF) and, on a fresh load of the same resource, a real custom style from this account by name (bootstrapped map → setStyle() swap → style-name badge showed "SF Coffee Finder", the real style's name) — confirming both paths work end-to-end, not just against mocks.
  • npx tsc --noEmit, npx eslint: clean (0 errors; pre-existing no-explicit-any warnings elsewhere, untouched).
  • Full suite passes (615/615).

🤖 Generated with Claude Code

GeojsonPreviewUIResource and PreviewStyleUIResource were two nearly
identical hand-written MCP Apps templates — the same postMessage
handshake, fullscreen/open-link controls, and resize handling
copy-pasted across ~650 lines, differing only in what they drew on the
map once a tool result arrived. Merged into MapPreviewUIResource, which
dispatches on the shape of the incoming tool-result URL (a geojson.io
data URL vs. a Styles API .html preview URL) instead of each resource
assuming a fixed mode.

geojson_preview_tool and preview_style_tool now both point at
'ui://mapbox/map-preview/index.html'. Neither tool's own input/output
contract changed — this only touches which resource renders their
result. The GeoJSON path keeps its existing eager map bootstrap (drawing
Standard immediately so an overlay has something to render onto right
away); the style-preview path reuses that map via setStyle() when it
exists, falling back to constructing its own when it doesn't (e.g. no
server-mintable token yet).

style_comparison_tool's StyleComparisonUIResource stays separate — it
renders two synced map instances under a swipe/compare slider, a
genuinely different UI shape from "one map, different content."

Breaking change to the public resources export: previewStyleUI/
geojsonPreviewUI are replaced by mapPreviewUI.

Migrated the existing AGI-905 cross-account token-leak regression suite
onto the merged resource unchanged, and added new coverage for the
merged dispatch logic (geojson vs. style-preview routing, the
setStyle()-reuse vs. fresh-map paths, and the unrecognized-URL error
case) via a node:vm sandbox running the actual embedded script, mirroring
the pattern already used for this in the sibling mcp-server repo.

Verified live against the real Mapbox API in a real browser: both the
GeoJSON overlay path and the style-swap-in-place path (loading a real
custom style by name) work end-to-end on the merged resource.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@mattpodwysocki
mattpodwysocki requested a review from a team as a code owner August 21, 2026 14:40
…a token

Both previously made accessToken a required pk.* input, so a caller had to
already have (or separately go create) a public token before either tool
would do anything, even for a one-off inline preview. accessToken is now
optional on both: when omitted, the tool auto-generates a preview token
from the server's own access token via a new shared mintScopedPreviewToken
utility (the same tokens/v2 minting pattern MapPreviewUIResource already
used for geojson_preview_tool, extracted so it's not duplicated a third
time), so a first call needs no setup at all.

Pass share: true alongside your own accessToken to get a link built from a
token you manage yourself, for when something durable/shareable is actually
wanted. share: true without accessToken is a clear validation error, not a
silent fallback.

The two tools' auto-mint tokens differ in shape, confirmed live against the
real API rather than assumed: preview_style_tool mints a short-lived (~1h)
tk.* token, since the Styles API's embeddable HTML preview page and GL JS
both accept it. style_comparison_tool mints a non-expiring pk.* token
instead, because agent.mapbox.com/tools/style-compare (which its comparison
link embeds) validates the token prefix client-side and hard-rejects
anything but pk.* — a tk.* token 400s there with "Configuration Error:
Invalid token type", caught by loading the real returned URL in a browser
before shipping this. The pk.* token stays narrowly scoped but doesn't
self-expire; it persists on the account until manually revoked.

Both tools now take httpRequest as a constructor dependency, matching
every other network-calling tool in this repo.

Verified live end-to-end in a real browser for both tools: a real custom
style loaded via preview_style_tool's auto-minted token, and a real
before/after comparison rendered via style_comparison_tool's.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Per README.md, the hosted MCP endpoint authenticates each request with its
own access token rather than a personal Mapbox account token, and that
token isn't granted tokens:write — so the auto-mint path added in the
previous commit can never succeed there. Without this, a hosted caller
omitting accessToken would hit a raw, misleading jwtUtils error
("MAPBOX_ACCESS_TOKEN is not in valid JWT format", naming an env var the
hosted deployment doesn't use) or a bare "Token API 403" with no next step.

Added describeAutoMintFailure() (src/utils/mintScopedPreviewToken.ts),
used by both preview_style_tool and style_comparison_tool's auto-mint
catch blocks, which recognizes both failure shapes (a server token that
doesn't parse as a Mapbox token at all, or one that parses fine but lacks
tokens:write) and rewrites them into a message that actually tells the
caller what to do: pass accessToken directly via list_tokens_tool/
create_token_tool. Anything else (e.g. a transient 5xx) passes through
unchanged rather than being guessed at.

Verified live via a real MCP client with a non-Mapbox-shaped bearer token
(simulating the hosted endpoint's auth shape) that the new message is
what actually gets returned end-to-end, not just in unit tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Valiunia
Valiunia previously approved these changes Aug 24, 2026
…itation

feat: preview_style_tool and style_comparison_tool no longer require a token
@mattpodwysocki
mattpodwysocki merged commit fe01305 into main Aug 24, 2026
2 checks passed
@mattpodwysocki
mattpodwysocki deleted the feat/consolidate-preview-ui-resources branch August 24, 2026 15:31
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