From 1d7224fbac37bec2ab91d4c9a916479be255d112 Mon Sep 17 00:00:00 2001 From: ctufts Date: Thu, 15 Oct 2026 11:10:20 -0400 Subject: [PATCH] Revert "feat(tools): add export_map_html_tool for maps in agent-built pages (#275)" This reverts commit 1a1dea99ff25293fc8e74dda2ba06622ecb6779f. The work is kept on the feat/export-map-html-tool branch to re-land later. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 10 +- README.md | 9 - package.json | 2 +- scripts/build-map-app.mjs | 21 -- src/resources/ui-apps/mapAppHtml.ts | 47 +--- src/resources/ui-apps/mapAppStandalone.ts | 85 ------- .../ExportMapHtmlTool.input.schema.ts | 22 -- .../ExportMapHtmlTool.output.schema.ts | 28 --- .../export-map-html-tool/ExportMapHtmlTool.ts | 215 ---------------- src/tools/render-map-tool/RenderMapTool.ts | 94 ++++++- .../render-map-tool/assembleMapPayload.ts | 92 ------- src/tools/toolRegistry.ts | 2 - src/utils/serverInstructions.ts | 5 - .../ui-apps/mapAppStandalone.test.ts | 149 ----------- .../ExportMapHtmlTool.test.ts | 232 ------------------ test/utils/serverInstructions.test.ts | 9 - 16 files changed, 104 insertions(+), 918 deletions(-) delete mode 100644 scripts/build-map-app.mjs delete mode 100644 src/resources/ui-apps/mapAppStandalone.ts delete mode 100644 src/tools/export-map-html-tool/ExportMapHtmlTool.input.schema.ts delete mode 100644 src/tools/export-map-html-tool/ExportMapHtmlTool.output.schema.ts delete mode 100644 src/tools/export-map-html-tool/ExportMapHtmlTool.ts delete mode 100644 src/tools/render-map-tool/assembleMapPayload.ts delete mode 100644 test/resources/ui-apps/mapAppStandalone.test.ts delete mode 100644 test/tools/export-map-html-tool/ExportMapHtmlTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8266dcf4..202a9e20 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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:`). Search Box also returns OSM-sourced ids (`urn:mbxpoi-osm:`) and, near Union Square in San Francisco, `urn:mbxpoi:mapbox-n`. 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. diff --git a/README.md b/README.md index 9f5f0b7a..ef875a51 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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@/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)** diff --git a/package.json b/package.json index b3d47b99..7338ded9 100644 --- a/package.json +++ b/package.json @@ -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}\"", diff --git a/scripts/build-map-app.mjs b/scripts/build-map-app.mjs deleted file mode 100644 index 16dc85c6..00000000 --- a/scripts/build-map-app.mjs +++ /dev/null @@ -1,21 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -// Writes the standalone map script that export_map_html_tool pages load from -// jsDelivr. Runs after tshy, from the compiled ESM output, so the file is -// byte-for-byte what standaloneMapAppScript() returns and matches the -// integrity hash the tool puts in each page. - -import { mkdirSync, writeFileSync } from 'node:fs'; -import { dirname } from 'node:path'; -import { - STANDALONE_SCRIPT_PATH, - standaloneMapAppScript, - standaloneScriptIntegrity -} from '../dist/esm/resources/ui-apps/mapAppStandalone.js'; - -mkdirSync(dirname(STANDALONE_SCRIPT_PATH), { recursive: true }); -writeFileSync(STANDALONE_SCRIPT_PATH, standaloneMapAppScript()); -console.log( - `Wrote ${STANDALONE_SCRIPT_PATH} (${standaloneMapAppScript().length} bytes, ${standaloneScriptIntegrity()})` -); diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index 1a376c4e..60056e1c 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -36,25 +36,6 @@ export function renderMapAppHtml(params: { )}` : ''; - return `${mapAppShell(glVersion)} -${initialDataScript} - - - -`; -} - -/** - * 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 ` @@ -150,22 +131,13 @@ export function mapAppShell(glVersion: string): string {
Loading…
-`; -} + +${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}; + + +`; } -export function escapeForScript(s: string): string { +function escapeForScript(s: string): string { return s.replace(/<\/script>/gi, '<\\/script>'); } diff --git a/src/resources/ui-apps/mapAppStandalone.ts b/src/resources/ui-apps/mapAppStandalone.ts deleted file mode 100644 index 26c99f38..00000000 --- a/src/resources/ui-apps/mapAppStandalone.ts +++ /dev/null @@ -1,85 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import { createHash } from 'node:crypto'; -import type { MapAppPayload } from '../../utils/mapAppPayload.js'; -import { - escapeForScript, - MAPBOX_GL_VERSION, - mapAppScript, - mapAppShell -} from './mapAppHtml.js'; - -/** - * Standalone build of the map app, for pages that live outside an MCP host - * (e.g. a site an agent is building). The page carries its data and config - * as JSON tags and loads the shared map script from jsDelivr, so the HTML an - * agent has to copy into a file stays a few KB instead of the ~60 KB inline - * script the in-chat page uses. - * - * The build writes `standaloneMapAppScript()` to dist/map-app/standalone.js - * (scripts/build-map-app.mjs), which ships in the npm package; the page pins - * that file by package version and subresource-integrity hash. - */ - -const NPM_PACKAGE = '@mapbox/mcp-server'; -export const STANDALONE_SCRIPT_PATH = 'dist/map-app/standalone.js'; - -// Read TOKEN / API_ENDPOINT from the page's #map-config JSON tag instead of -// having them interpolated, so one hosted copy works for every page. -const readConfig = (key: 'token' | 'apiEndpoint', fallback: string) => - `(function() { try { var c = JSON.parse(document.getElementById('map-config').textContent); ` + - `return typeof c.${key} === 'string' ? c.${key} : ${JSON.stringify(fallback)}; } ` + - `catch (_) { return ${JSON.stringify(fallback)}; } })()`; - -let cachedScript: string | undefined; - -/** The exact contents of dist/map-app/standalone.js. */ -export function standaloneMapAppScript(): string { - cachedScript ??= - mapAppScript({ - token: readConfig('token', ''), - apiEndpoint: readConfig('apiEndpoint', 'https://api.mapbox.com/') - }) + '\n'; - return cachedScript; -} - -/** Subresource-integrity value for the hosted script. */ -export function standaloneScriptIntegrity(): string { - const digest = createHash('sha384') - .update(standaloneMapAppScript(), 'utf8') - .digest('base64'); - return `sha384-${digest}`; -} - -export function standaloneScriptUrl(packageVersion: string): string { - return `https://cdn.jsdelivr.net/npm/${NPM_PACKAGE}@${packageVersion}/${STANDALONE_SCRIPT_PATH}`; -} - -/** - * A complete, self-contained HTML page showing `payload`. `scriptUrl` defaults - * to the jsDelivr copy for `packageVersion`; override it to serve the script - * from somewhere else. - */ -export function renderStandaloneMapHtml(params: { - publicToken: string; - payload: MapAppPayload; - packageVersion: string; - apiEndpoint?: string; - glVersion?: string; - scriptUrl?: string; -}): string { - const config = { - token: params.publicToken, - apiEndpoint: params.apiEndpoint ?? 'https://api.mapbox.com/' - }; - const scriptUrl = - params.scriptUrl ?? standaloneScriptUrl(params.packageVersion); - return `${mapAppShell(params.glVersion ?? MAPBOX_GL_VERSION)} - - - - - -`; -} diff --git a/src/tools/export-map-html-tool/ExportMapHtmlTool.input.schema.ts b/src/tools/export-map-html-tool/ExportMapHtmlTool.input.schema.ts deleted file mode 100644 index 2ca5d991..00000000 --- a/src/tools/export-map-html-tool/ExportMapHtmlTool.input.schema.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import { z } from 'zod'; -import { RenderMapInputSchema } from '../render-map-tool/RenderMapTool.input.schema.js'; - -/** - * Same payload input as `render_map_tool`, so refs from other Mapbox tools and - * inline layers work the same way, plus an optional public token to put in - * the page instead of the account's default one. - */ -export const ExportMapHtmlInputSchema = RenderMapInputSchema.extend({ - public_token: z - .string() - .regex(/^pk\./, 'must be a Mapbox public token (pk.*)') - .optional() - .describe( - "Mapbox public token (pk.*) to put in the page. Defaults to the account's default public token. A URL-restricted token is recommended for a published site." - ) -}); - -export type ExportMapHtmlInput = z.infer; diff --git a/src/tools/export-map-html-tool/ExportMapHtmlTool.output.schema.ts b/src/tools/export-map-html-tool/ExportMapHtmlTool.output.schema.ts deleted file mode 100644 index f9d13800..00000000 --- a/src/tools/export-map-html-tool/ExportMapHtmlTool.output.schema.ts +++ /dev/null @@ -1,28 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import { z } from 'zod'; - -export const ExportMapHtmlOutputSchema = z.object({ - bytes: z - .number() - .int() - .describe( - 'Size of the HTML document, which is the second text content item. Save it as its own file, e.g. map.html.' - ), - layer_count: z.number().int(), - marker_count: z.number().int(), - live_layer_count: z - .number() - .int() - .describe( - "Layers the page fetches from the Mapbox API each time it loads, using the page's token." - ), - token_source: z - .enum(['provided', 'access_token', 'account_default', 'server_default']) - .describe('Where the public token in the page came from.'), - script_url: z.string().describe('Map script the page loads.'), - summary: z.string().optional() -}); - -export type ExportMapHtmlOutput = z.infer; diff --git a/src/tools/export-map-html-tool/ExportMapHtmlTool.ts b/src/tools/export-map-html-tool/ExportMapHtmlTool.ts deleted file mode 100644 index 385ba275..00000000 --- a/src/tools/export-map-html-tool/ExportMapHtmlTool.ts +++ /dev/null @@ -1,215 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; -import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; -import type { HttpRequest } from '../../utils/types.js'; -import { - ExportMapHtmlInputSchema, - type ExportMapHtmlInput -} from './ExportMapHtmlTool.input.schema.js'; -import { - ExportMapHtmlOutputSchema, - type ExportMapHtmlOutput -} from './ExportMapHtmlTool.output.schema.js'; -import { assembleMapPayload } from '../render-map-tool/assembleMapPayload.js'; -import { - renderStandaloneMapHtml, - standaloneScriptUrl -} from '../../resources/ui-apps/mapAppStandalone.js'; -import { renderMapAppHtml } from '../../resources/ui-apps/mapAppHtml.js'; -import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; -import { getOwnerKeyFromToken } from '../../utils/jwtUtils.js'; -import { getVersionInfo } from '../../utils/versionUtils.js'; - -/** - * Largest map payload (serialized JSON) the tool writes into a page. Unlike - * render_map_tool, which stores large payloads server-side and passes a ref, - * the page has to carry its data, and the agent copies the whole page into a - * file write, so every byte costs output tokens. - */ -export const MAX_EXPORT_PAYLOAD_BYTES = 200 * 1024; - -/** - * `export_map_html_tool` — the same map `render_map_tool` draws in chat, as a - * standalone HTML page an agent can save into a site or project. Hosts render - * `render_map_tool` only inside the conversation, so without this an agent - * building a page has to write its own Mapbox GL JS code and ask the user for - * a token. - * - * The page is a few KB: the map data and a public token as JSON tags, plus - * the shared map script loaded from jsDelivr by package version (see - * mapAppStandalone.ts). Keeping it small matters because the agent copies the - * whole document into a file write. - */ -export class ExportMapHtmlTool extends MapboxApiBasedTool< - typeof ExportMapHtmlInputSchema, - typeof ExportMapHtmlOutputSchema -> { - name = 'export_map_html_tool'; - description = - 'Generate a standalone HTML page with an interactive Mapbox GL JS map, for when you are building a web page, site or file that needs a map. ' + - 'Takes the same input as render_map_tool: `payload_refs` from other Mapbox tools (pass `mapboxRender.ref` values unchanged), or inline `layers`/`markers`/`legend`. ' + - 'Returns a complete HTML document with the map data and a Mapbox public token included (a few KB plus the data, which is limited to 200 KB), to save as its own file (e.g. map.html) or embed with an iframe. ' + - 'To show a map in this conversation instead, use render_map_tool.'; - annotations = { - title: 'Export Map as HTML', - readOnlyHint: true, - destructiveHint: false, - idempotentHint: true, - // May call the Tokens API to find the account's default public token. - openWorldHint: true - }; - - private readonly packageVersion: () => string; - - constructor(params: { - httpRequest: HttpRequest; - packageVersion?: () => string; - }) { - super({ - inputSchema: ExportMapHtmlInputSchema, - outputSchema: ExportMapHtmlOutputSchema, - httpRequest: params.httpRequest - }); - this.packageVersion = - params.packageVersion ?? (() => getVersionInfo().version); - } - - protected async execute( - input: ExportMapHtmlInput, - accessToken: string - ): Promise { - const owner = getOwnerKeyFromToken(accessToken); - const { payload, unresolvedRefs } = assembleMapPayload(input, owner); - - const nothingDrawable = - (payload?.layers?.length ?? 0) === 0 && - (payload?.markers?.length ?? 0) === 0 && - (payload?.selfFetch?.length ?? 0) === 0; - if (!payload || nothingDrawable) { - return { - content: [ - { - type: 'text', - text: - unresolvedRefs.length > 0 - ? 'ExportMapHtmlTool: none of the provided payload_refs could be resolved. ' + - 'They have likely expired (temporary map data lasts about 30 minutes) or belong to a different session. ' + - 'Re-run the tool(s) that produced the data and pass their mapboxRender.ref values unchanged.' - : 'ExportMapHtmlTool: nothing to draw. Pass `payload_refs` or inline `layers`/`markers`.' - } - ], - isError: true - }; - } - // A deferred layer is fetched from the MCP host with resources/read, which - // a standalone page doesn't have. - delete payload.defer; - - const payloadBytes = Buffer.byteLength(JSON.stringify(payload), 'utf8'); - if (payloadBytes > MAX_EXPORT_PAYLOAD_BYTES) { - return { - content: [ - { - type: 'text', - text: - `ExportMapHtmlTool: the map data is ${Math.round(payloadBytes / 1024)} KB, over the ${MAX_EXPORT_PAYLOAD_BYTES / 1024} KB limit for a standalone page. ` + - 'Reduce it before exporting: run large polygons or lines through simplify_tool, drop features or properties the page does not need, ' + - 'or split the data across several maps.' - } - ], - isError: true - }; - } - - const apiEndpoint = MapboxApiBasedTool.mapboxApiEndpoint; - const publicToken = - input.public_token ?? - (await resolveMapboxPublicToken({ - accessToken, - apiEndpoint, - httpRequest: this.httpRequest - })); - if (!publicToken) { - return { - content: [ - { - type: 'text', - text: - 'ExportMapHtmlTool: no Mapbox public token available for the page. ' + - 'Pass `public_token` (a pk.* token), or configure the server with MAPBOX_PUBLIC_TOKEN ' + - "or an access token that can read the account's tokens (tokens:read)." - } - ], - isError: true - }; - } - const tokenSource: ExportMapHtmlOutput['token_source'] = input.public_token - ? 'provided' - : accessToken.startsWith('pk.') - ? 'access_token' - : publicToken === process.env.MAPBOX_PUBLIC_TOKEN - ? 'server_default' - : 'account_default'; - - // The hosted script is pinned to a published package version. Running - // from source, the version can't be read ('0.0.0'), so inline the script - // instead of pointing at a URL that would 404. - const packageVersion = this.packageVersion(); - const hosted = packageVersion !== '0.0.0'; - const html = hosted - ? renderStandaloneMapHtml({ - publicToken, - payload, - packageVersion, - apiEndpoint - }) - : renderMapAppHtml({ publicToken, apiEndpoint, initialData: payload }); - - const layerCount = payload.layers?.length ?? 0; - const markerCount = payload.markers?.length ?? 0; - const liveLayerCount = payload.selfFetch?.length ?? 0; - const bytes = Buffer.byteLength(html, 'utf8'); - - const notes = [ - `Standalone map page (${bytes} bytes): ${layerCount} layer${layerCount === 1 ? '' : 's'}, ` + - `${markerCount} marker${markerCount === 1 ? '' : 's'}` + - (payload.summary ? `, "${payload.summary}"` : '') + - '. Save the HTML below as its own file (e.g. map.html), or embed that file with an iframe.', - hosted - ? '' - : 'The server version is unknown (running from source), so the map script is inlined instead of loaded from jsDelivr.', - tokenSource === 'provided' - ? 'The page uses the public token you provided.' - : "The page uses the account's default public token. For a published site, a URL-restricted public token (pass it as `public_token`) is safer.", - liveLayerCount > 0 - ? `The page fetches ${liveLayerCount} layer${liveLayerCount === 1 ? '' : 's'} from the Mapbox API each time it loads, billed to that token's account.` - : '', - unresolvedRefs.length > 0 - ? `${unresolvedRefs.length} payload ref${unresolvedRefs.length === 1 ? '' : 's'} could not be resolved and ${unresolvedRefs.length === 1 ? 'was' : 'were'} left out.` - : '' - ].filter(Boolean); - - // The page goes in the text content only. Repeating it here would double - // the tokens the host passes to the model. - const structured: ExportMapHtmlOutput = { - bytes, - layer_count: layerCount, - marker_count: markerCount, - live_layer_count: liveLayerCount, - token_source: tokenSource, - script_url: hosted ? standaloneScriptUrl(packageVersion) : 'inline', - summary: payload.summary - }; - - return { - content: [ - { type: 'text', text: notes.join(' ') }, - { type: 'text', text: html } - ], - structuredContent: structured, - isError: false - }; - } -} diff --git a/src/tools/render-map-tool/RenderMapTool.ts b/src/tools/render-map-tool/RenderMapTool.ts index 69da087a..e3a5eebe 100644 --- a/src/tools/render-map-tool/RenderMapTool.ts +++ b/src/tools/render-map-tool/RenderMapTool.ts @@ -15,13 +15,24 @@ import { type RenderMapInput } from './RenderMapTool.input.schema.js'; import { RenderMapOutputSchema } from './RenderMapTool.output.schema.js'; +import type { MapAppPayload } from '../../utils/mapAppPayload.js'; import { + resolveMapPayloadRef, + mergeMapPayloads, storeMapPayload, buildMapboxRenderField, MAX_INLINE_PAYLOAD_BYTES } from '../../utils/storeMapPayload.js'; -import { buildInlinePayloadRef } from '../../utils/inlinePayloadRef.js'; -import { assembleMapPayload } from './assembleMapPayload.js'; +import { isComputeRef, resolveComputeRef } from '../../utils/computeRef.js'; +import { + isSelfFetchRef, + resolveSelfFetchRef +} from '../../utils/selfFetchRef.js'; +import { + isInlinePayloadRef, + resolveInlinePayloadRef, + buildInlinePayloadRef +} from '../../utils/inlinePayloadRef.js'; import { getOwnerKeyFromToken } from '../../utils/jwtUtils.js'; import type { HttpRequest } from '../../utils/types.js'; @@ -116,7 +127,10 @@ export class RenderMapTool extends BaseTool< try { const input = RenderMapInputSchema.parse(rawInput); - const { payload, unresolvedRefs } = assembleMapPayload(input, owner); + const { payload, unresolvedRefs } = this.assemblePayload( + input, + owner + ); // A summary/legend alone counts as renderable, so without this a // call whose only data ref failed would still "succeed" and draw @@ -255,6 +269,80 @@ export class RenderMapTool extends BaseTool< } ); } + + /** + * Resolve `payload_refs` and merge with any inline layers/markers/legend. + * Inline `summary` (when set) overrides any summary from the refs. + * `payload` is null if nothing renderable is provided. `unresolvedRefs` + * lists any input refs that failed to resolve (expired/unknown/wrong + * owner) so the caller can tell the LLM data may be missing or stale, + * rather than silently dropping it. + */ + private assemblePayload( + input: RenderMapInput, + owner: string | undefined + ): { payload: MapAppPayload | null; unresolvedRefs: string[] } { + const fromRefs: MapAppPayload[] = []; + const unresolvedRefs: string[] = []; + if (Array.isArray(input.payload_refs)) { + for (const ref of input.payload_refs) { + // Compute refs (union/intersect/difference), self-fetch refs + // (directions), and inline-payload refs (render_map_tool's own + // output, in case it's chained into another render_map_tool call) + // are all self-describing and carry no server-side state — + // resolving them never depends on `owner` or on anything having + // survived since the ref was minted. + const resolved = isComputeRef(ref) + ? resolveComputeRef(ref) + : isSelfFetchRef(ref) + ? resolveSelfFetchRef(ref) + : isInlinePayloadRef(ref) + ? resolveInlinePayloadRef(ref) + : resolveMapPayloadRef(ref, owner); + if (resolved) fromRefs.push(resolved); + else unresolvedRefs.push(ref); + } + } + + const inline: MapAppPayload = { + // RenderMapInput's `data: z.unknown()` widens layer geometry beyond + // MapAppPayload's strict Geometry union, so cast to assemble the + // payload object that flows to the iframe renderer. + layers: (input.layers ?? []) as MapAppPayload['layers'], + markers: input.markers as MapAppPayload['markers'], + legend: input.legend, + camera: input.camera as MapAppPayload['camera'], + summary: input.summary, + baseMapConfig: input.baseMapConfig, + baseStyle: input.baseStyle + }; + const hasInlineContent = + (inline.layers && inline.layers.length > 0) || + (inline.markers && inline.markers.length > 0); + + const all: MapAppPayload[] = [...fromRefs]; + // baseMapConfig/baseStyle-only calls (e.g. "now turn the water red" or + // "switch to satellite" with no new data) are renderable on their own — + // treated like summary/legend. + if ( + hasInlineContent || + inline.summary || + inline.legend || + inline.baseMapConfig || + inline.baseStyle + ) + all.push(inline); + if (all.length === 0) return { payload: null, unresolvedRefs }; + + const merged = mergeMapPayloads(all); + // Inline summary/camera/legend/baseMapConfig/baseStyle take precedence when provided. + if (input.summary) merged.summary = input.summary; + if (input.camera) merged.camera = inline.camera; + if (input.legend) merged.legend = input.legend; + if (input.baseMapConfig) merged.baseMapConfig = input.baseMapConfig; + if (input.baseStyle) merged.baseStyle = input.baseStyle; + return { payload: merged, unresolvedRefs }; + } } export type { RenderMapInput }; diff --git a/src/tools/render-map-tool/assembleMapPayload.ts b/src/tools/render-map-tool/assembleMapPayload.ts deleted file mode 100644 index 4eb8ab0e..00000000 --- a/src/tools/render-map-tool/assembleMapPayload.ts +++ /dev/null @@ -1,92 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import type { MapAppPayload } from '../../utils/mapAppPayload.js'; -import { - resolveMapPayloadRef, - mergeMapPayloads -} from '../../utils/storeMapPayload.js'; -import { isComputeRef, resolveComputeRef } from '../../utils/computeRef.js'; -import { - isSelfFetchRef, - resolveSelfFetchRef -} from '../../utils/selfFetchRef.js'; -import { - isInlinePayloadRef, - resolveInlinePayloadRef -} from '../../utils/inlinePayloadRef.js'; -import type { RenderMapInput } from './RenderMapTool.input.schema.js'; - -/** - * Resolve `payload_refs` and merge with any inline layers/markers/legend. - * Inline `summary` (when set) overrides any summary from the refs. - * `payload` is null if nothing renderable is provided. `unresolvedRefs` - * lists any input refs that failed to resolve (expired/unknown/wrong - * owner) so the caller can tell the LLM data may be missing or stale, - * rather than silently dropping it. - */ -export function assembleMapPayload( - input: RenderMapInput, - owner: string | undefined -): { payload: MapAppPayload | null; unresolvedRefs: string[] } { - const fromRefs: MapAppPayload[] = []; - const unresolvedRefs: string[] = []; - if (Array.isArray(input.payload_refs)) { - for (const ref of input.payload_refs) { - // Compute refs (union/intersect/difference), self-fetch refs - // (directions), and inline-payload refs (render_map_tool's own - // output, in case it's chained into another render_map_tool call) - // are all self-describing and carry no server-side state — - // resolving them never depends on `owner` or on anything having - // survived since the ref was minted. - const resolved = isComputeRef(ref) - ? resolveComputeRef(ref) - : isSelfFetchRef(ref) - ? resolveSelfFetchRef(ref) - : isInlinePayloadRef(ref) - ? resolveInlinePayloadRef(ref) - : resolveMapPayloadRef(ref, owner); - if (resolved) fromRefs.push(resolved); - else unresolvedRefs.push(ref); - } - } - - const inline: MapAppPayload = { - // RenderMapInput's `data: z.unknown()` widens layer geometry beyond - // MapAppPayload's strict Geometry union, so cast to assemble the - // payload object that flows to the iframe renderer. - layers: (input.layers ?? []) as MapAppPayload['layers'], - markers: input.markers as MapAppPayload['markers'], - legend: input.legend, - camera: input.camera as MapAppPayload['camera'], - summary: input.summary, - baseMapConfig: input.baseMapConfig, - baseStyle: input.baseStyle - }; - const hasInlineContent = - (inline.layers && inline.layers.length > 0) || - (inline.markers && inline.markers.length > 0); - - const all: MapAppPayload[] = [...fromRefs]; - // baseMapConfig/baseStyle-only calls (e.g. "now turn the water red" or - // "switch to satellite" with no new data) are renderable on their own — - // treated like summary/legend. - if ( - hasInlineContent || - inline.summary || - inline.legend || - inline.baseMapConfig || - inline.baseStyle - ) - all.push(inline); - if (all.length === 0) return { payload: null, unresolvedRefs }; - - const merged = mergeMapPayloads(all); - // Inline summary/camera/legend/baseMapConfig/baseStyle take precedence when provided. - if (input.summary) merged.summary = input.summary; - if (input.camera) merged.camera = inline.camera; - if (input.legend) merged.legend = input.legend; - if (input.baseMapConfig) merged.baseMapConfig = input.baseMapConfig; - if (input.baseStyle) merged.baseStyle = input.baseStyle; - return { payload: merged, unresolvedRefs }; -} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index 6e7067f2..22552513 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -2,7 +2,6 @@ // Licensed under the MIT License. // INSERT NEW TOOL IMPORT HERE -import { ExportMapHtmlTool } from './export-map-html-tool/ExportMapHtmlTool.js'; import { GroundLocationTool } from './ground-location-tool/GroundLocationTool.js'; import { PointsWithinPolygonTool } from './points-within-polygon-tool/PointsWithinPolygonTool.js'; import { UnionTool } from './union-tool/UnionTool.js'; @@ -42,7 +41,6 @@ import { httpRequest } from '../utils/httpPipeline.js'; */ export const CORE_TOOLS = [ // INSERT NEW TOOL INSTANCE HERE - new ExportMapHtmlTool({ httpRequest }), new GroundLocationTool({ httpRequest }), new PointsWithinPolygonTool(), new UnionTool(), diff --git a/src/utils/serverInstructions.ts b/src/utils/serverInstructions.ts index 804f4386..dfaf5b81 100644 --- a/src/utils/serverInstructions.ts +++ b/src/utils/serverInstructions.ts @@ -19,11 +19,6 @@ export function buildServerInstructions( 'To show anything on a map, use render_map_tool: it renders an interactive Mapbox GL JS map inline.', 'When a tool result includes mapboxRender.ref, pass that ref unchanged in render_map_tool payload_refs (several refs can share one map). Never write or edit a ref by hand.' ]; - if (enabledToolNames.includes('export_map_html_tool')) { - lines.push( - 'When you are building a web page, site or file that needs a map, use export_map_html_tool: it returns the same map as a standalone HTML page, with the data and a public token included.' - ); - } if (enabledToolNames.includes('static_map_image_tool')) { lines.push( 'Use static_map_image_tool only when the user explicitly asks for a static image file (PNG/JPEG), never as the default way to show a map.' diff --git a/test/resources/ui-apps/mapAppStandalone.test.ts b/test/resources/ui-apps/mapAppStandalone.test.ts deleted file mode 100644 index 4b99d214..00000000 --- a/test/resources/ui-apps/mapAppStandalone.test.ts +++ /dev/null @@ -1,149 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -import { describe, it, expect } from 'vitest'; -import * as vm from 'node:vm'; -import { - renderStandaloneMapHtml, - standaloneMapAppScript, - standaloneScriptUrl -} from '../../../src/resources/ui-apps/mapAppStandalone.js'; -import { - mapAppScript, - renderMapAppHtml -} from '../../../src/resources/ui-apps/mapAppHtml.js'; - -/** - * Runs the hosted standalone script against a fake page with the given - * #map-config and #initial-data tags. Top-level page: window.parent is the - * window itself, as in a real browser with no host. - */ -function runStandalone(config: unknown, initialData: unknown) { - const elements: Record = { - 'map-config': { textContent: JSON.stringify(config), style: {} }, - 'initial-data': { textContent: JSON.stringify(initialData), style: {} } - }; - const fetchUrls: string[] = []; - const mapboxgl = { - accessToken: '', - Map: function Map() { - return { - addControl: () => {}, - on: (event: string, cb: () => void) => { - if (event === 'load') cb(); - }, - once: () => {}, - addSource: () => {}, - addLayer: () => {}, - getLayer: () => null, - getSource: () => null, - removeLayer: () => {}, - removeSource: () => {}, - fitBounds: () => {}, - flyTo: () => {}, - resize: () => {} - }; - }, - NavigationControl: function NavigationControl() {}, - Marker: function Marker() { - const m = { - setLngLat: () => m, - addTo: () => m, - setPopup: () => m, - remove: () => {} - }; - return m; - }, - Popup: function Popup() { - return { setText: () => ({}), setHTML: () => ({}) }; - } - }; - const listeners: Array<(e: { data: unknown }) => void> = []; - const win: Record = { - addEventListener: (event: string, cb: (e: { data: unknown }) => void) => { - if (event === 'message') listeners.push(cb); - } - }; - win.parent = { - postMessage: (data: unknown) => listeners.forEach((l) => l({ data })) - }; - const sandbox = { - window: win, - document: { - getElementById: (id: string) => - (elements[id] ??= { textContent: '', style: {} }), - createElement: () => ({ style: {}, textContent: '', className: '' }) - }, - mapboxgl, - console, - setTimeout, - URLSearchParams, - fetch: (url: string) => { - fetchUrls.push(url); - return Promise.resolve({ - ok: false, - status: 599, - json: async () => ({}) - }); - } - }; - vm.createContext(sandbox); - vm.runInContext(standaloneMapAppScript(), sandbox); - return { mapboxgl, fetchUrls, errorEl: elements.error }; -} - -describe('standalone map app', () => { - it('reads its token and API endpoint from the page', () => { - const { mapboxgl, fetchUrls, errorEl } = runStandalone( - { token: 'pk.page-token', apiEndpoint: 'https://example.test/' }, - { - layers: [], - selfFetch: [ - { - tool: 'directions', - params: { - coordinates: [ - { longitude: -105, latitude: 39.75 }, - { longitude: -105.2, latitude: 39.66 } - ] - } - } - ] - } - ); - expect(mapboxgl.accessToken).toBe('pk.page-token'); - expect(errorEl?.textContent ?? '').toBe(''); - expect(fetchUrls.length).toBeGreaterThan(0); - expect(fetchUrls[0].startsWith('https://example.test/')).toBe(true); - expect(fetchUrls[0]).toContain('access_token=pk.page-token'); - }); - - it('is the in-chat script with only the config source changed', () => { - const inline = mapAppScript({ token: '"T"', apiEndpoint: '"E"' }); - const standalone = standaloneMapAppScript(); - const strip = (s: string) => - s - .replace(/var TOKEN = .*;\n/, '') - .replace(/var API_ENDPOINT = .*;\n/, ''); - expect(strip(standalone).trimEnd()).toBe(strip(inline).trimEnd()); - expect(renderMapAppHtml({ publicToken: 'pk.x' })).toContain( - mapAppScript({ - token: '"pk.x"', - apiEndpoint: '"https://api.mapbox.com/"' - }) - ); - }); - - it('pins the script URL to the package version', () => { - expect(standaloneScriptUrl('1.2.3')).toBe( - 'https://cdn.jsdelivr.net/npm/@mapbox/mcp-server@1.2.3/dist/map-app/standalone.js' - ); - const html = renderStandaloneMapHtml({ - publicToken: 'pk.x', - payload: { layers: [] }, - packageVersion: '1.2.3', - scriptUrl: 'https://cdn.example/standalone.js' - }); - expect(html).toContain('src="https://cdn.example/standalone.js"'); - }); -}); diff --git a/test/tools/export-map-html-tool/ExportMapHtmlTool.test.ts b/test/tools/export-map-html-tool/ExportMapHtmlTool.test.ts deleted file mode 100644 index 0df03526..00000000 --- a/test/tools/export-map-html-tool/ExportMapHtmlTool.test.ts +++ /dev/null @@ -1,232 +0,0 @@ -// Copyright (c) Mapbox, Inc. -// Licensed under the MIT License. - -process.env.MAPBOX_ACCESS_TOKEN = - 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ0ZXN0In0.signature'; - -import { createHash } from 'node:crypto'; -import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest'; -import { setupHttpRequest } from '../../utils/httpPipelineUtils.js'; -import { - ExportMapHtmlTool, - MAX_EXPORT_PAYLOAD_BYTES -} from '../../../src/tools/export-map-html-tool/ExportMapHtmlTool.js'; -import { standaloneMapAppScript } from '../../../src/resources/ui-apps/mapAppStandalone.js'; -import { buildSelfFetchRef } from '../../../src/utils/selfFetchRef.js'; - -const SERVER_PK = 'pk.eyJ1Ijoic2VydmVyIn0.server'; - -function textOf(result: { content: unknown }, index: number): string { - const item = (result.content as Array<{ type: string; text: string }>)[index]; - return item.text; -} - -function jsonTag(html: string, id: string): unknown { - const match = html.match( - new RegExp( - `` - ) - ); - if (!match) throw new Error(`no #${id} tag`); - return JSON.parse(match[1]); -} - -const markers = [ - { coordinates: [-73.99, 40.7], label: '1', popup: 'Shop one' }, - { coordinates: [-73.96, 40.72], label: '2', popup: 'Shop two' } -]; - -describe('ExportMapHtmlTool', () => { - beforeEach(() => { - process.env.MAPBOX_PUBLIC_TOKEN = SERVER_PK; - }); - afterEach(() => { - delete process.env.MAPBOX_PUBLIC_TOKEN; - vi.restoreAllMocks(); - }); - - it('returns a small standalone page with the data, token and pinned script', async () => { - const { httpRequest } = setupHttpRequest(); - const result = await new ExportMapHtmlTool({ - httpRequest, - packageVersion: () => '9.9.9' - }).run({ - markers, - summary: 'Our shops' - }); - - expect(result.isError).toBe(false); - const html = textOf(result, 1); - // Small enough for an agent to copy into a file write; the ~60 KB map - // script is loaded from the CDN instead of inlined. - expect(html.length).toBeLessThan(10_000); - expect(html).not.toContain('function initMap'); - - expect(jsonTag(html, 'initial-data')).toMatchObject({ - markers, - summary: 'Our shops' - }); - expect(jsonTag(html, 'map-config')).toEqual({ - token: SERVER_PK, - apiEndpoint: 'https://api.mapbox.com/' - }); - - const version = '9.9.9'; - const integrity = `sha384-${createHash('sha384').update(standaloneMapAppScript(), 'utf8').digest('base64')}`; - expect(html).toContain( - `` - ); - - expect(result.structuredContent).toMatchObject({ - marker_count: 2, - layer_count: 0, - live_layer_count: 0, - token_source: 'server_default', - bytes: Buffer.byteLength(html, 'utf8') - }); - // The page is returned once, as text, not repeated in structuredContent. - expect(result.structuredContent).not.toHaveProperty('html'); - expect(JSON.stringify(result.structuredContent)).not.toContain(' { - const { httpRequest } = setupHttpRequest(); - const result = await new ExportMapHtmlTool({ - httpRequest, - packageVersion: () => '9.9.9' - }).run({ - markers, - public_token: 'pk.eyJ1IjoibWUifQ.restricted' - }); - - expect(result.isError).toBe(false); - expect(jsonTag(textOf(result, 1), 'map-config')).toMatchObject({ - token: 'pk.eyJ1IjoibWUifQ.restricted' - }); - expect(result.structuredContent).toMatchObject({ - token_source: 'provided' - }); - }); - - it('rejects a public_token that is not a pk token', async () => { - const { httpRequest } = setupHttpRequest(); - const result = await new ExportMapHtmlTool({ httpRequest }).run({ - markers, - public_token: 'sk.secret' - }); - expect(result.isError).toBe(true); - }); - - it('says when layers are fetched live on each page load', async () => { - const { httpRequest } = setupHttpRequest(); - const ref = buildSelfFetchRef('directions', { - coordinates: [ - { longitude: -105, latitude: 39.75 }, - { longitude: -105.2, latitude: 39.66 } - ] - }); - const result = await new ExportMapHtmlTool({ httpRequest }).run({ - payload_refs: [ref] - }); - - expect(result.isError).toBe(false); - expect(result.structuredContent).toMatchObject({ live_layer_count: 1 }); - expect(textOf(result, 0)).toContain( - 'fetches 1 layer from the Mapbox API each time it loads' - ); - expect( - (jsonTag(textOf(result, 1), 'initial-data') as { selfFetch: unknown[] }) - .selfFetch - ).toHaveLength(1); - }); - - it('keeps a summary containing inside its JSON tag', async () => { - const { httpRequest } = setupHttpRequest(); - const result = await new ExportMapHtmlTool({ httpRequest }).run({ - markers, - summary: 'ab' - }); - - const html = textOf(result, 1); - expect(html).not.toContain('b' - }); - }); - - it('rejects map data over the size limit with next steps', async () => { - const { httpRequest } = setupHttpRequest(); - // One long line: ~16 bytes per coordinate pair, well over the limit. - const count = Math.ceil(MAX_EXPORT_PAYLOAD_BYTES / 16) + 1000; - const coordinates = Array.from({ length: count }, (_, i) => [ - -105 + i * 1e-5, - 39.7 - ]); - const result = await new ExportMapHtmlTool({ - httpRequest, - packageVersion: () => '9.9.9' - }).run({ - layers: [ - { - id: 'long-line', - type: 'line', - data: { - type: 'Feature', - properties: {}, - geometry: { type: 'LineString', coordinates } - } - } - ] - }); - - expect(result.isError).toBe(true); - expect(textOf(result, 0)).toContain('limit for a standalone page'); - expect(textOf(result, 0)).toContain('simplify_tool'); - expect(result.content).toHaveLength(1); - }); - - it('errors when there is nothing to draw', async () => { - const { httpRequest } = setupHttpRequest(); - const result = await new ExportMapHtmlTool({ httpRequest }).run({}); - expect(result.isError).toBe(true); - expect(textOf(result, 0)).toContain('nothing to draw'); - }); - - it('errors with next steps when refs have expired', async () => { - const { httpRequest } = setupHttpRequest(); - const result = await new ExportMapHtmlTool({ httpRequest }).run({ - payload_refs: ['mapbox://temp/map-payload-does-not-exist'] - }); - expect(result.isError).toBe(true); - expect(textOf(result, 0)).toContain( - 'none of the provided payload_refs could be resolved' - ); - }); - - it('inlines the map script when the package version is unknown', async () => { - const { httpRequest } = setupHttpRequest(); - const result = await new ExportMapHtmlTool({ - httpRequest, - packageVersion: () => '0.0.0' - }).run({ markers }); - - expect(result.isError).toBe(false); - const html = textOf(result, 1); - expect(html).not.toContain('cdn.jsdelivr.net'); - expect(html).toContain('function initMap'); - expect(jsonTag(html, 'initial-data')).toMatchObject({ markers }); - expect(result.structuredContent).toMatchObject({ script_url: 'inline' }); - expect(textOf(result, 0)).toContain('running from source'); - }); - - it('errors when no public token can be found', async () => { - delete process.env.MAPBOX_PUBLIC_TOKEN; - const { httpRequest } = setupHttpRequest(); - const result = await new ExportMapHtmlTool({ httpRequest }).run({ - markers - }); - expect(result.isError).toBe(true); - expect(textOf(result, 0)).toContain('Pass `public_token`'); - }); -}); diff --git a/test/utils/serverInstructions.test.ts b/test/utils/serverInstructions.test.ts index 3457e5a2..95b503d2 100644 --- a/test/utils/serverInstructions.test.ts +++ b/test/utils/serverInstructions.test.ts @@ -25,15 +25,6 @@ describe('buildServerInstructions', () => { expect(text).not.toContain('static_map_image_tool'); }); - it('points page building at export_map_html_tool when it is enabled', () => { - const text = buildServerInstructions(getAllTools().map((t) => t.name)); - expect(text).toContain('building a web page'); - expect(text).toContain('use export_map_html_tool'); - expect( - buildServerInstructions(['render_map_tool', 'isochrone_tool']) - ).not.toContain('export_map_html_tool'); - }); - it('returns no instructions when render_map_tool is disabled', () => { expect( buildServerInstructions(['static_map_image_tool', 'isochrone_tool'])