If you have used an MCP server before, you know the pattern: the AI agent calls a tool, the tool returns some JSON, and the model reads that JSON back to you in a sentence. That works fine for a list of restaurant names. It falls apart the moment the data is inherently spatial. A route is not really a sentence. Neither is a set of delivery zones or a cluster of nearby coffee shops. You want to see it on a map.
That is the gap MCP Apps closes, and it is what we built render_map_tool around in the Mapbox MCP Server. This post walks through what MCP Apps actually is, why we ended up with one visualization tool instead of one per data type, and how the tool works whether you are chaining it off another Mapbox tool or feeding it your own GeoJSON from a completely unrelated system.
Want to try this yourself before reading further? Follow the hosted MCP server user guide to connect your own client to the production endpoint.
Try it now
The Mapbox MCP server is hosted and ready to use, no local install required. The production endpoint is:
https://mcp.mapbox.com/mcp
For Claude Code, add it before starting a session:
claude mcp add --transport http mapbox-mcp-production https://mcp.mapbox.com/mcp
Every other major client (Claude Desktop, VS Code, Cursor, and more) has its own setup steps, all covered in the hosted MCP server user guide.
A quick primer: MCP and MCP Apps
The Model Context Protocol is the open standard that lets an AI client (Claude Desktop, Claude Code, VS Code with Copilot, and others) talk to external tools and data sources in a consistent way. An MCP server exposes tools, the client's model decides when to call them, and the results come back as text or structured JSON that the model reasons over.
That is enough for most tools, but it has an obvious ceiling: everything ends up flattened into text for the model to summarize. MCP Apps is an extension to that protocol for exactly the cases where flattening loses too much. Instead of returning only text, a tool can also point at a small, self-contained HTML application that the client renders directly inside the conversation, in a sandboxed iframe. The tool still returns normal MCP output for the model to reason about. The HTML panel is an additional, richer surface for the human on the other end.
For a Mapbox MCP server, that is the natural place to put an actual map.
Why one tool, not one map per tool
Our first instinct was the obvious one: give directions_tool its own map panel, give isochrone_tool its own map panel, and so on. We built that, then threw it away, for two reasons that only became clear once we had multiple tools in play.
Chain position matters more than you would expect. A lot of real requests are two steps: get directions, then show them. That means two tool calls in sequence, and several MCP App hosts, Claude Desktop among them, only fully render the interactive panel for the last tool call in a chain. If the map panel belongs to directions_tool and something else runs after it, the panel either does not render or gets superseded. Funneling every visualization through one tool that is always the final step in the chain sidesteps the problem entirely, rather than working around it per tool.
Geometry is expensive to pass through the model. A route polyline or a set of isochrone contours can be tens of thousands of coordinate pairs. Making the model carry that as a tool-call argument on every hop is slow and burns tokens for no benefit, since the model never actually needs to reason about individual coordinates. We wanted a way to hand geometry from one tool to another without the model ever having to see it.
The result is render_map_tool: a single tool that is the only one in the server declaring an MCP Apps resource, and everything else that produces geospatial output hands its result to it.
Chaining: directions in, a live map out
Here is what that looks like end to end. When you ask an agent for directions, directions_tool returns the usual structured data (distance, duration, turn-by-turn instructions), plus one extra field:
{
"structuredContent": {
"routes": [ /* ... */ ],
"mapboxRender": { "ref": "mapbox://selffetch/directions?data=..." }
}
}
That ref is a short, opaque reference string, not the route geometry itself. The model never sees a single coordinate. It just passes the ref straight through to render_map_tool:
{ "payload_refs": ["mapbox://selffetch/directions?data=..."] }
You can pass more than one ref in the same call, which is how you merge results from multiple tools onto a single map, for example an isochrone alongside a route:
{
"payload_refs": [
"mapbox://selffetch/isochrone?data=...",
"mapbox://selffetch/directions?data=..."
]
}

Driving from the Golden Gate Bridge to Union Square: about 8.3 miles, ~24 minutes via US-101 North/South, through a tunnel and a toll road, with light-to-moderate traffic along the way.
Under the hood, that mapbox://selffetch/... ref is not a pointer into a server-side cache. It base64-encodes the original tool call's parameters directly. When the map panel loads in the client, it decodes the ref and fetches the route geometry itself, directly from the Mapbox Directions API, using the same public token already used to load map tiles. This turned out to matter in a way we did not fully appreciate until we hit it in practice.
The map has to survive things the tool call does not. A conversation can sit idle for an hour, the MCP server process can restart, or a client can reopen an old chat and re-render a map card that was created in a previous session. In every one of those cases, anything that depended on in-memory server state from the original request is gone. Early on, our refs pointed at exactly that kind of ephemeral store, and a restart quietly broke every map card that hadn't been viewed yet. Making the ref self-describing, and having the client fetch geometry itself rather than trust a stashed server-side result, fixes that categorically: there is no server memory to lose, so there is nothing to survive. It also means the map always reflects live data. If you ask for directions and check back on that same map card twenty minutes later, current traffic conditions are what gets drawn, not a snapshot from when the tool first ran.
Standalone: your own data, no other Mapbox tool required
Chaining is what an LLM does automatically once it has called a Mapbox tool. But a lot of real usage has nothing to do with our tools at all. If you are integrating the Mapbox MCP server into your own agent and you already have geospatial data, your own delivery zones, a set of store locations from your own database, a GPS trace from a device you operate, there is no reason to route it through our APIs first just to get it on a map.
render_map_tool accepts that data directly:
{
"summary": "Downtown delivery zone",
"layers": [
{
"id": "delivery-zone",
"type": "fill",
"data": {
"type": "Feature",
"geometry": {
"type": "Polygon",
"coordinates": [
[
[-122.4194, 37.7749],
[-122.4094, 37.7749],
[-122.4094, 37.7849],
[-122.4194, 37.7849],
[-122.4194, 37.7749]
]
]
},
"properties": {}
},
"paint": {
"fill-color": "#3b82f6",
"fill-opacity": 0.25,
"fill-outline-color": "#1d4ed8"
}
}
],
"markers": [
{
"coordinates": [-122.4144, 37.7799],
"style": "pin",
"color": "#ef4444",
"popup": "Warehouse"
}
],
"legend": [{ "label": "Delivery zone", "color": "#3b82f6", "opacity": 0.25 }]
}

Rendered the downtown delivery zone map — the blue fill shows the zone with the warehouse pin at its center.
No payload_refs, no dependency on directions_tool or any other tool in the server. This is the entire request, and it is not a lesser or fallback code path either. It is the same rendering pipeline every other tool in the server uses internally, just entered directly.
A few things worth calling out for anyone integrating this from outside our tool set:
-
layersandmarkersare a thin pass-through to the Mapbox GL JS style spec, not a custom DSL. Thepaintandlayoutobjects go straight toaddLayer, so anything expressible in GL JS style is expressible in the payload. If you already know Mapbox GL JS, there is nothing new to learn here. -
You do not need a Mapbox access token to build the payload. Token handling for the actual map tiles happens entirely inside the client's rendering panel, using the server's own public-token resolution. Your
layers/markersare plain GeoJSON and CSS-style values with nothing sensitive embedded in them. -
Multiple layers and markers in one call are merged onto the same map, and the camera auto-fits to everything drawn unless you pass an explicit
camera. -
Each call is a fresh render. The tool does not accumulate state across calls, so if you want several things on one map, put them in the same call's
layers/markers/payload_refsrather than calling the tool repeatedly and expecting it to build up a picture.
An LLM with access to the tool can also compose this JSON itself from a plain description, so a prompt like "show a fill polygon over these coordinates, with a red pin labeled Warehouse" works without you hand-building the payload at all. But the direct, tool-level integration is what matters if you are wiring this into an agent programmatically rather than relying on a model to fill it in.
Where this fits if you cannot render MCP Apps at all
Not every MCP client supports MCP Apps yet. In a client without support, render_map_tool still returns its resolved payload as ordinary tool output. You lose the live interactive panel, but the call does not fail, and there is nothing to catch or work around on your end. If you specifically need a guaranteed visual regardless of client capability, the server also ships static_map_image_tool, which returns a base64-encoded PNG that renders anywhere.
The short version
MCP Apps gives tools a way to hand a human a real, interactive surface instead of a wall of text. For anything spatial, that surface is a map. We built render_map_tool as the one place all of that goes through in the Mapbox MCP server, whether the data came from chaining one of our own tools or from a GeoJSON payload you built entirely on your own. The chaining path keeps geometry out of the model's hands and off the wire until it is actually needed, and the standalone path means you never have to route your own data through us just to visualize it.
If you want to see the whole payload schema, the compatible client list, or the reasoning behind specific design choices, the full guide is in the repo: docs/render-map-tool.md. The server itself is open source at github.com/mapbox/mcp-server.
Ready to connect your own client? Follow the hosted MCP server user guide to get started with the production endpoint.
Build with Location AI
Whether you're building AI agents, AI-native applications, or adding location intelligence to an existing product, check out the Mapbox Location AI getting started guides and try the companion benchmarks with your own data. If you'd like to discuss your use case, explore architectural approaches, or build together, contact the Mapbox team.
Top comments (0)