Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 1 addition & 9 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,10 @@

### New Features

- **`export_map_html_tool`: the `render_map_tool` map as a standalone HTML page, for agents building sites or files (#275).** `render_map_tool` renders only inside the chat (as an MCP App), so an agent building a page had to write its own Mapbox GL JS code and ask the user for a public token. That's what happened in ChatGPT/Codex when someone asked for a site with a map. The new tool takes the same input as `render_map_tool` and returns a complete HTML document of about 3 KB plus the map data. The data and a public token are written into JSON tags in the page, and the map script loads from jsDelivr. The token is the account's default public token, resolved the same way the in-chat map does it, or `public_token` if passed. The tool result recommends a URL-restricted token for published sites. The page stays small because the agent has to copy the whole document into a file write; inlining the ~60 KB map script would mean re-emitting ~16k tokens.
- **Shared map script:** the in-chat page's script is now `mapAppScript()`, and its markup and styles are `mapAppShell()`. The in-chat page output is byte-for-byte unchanged. The build writes a standalone copy that reads its token and API endpoint from the page (`dist/map-app/standalone.js`, shipped in the npm package). Pages pin it by package version and a sha384 integrity hash. Running from source, where the version can't be read, the tool inlines the script instead.
- **Live layers:** routes, isochrones and search results are still fetched by the page on load, as the in-chat map does, so each page view calls the Mapbox API on the token's account. The tool result says how many layers do this.
- **Server instructions:** when the tool is enabled, they now point page building at it.
- **Size:** the page carries its data (there's no server-side ref to fall back on, as `render_map_tool` has), so map data over 200 KB is rejected with next steps: simplify large geometry with `simplify_tool`, drop what the page doesn't need, or split it across maps. The page is returned once, as text; `structuredContent` has only its size and metadata, so the document isn't sent to the model twice.
- **Shared payload assembly:** `render_map_tool`'s ref resolution and payload merging moved to `assembleMapPayload()` so both tools share it; `render_map_tool` behavior is unchanged.
- **Testing:** added tests for the tool, the standalone script (run in a VM sandbox: it reads its token and endpoint from the page and calls the configured endpoint), and the server instructions. Checked in headless Chrome that a generated page loads the hosted script with its integrity hash and creates the map, and that a page with a wrong hash does not run the script.

- **`render_map_tool`: a results panel for search results, with popularity from Place Details (#262).** When a map shows places from `search_and_geocode_tool`, `category_search_tool` or `ground_location_tool`, a list docked at the top right shows one row per pin (name, category, distance), numbered to match the pins. Clicking a row flies to that pin. The page then makes one batched `POST` to the Places API's `/details/retrieve` (up to 100 ids) and adds each place's popularity score to its row and its phone number to its popup. It runs client-side on the public token, like the other self-fetched layers, and if the call fails, the rows stay as they were.
- **Inline markers:** Claude usually builds the map from its own `markers` rather than `payload_refs`, so `markers[]` now takes optional `id` (the POI's `mapbox_id`), `name`, `category` and `distanceMeters`. A marker with `id` gets the same row and enrichment. Markers with `id` and no `label` are numbered to match their row.
- **Ids the API rejects:** the batch endpoint rejects the whole request if any id isn't a native Places id (`urn:mbxpoi:<uuid>`). Search Box also returns OSM-sourced ids (`urn:mbxpoi-osm:`) and, near Union Square in San Francisco, `urn:mbxpoi:mapbox-n<id>`. The page sends only UUID-form ids, and if the API still rejects one it names, drops it and retries once, so one bad id doesn't stop the rest from being enriched.
- **Photos through the server:** Place Details photos are hosted wherever each business's site hosts them (e.g. `dyn.mktgcdn.com`, `cdn.prod.website-files.com`), so the map's CSP can't list their hosts, and allowing any `https:` image host would also allow scripts from anywhere. A new resource, `mapbox://place-photo/{mapbox_id}`, looks the place up on the server and returns its first photo as base64, and the page shows it as a `data:` URL. The resource only takes an id, never a URL, and only fetches what Place Details returns for it: https on a public hostname that resolves only to public addresses, no redirects, JPEG/PNG/WebP/GIF only, up to 1 MB, with a 5 s timeout. Results are cached by id for 30 minutes, misses included. The cache is shared by everyone on the server process, so it is capped at 20 MB as well as 200 entries. Each photo costs one more single-place Place Details lookup on the server, only for rows whose place has a photo. A row gets a thumbnail only when a photo loads, for up to 10 rows per map. Pages from `export_map_html_tool` have no host or CSP, so they load the photo URL directly, as do such pages framed by a site that isn't an MCP host. Hosts without `resources/read`, such as ChatGPT, show no photos; the page stops waiting after 15 s.
- **Photos through the server:** Place Details photos are hosted wherever each business's site hosts them (e.g. `dyn.mktgcdn.com`, `cdn.prod.website-files.com`), so the map's CSP can't list their hosts, and allowing any `https:` image host would also allow scripts from anywhere. A new resource, `mapbox://place-photo/{mapbox_id}`, looks the place up on the server and returns its first photo as base64, and the page shows it as a `data:` URL. The resource only takes an id, never a URL, and only fetches what Place Details returns for it: https on a public hostname that resolves only to public addresses, no redirects, JPEG/PNG/WebP/GIF only, up to 1 MB, with a 5 s timeout. Results are cached by id for 30 minutes, misses included. The cache is shared by everyone on the server process, so it is capped at 20 MB as well as 200 entries. Each photo costs one more single-place Place Details lookup on the server, only for rows whose place has a photo. A row gets a thumbnail only when a photo loads, for up to 10 rows per map. A page with no MCP host or CSP loads the photo URL directly. Hosts without `resources/read`, such as ChatGPT, show no photos; the page stops waiting after 15 s.
- **One panel per render:** a render can draw inline markers and then merge in fetched search results. Both join one numbered list, a place in both is listed once, and each place is looked up once. The first set keeps the caller's own numbers when they're unique (a visit order, say), and later sets continue from there. A letter label such as `A` stays on its pin and shows in its row. A Place Details response from an earlier render is ignored, so it can't write into a newer panel.
- **Layout:** while the panel is open, the map fits its data into the space beside it. Clicking the panel's heading collapses it to a single line and refits, and on a map narrower than 560 px it starts collapsed.
- **Testing:** added tests for the panel and enrichment, including id filtering, numbering, inline markers, and photos both through the server and loaded directly on a standalone page. The photo resource has tests for its URL and content checks, size cap, caching and failures. The test sandbox's fake DOM gained `appendChild`/`removeChild`/`addEventListener`, and its fetch mock now passes the request `init` through, so the `POST` body can be checked.
Expand Down
9 changes: 0 additions & 9 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,6 @@ Each of the four prompts below exercises a different strategy in one tool call:
- "Show me a satellite view of Manhattan with key landmarks marked"
- "Generate a map highlighting all Starbucks locations within a mile of downtown Seattle"
- "Show a fill polygon over these coordinates: [...], with a marker labeled 'Warehouse' at [...]" — renders your own GeoJSON directly via `render_map_tool`, no other Mapbox tool needed
- "Build an index.html for our coffee shops with a map of the areas within a 10-minute walk of each shop" — `export_map_html_tool` returns a standalone map page for the site

### Analysis & Planning

Expand Down Expand Up @@ -163,14 +162,6 @@ Every geospatial tool in this server (directions, isochrone, search, and more) c

If you need a guaranteed static image in clients without MCP Apps support, use `static_map_image_tool` instead — it returns a base64-encoded PNG/JPEG that every client can display.

### Maps in pages you build (`export_map_html_tool`)

`render_map_tool` only renders inside the chat. When an agent is building a web page, site or file that needs a map, **`export_map_html_tool`** returns the same map as a standalone HTML page. It takes the same input as `render_map_tool` (refs from other tools, or your own GeoJSON) and returns a document of a few KB with the map data and a Mapbox public token included, ready to save as `map.html` or embed with an iframe. The agent doesn't need to write Mapbox GL JS code or ask for a token. Map data is limited to 200 KB per page, since the agent copies the whole page into a file; simplify large polygons with `simplify_tool` first.

- **Token:** the page uses the account's default public token unless you pass `public_token`. For a published site, a [URL-restricted public token](https://docs.mapbox.com/accounts/guides/tokens/#url-restrictions) is safer.
- **Map script:** the page loads it from jsDelivr (`@mapbox/mcp-server@<version>/dist/map-app/standalone.js`), pinned by version and a subresource-integrity hash, so the page itself stays small.
- **Live layers:** routes, isochrones and search results are fetched from the Mapbox API each time the page loads, billed to the token's account. The tool result says how many layers do this.

#### CLIENT_NEEDS_RESOURCE_FALLBACK

**Resource Fallback Tools (Opt-In for Non-Compliant Clients)**
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"mcp-server": "dist/esm/index.js"
},
"scripts": {
"build": "npm run prepare && tshy && npm run generate-version && node scripts/add-shebang.cjs && node scripts/build-map-app.mjs",
"build": "npm run prepare && tshy && npm run generate-version && node scripts/add-shebang.cjs",
"changelog:prepare-release": "node scripts/prepare-changelog-release.cjs",
"format": "prettier --check \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\" \"./examples/**/*.{ts,tsx,js,json,md}\"",
"format:fix": "prettier --write \"./src/**/*.{ts,tsx,js,json,md}\" \"./test/**/*.{ts,tsx,js,json,md}\" \"./examples/**/*.{ts,tsx,js,json,md}\"",
Expand Down
21 changes: 0 additions & 21 deletions scripts/build-map-app.mjs

This file was deleted.

47 changes: 11 additions & 36 deletions src/resources/ui-apps/mapAppHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,25 +36,6 @@ export function renderMapAppHtml(params: {
)}</script>`
: '';

return `${mapAppShell(glVersion)}
${initialDataScript}

<script>
${mapAppScript({
token: JSON.stringify(publicToken),
apiEndpoint: JSON.stringify(apiEndpoint)
})}
</script>
</body>
</html>`;
}

/**
* Everything in the map page before its data and script: Mapbox GL JS, styles,
* and the elements the script draws into. Shared by the in-chat MCP App page
* and the standalone page `export_map_html_tool` returns.
*/
export function mapAppShell(glVersion: string): string {
return `<!DOCTYPE html>
<html lang="en">
<head>
Expand Down Expand Up @@ -150,22 +131,13 @@ export function mapAppShell(glVersion: string): string {
<div id="legend" style="display:none"></div>
<div id="side-panel" style="display:none"></div>
<div id="loading">Loading…</div>
<div id="error" style="display:none"></div>`;
}
<div id="error" style="display:none"></div>
${initialDataScript}

/**
* The map page's script. `token` and `apiEndpoint` are JavaScript expressions,
* not values: the in-chat page passes JSON string literals, and the standalone
* build (MAP_APP_STANDALONE_CONFIG) reads them from a JSON tag in the page so
* one hosted copy works for every exported map.
*/
export function mapAppScript(config: {
token: string;
apiEndpoint: string;
}): string {
return `(function() {
var TOKEN = ${config.token};
var API_ENDPOINT = ${config.apiEndpoint};
<script>
(function() {
var TOKEN = ${JSON.stringify(publicToken)};
var API_ENDPOINT = ${JSON.stringify(apiEndpoint)};

var loadingEl = document.getElementById('loading');
var errorEl = document.getElementById('error');
Expand Down Expand Up @@ -2197,9 +2169,12 @@ export function mapAppScript(config: {
function escapeAttr(s) {
return escapeText(s).replace(/"/g, '&quot;');
}
})();`;
})();
</script>
</body>
</html>`;
}

export function escapeForScript(s: string): string {
function escapeForScript(s: string): string {
return s.replace(/<\/script>/gi, '<\\/script>');
}
85 changes: 0 additions & 85 deletions src/resources/ui-apps/mapAppStandalone.ts

This file was deleted.

This file was deleted.

This file was deleted.

Loading
Loading