From 7da00fc4baeea395c77a388281b0db64d7b90d17 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Fri, 13 Nov 2026 14:31:16 +0200 Subject: [PATCH 1/3] feat: in ChatGPT, keep the map open next to the chat and let the model draw on it In ChatGPT, render_map_tool opens its map as a tab next to the chat that stays open: - A thread entrypoint opens an empty map. - The map shares its view and the selected place (a pin, a search result or a Standard base map label) as model context. - WebMCP tools (redraw_map, search_map, add_places, move_map, read_map) let the model change the open map instead of opening another. - The server instructions, tool description and results point the model at those tools. --- CHANGELOG.md | 8 + README.md | 4 + src/resources/ui-apps/MapAppUIResource.ts | 6 + src/resources/ui-apps/mapAppHtml.ts | 464 +++++++++++- src/tools/BaseTool.ts | 46 +- src/tools/render-map-tool/RenderMapTool.ts | 102 ++- src/utils/serverInstructions.ts | 5 +- src/utils/storeMapPayload.ts | 7 +- .../ui-apps/mapAppHtml.script.test.ts | 697 +++++++++++++++++- .../render-map-tool/RenderMapTool.test.ts | 89 ++- test/utils/renderHint.test.ts | 18 + test/utils/serverInstructions.test.ts | 7 + 12 files changed, 1392 insertions(+), 61 deletions(-) create mode 100644 test/utils/renderHint.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b77d74b..691c1a35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,13 @@ ## Unreleased +### Features + +- **ChatGPT: a map that stays open next to the chat (#333).** In ChatGPT, `render_map_tool` opens its map as a tab beside the conversation instead of inline (`preferredModelDisplayMode: "fullscreen"` on the tool, `preferredDisplayMode` on the UI resource), and the map works with the chat while it is open. Hosts without the OpenAI MCP Extensions ignore the `openai/ui` metadata and `document.modelContext`. + - **Open it yourself:** `render_map_tool` is a thread entrypoint (`_meta["openai/ui"].entrypoints: [{ type: "thread" }]`), listed as **Mapbox Map** under "+" → "More tools". Called with `{}`, as an entrypoint is, it opens an empty map. `{}` used to be an error; a call whose refs all fail to resolve still is. The tool is now titled **Mapbox Map** (was "Render Map"), since the title is what the tab and the entrypoint show. + - **Map to chat:** the open map shares its view (center, zoom, bounds) as background context (`ui/update-model-context` `structuredContent`), so "what's around here?" works without naming the place. A place the user selects is a titled item in the composer. A place is a pin, a search result, or a label of the Standard base map: points of interest, place names, landmark icons and indoor labels, the label featuresets Standard offers. A selected label is highlighted (POIs, which have only a `hide` state, are hidden under a selected pin), a second click deselects it, and a click on another moves the selection. ChatGPT clears the context both when a message is sent and when the user removes the item, so the selection stays on the map, and the place isn't attached again until the user selects one. An inline map shares nothing. + - **Chat to map (WebMCP):** the map registers tools through `document.modelContext` that act on the open map, so "show coffee shops on it" no longer opens a second map: `redraw_map` (takes `render_map_tool`'s arguments; the map calls `render_map_tool` itself and redraws in place), `search_map`, `add_places`, `move_map` and `read_map`. ChatGPT offers them in agent mode (Codex) only, not in an Instant chat, and not when several maps are open. + - **Pointing the model at the open map:** the server instructions, the `render_map_tool` description and result, the render hint data tools append, and the map's shared context point the model at these tools. A new map asks the model to call `read_map` once, since the model only started using the map's tools after calling one; in ChatGPT it also says firmly to update this map rather than open another. + ## 0.18.0 - 2026-11-11 ### Features diff --git a/README.md b/README.md index ed3b51cc..7bcb1c9b 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,10 @@ 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. +#### A map next to the chat in ChatGPT + +In ChatGPT, `render_map_tool` maps open as a tab next to the chat and stay open. You can also open an empty map from "+" → "More tools" → **Mapbox Map**. The map shares its view and the selected place (a pin, a search result, or a base map label such as a shop or a neighborhood) with the conversation, and in agent mode it registers WebMCP tools (`redraw_map`, `search_map`, `add_places`, `move_map`, `read_map`) so the model draws on the open map instead of opening another. Hosts without the [OpenAI MCP Extensions](https://developers.openai.com/plugins/build/extensions) ignore all of this. + #### CLIENT_NEEDS_RESOURCE_FALLBACK **Resource Fallback Tools (Opt-In for Non-Compliant Clients)** diff --git a/src/resources/ui-apps/MapAppUIResource.ts b/src/resources/ui-apps/MapAppUIResource.ts index cc20d8fb..906a938e 100644 --- a/src/resources/ui-apps/MapAppUIResource.ts +++ b/src/resources/ui-apps/MapAppUIResource.ts @@ -69,6 +69,12 @@ export class MapAppUIResource extends BaseResource { ui: { csp: mapAppCsp(this.apiEndpoint()), preferredSize: { width: 1000, height: 600 } + }, + // ChatGPT: the map opens as a tab next to the chat, where it + // stays open across turns, rather than inline. + 'openai/ui': { + preferredDisplayMode: 'fullscreen', + availableDisplayModes: ['inline', 'fullscreen'] } } } diff --git a/src/resources/ui-apps/mapAppHtml.ts b/src/resources/ui-apps/mapAppHtml.ts index 527f7858..682cefc9 100644 --- a/src/resources/ui-apps/mapAppHtml.ts +++ b/src/resources/ui-apps/mapAppHtml.ts @@ -236,6 +236,8 @@ ${initialDataScript} // <-> pin) and Place Details enrichment can find a marker by mapbox_id // without re-querying the map for it. var markersById = {}; + // Named markers on the map, for the model (read_map). + var placesOnMap = []; var map = null; var mapLoaded = false; @@ -344,9 +346,13 @@ ${initialDataScript} message.params) { // Partial updates: only the fields that changed are sent. applyHostContext(message.params); + if ('openai/modelContext' in message.params) { + onModelContextChanged(message.params['openai/modelContext']); + } if (message.params.displayMode) { currentDisplayMode = message.params.displayMode; if (map) setTimeout(function() { map.resize(); }, 100); + shareMapContext(); } } }); @@ -402,7 +408,13 @@ ${initialDataScript} function(result) { hostAnswered = true; if (result) applyHostContext(result.hostContext); + if (result && result.hostContext && result.hostContext.displayMode) { + currentDisplayMode = result.hostContext.displayMode; + } + canUpdateModelContext = !!(result && result.hostCapabilities && + result.hostCapabilities.updateModelContext); sendNotification('ui/notifications/initialized', {}); + shareMapContext(); }, function() { hostAnswered = true; @@ -457,6 +469,8 @@ ${initialDataScript} map = new mapboxgl.Map(mapOptions); map.addControl(new mapboxgl.NavigationControl(), 'top-left'); map.addControl(new LayerToggleControl(), 'top-left'); + map.on('moveend', scheduleShareMapContext); + addLabelInteractions(); map.on('load', function() { mapLoaded = true; if (pendingPayload) { @@ -465,6 +479,7 @@ ${initialDataScript} } else { consumeInitialData(); } + shareMapContext(); }); } initMap(); @@ -483,6 +498,13 @@ ${initialDataScript} // structuredContent (e.g. Claude Desktop) never have inline data to find // here, so they fall through to the ref path exactly as before. function handleToolResult(result) { + // An empty map opened from the thread entrypoint stays open next to the + // chat, so it shares context like a fullscreen map. + if (result && result.structuredContent && result.structuredContent.explorer === true && + !explorerMode) { + explorerMode = true; + shareMapContext(); + } var payload = extractInlinePayload(result); if (payload) { stageRender(payload); @@ -518,6 +540,419 @@ ${initialDataScript} showError('Tool result did not contain a map payload.'); } + // --- Model context --------------------------------------------------------- + // A map that stays open next to a chat (an empty map opened from the + // thread entrypoint, or any map expanded into a tab) tells the model what the user is looking at, so "what's + // around here?" works without naming the place. The view is background + // context (structuredContent); a selected place is a chip the user can + // remove from the composer (content). An inline map shares nothing. + var explorerMode = false; + var webMcpTools = false; + var canUpdateModelContext = false; + var shareTimer = null; + var lastSharedContext = ''; + var sharePending = false; + var ownContextUpdateId = null; + // A place whose chip the user removed, until they select another. + var dismissedPlaceKey = null; + var pinnedPlace = null; + + function coord(n) { return Math.round(n * 1e5) / 1e5; } + + function mapView() { + var center = map.getCenter(); + var bounds = map.getBounds(); + return { + center: { longitude: coord(center.lng), latitude: coord(center.lat) }, + zoom: Math.round(map.getZoom() * 10) / 10, + bounds: { + west: coord(bounds.getWest()), + south: coord(bounds.getSouth()), + east: coord(bounds.getEast()), + north: coord(bounds.getNorth()) + } + }; + } + + // The place the user picked last: a plain pin they clicked (pinnedPlace), + // or the active result card. + function selectedPlace() { + if (pinnedPlace) return pinnedPlace; + var entry = activePlaceId ? markersById[activePlaceId] : null; + if (!entry || !entry.name) return null; + var at = entry.marker.getLngLat(); + return { + name: entry.name, + longitude: coord(at.lng), + latitude: coord(at.lat), + mapbox_id: activePlaceId + }; + } + + function placeKey(place) { + return place ? place.mapbox_id || (place.name + '@' + place.longitude + ',' + place.latitude) : null; + } + + // A pin without a result card (add_places, a hand-composed marker) has no + // other way to be selected than a click. + function selectPinnedPlace(place) { + setSelectedLabel(null); + pinnedPlace = place; + dismissedPlaceKey = null; + shareMapContext(); + } + + function clearPinnedPlace() { + setSelectedLabel(null); + pinnedPlace = null; + } + + // A label on the Standard base map (a point of interest, a place name, a + // landmark), selected like a pin so the user can ask about it. The base + // map has no mapbox_id for it, so the model gets its name, category and + // coordinates. Each featureset shows the selection its own way: poi has + // only a hide state, so the label is hidden and drawn again as a selected + // pin; place-labels and indoor-labels have a select state; landmark-icons + // has no state, so a pin goes on top. Road and water labels aren't in any + // featureset, so they can't be picked. + var selectedLabel = null; + var selectedLabelMarker = null; + + function setLabelState(label, on) { + if (!map || label.show === 'pin') return; + var state = {}; + state[label.show] = on; + try { map.setFeatureState(label.feature, state); } catch (_) { /* style switched */ } + } + + function setSelectedLabel(label) { + if (selectedLabel) setLabelState(selectedLabel, false); + if (selectedLabelMarker) selectedLabelMarker.remove(); + selectedLabel = label; + selectedLabelMarker = null; + if (!label || !map) return; + setLabelState(label, true); + if (label.show === 'select') return; + var place = label.place; + var el = buildPlaceElement(placeIcon(label.feature.properties.maki, place.category)); + el.className += ' active'; + var nameEl = document.createElement('span'); + nameEl.className = 'marker-name'; + nameEl.textContent = place.name; + el.appendChild(nameEl); + // A second click on the selected label lands on this pin: deselect it. + // Stopping the click keeps the hidden label under it from being picked + // again. + el.addEventListener('click', function(e) { + if (e && typeof e.stopPropagation === 'function') e.stopPropagation(); + deselectBaseMapLabel(); + }); + selectedLabelMarker = new mapboxgl.Marker({ element: el }) + .setLngLat([place.longitude, place.latitude]) + .addTo(map); + } + + function labelCategory(props) { + var kind = typeof props.maki === 'string' && props.maki !== 'marker' ? props.maki + : [props.class, props.type].filter(function(v) { return typeof v === 'string' && v; })[0] || ''; + return kind.replace(/[-_]/g, ' '); + } + + function selectBaseMapLabel(featuresetId, show, feature) { + var props = (feature && feature.properties) || {}; + var at = feature && feature.geometry && feature.geometry.coordinates; + if (typeof props.name !== 'string' || !props.name || !Array.isArray(at) || + typeof at[0] !== 'number' || typeof at[1] !== 'number') return; + var place = { name: props.name, longitude: coord(at[0]), latitude: coord(at[1]) }; + var category = labelCategory(props); + if (category) place.category = category; + // A second click on a label shown with its select state. + if (selectedLabel && selectedLabel.featuresetId === featuresetId && + placeKey(selectedLabel.place) === placeKey(place)) { + deselectBaseMapLabel(); + return; + } + selectPinnedPlace(place); + setSelectedLabel({ featuresetId: featuresetId, show: show, feature: feature, place: place }); + } + + function deselectBaseMapLabel() { + if (!selectedLabel) return; + clearPinnedPlace(); + shareMapContext(); + } + + function addLabelInteractions() { + if (typeof map.addInteraction !== 'function') return; + // Declared here, not beside the selection state: the map can load, and + // call this, before the script reaches those lines. + var labels = [ + { featuresetId: 'poi', show: 'hide' }, + { featuresetId: 'place-labels', show: 'select' }, + { featuresetId: 'landmark-icons', show: 'pin' }, + { featuresetId: 'indoor-labels', show: 'select' } + ]; + labels.forEach(function(label) { + var target = { featuresetId: label.featuresetId, importId: 'basemap' }; + map.addInteraction('select-base-map-' + label.featuresetId, { + type: 'click', target: target, + handler: function(e) { selectBaseMapLabel(label.featuresetId, label.show, e.feature); } + }); + map.addInteraction('base-map-' + label.featuresetId + '-pointer-in', { + type: 'mouseenter', target: target, + handler: function() { map.getCanvas().style.cursor = 'pointer'; } + }); + map.addInteraction('base-map-' + label.featuresetId + '-pointer-out', { + type: 'mouseleave', target: target, + handler: function() { map.getCanvas().style.cursor = ''; } + }); + }); + } + + function mapContext() { + var content = []; + var place = selectedPlace(); + if (place && placeKey(place) === dismissedPlaceKey) place = null; + if (place) { + content.push({ + type: 'text', + text: 'Place the user selected on the map: ' + place.name + + (place.category ? ', ' + place.category : '') + + ' (longitude ' + place.longitude + ', latitude ' + place.latitude + ')' + + (place.mapbox_id ? ', mapbox_id ' + place.mapbox_id : '') + '.', + _meta: { 'openai/title': place.name } + }); + } + return { + content: content, + structuredContent: { + note: 'The Mapbox map the user has open next to this chat. "Here", "this area" or "the map" means this view.' + + (webMcpTools + ? ' This map registers its own app tools (WebMCP): redraw_map (same arguments as render_map_tool), search_map, add_places, move_map, read_map. Look them up among the tools of the open app and call them to change this map. Do not call render_map_tool for that: it would open another map and leave this one unchanged.' + : ''), + view: mapView() + } + }; + } + + // Each call replaces what this map shared before. + function shareMapContext() { + if (!canUpdateModelContext || !map || !mapLoaded) return; + var staysOpen = explorerMode || currentDisplayMode === 'fullscreen'; + // Back inline: take back what this map shared. + if (!staysOpen && !lastSharedContext) return; + var context = staysOpen ? mapContext() : { content: [] }; + var key = JSON.stringify(context); + if (key === lastSharedContext) return; + lastSharedContext = key; + sharePending = true; + sendRequest('ui/update-model-context', context, 10000).then( + function(result) { + sharePending = false; + var meta = result && result._meta && result._meta['openai/modelContext']; + ownContextUpdateId = meta ? meta.updateId : null; + }, + function() { sharePending = false; lastSharedContext = ''; } + ); + } + + function scheduleShareMapContext() { + if (shareTimer) clearTimeout(shareTimer); + shareTimer = setTimeout(shareMapContext, 400); + } + + // The host reports what is attached now. Anything but our own last update + // without the place in it means the place left the composer: the user + // removed it, or a sent message used it up. + function onModelContextChanged(state) { + if (sharePending) return; + if (state && state.updateId === ownContextUpdateId) return; + if (state && Array.isArray(state.content) && state.content.length > 0) return; + // ChatGPT sends the same null when the user removes the item and when + // a sent message uses it up, so the map keeps its selection either way. + dismissedPlaceKey = placeKey(selectedPlace()); + lastSharedContext = ''; + } + + // --- WebMCP tools ---------------------------------------------------------- + // ChatGPT Desktop lets the model call tools an open app registers, so a + // request like "show coffee shops on it" draws on this map instead of + // opening another one. + // https://github.com/openai/mcp-extensions/blob/main/docs/spec.md#webmcp + function readMap() { + return JSON.stringify({ + view: mapView(), + places: placesOnMap, + selectedPlace: selectedPlace() + }); + } + + function requireMap() { + if (!map || !mapLoaded) throw new Error('The map is not ready yet.'); + } + + function clearMap() { + errorEl.style.display = 'none'; + stageRender({ layers: [], markers: [] }); + } + + function isLngLat(lng, lat) { + return typeof lng === 'number' && typeof lat === 'number' && + lng >= -180 && lng <= 180 && lat >= -90 && lat <= 90; + } + + var MAP_TOOLS = [ + { + name: 'read_map', + description: 'Read the open Mapbox map: its view (center, zoom, bounds), the places drawn on it and the place the user selected.', + inputSchema: { type: 'object', properties: {}, additionalProperties: false }, + annotations: { readOnlyHint: true }, + execute: function() { + requireMap(); + return readMap(); + } + }, + { + name: 'redraw_map', + description: 'Redraw the open Mapbox map with anything render_map_tool can draw: routes, isochrones, search results, GeoJSON layers, markers. Takes exactly the same arguments as render_map_tool (payload_refs from other Mapbox tools, inline layers and markers, summary, baseStyle, baseMapConfig, showTraffic) and draws on this map instead of opening another. Like render_map_tool it replaces what is on the map, so pass again whatever should stay (for example the route ref together with new markers).', + inputSchema: { + type: 'object', + properties: { + payload_refs: { type: 'array', items: { type: 'string' }, description: 'mapboxRender.ref values from other Mapbox tools.' }, + layers: { type: 'array', items: { type: 'object' } }, + markers: { type: 'array', items: { type: 'object' } }, + summary: { type: 'string' } + }, + additionalProperties: true + }, + execute: function(input) { + requireMap(); + var args = input && typeof input === 'object' ? input : {}; + if (Object.keys(args).length === 0) { + throw new Error('Pass payload_refs or inline layers/markers, as for render_map_tool.'); + } + // The server resolves refs and validates, exactly as for a call + // from the model; only where the result is drawn differs. + return sendRequest('tools/call', { name: 'render_map_tool', arguments: args }, 30000) + .then(function(result) { + var first = result && Array.isArray(result.content) ? result.content[0] : null; + var text = first && typeof first.text === 'string' ? first.text : ''; + if (!result || result.isError) throw new Error(text || 'render_map_tool failed.'); + errorEl.style.display = 'none'; + handleToolResult(result); + return text + ' Drawn on the open map.'; + }); + } + }, + { + name: 'search_map', + description: 'Search for places (a name, address or kind of place, such as "coffee") near the center of the open Mapbox map and draw the results on it. Use this instead of opening a new map when the user asks to show places on the map they already have open.', + inputSchema: { + type: 'object', + properties: { + query: { type: 'string', description: 'What to search for.' }, + clear: { type: 'boolean', description: 'Remove what is on the map first. Default false.' } + }, + required: ['query'], + additionalProperties: false + }, + execute: function(input) { + requireMap(); + var q = input && typeof input.query === 'string' ? input.query.trim() : ''; + if (!q) throw new Error('query is required.'); + if (input.clear === true) clearMap(); + var center = map.getCenter(); + return selfFetchSearch({ + q: q, + proximity: { longitude: center.lng, latitude: center.lat } + }).then(function(found) { + if (!found) throw new Error('No results for "' + q + '" near the map center.'); + return readMap(); + }); + } + }, + { + name: 'add_places', + description: 'Draw pins on the open Mapbox map at known coordinates and fit the view to them. Use this instead of opening a new map when the user asks to mark places on the map they already have open.', + inputSchema: { + type: 'object', + properties: { + places: { + type: 'array', + minItems: 1, + maxItems: 50, + items: { + type: 'object', + properties: { + name: { type: 'string' }, + longitude: { type: 'number' }, + latitude: { type: 'number' } + }, + required: ['name', 'longitude', 'latitude'], + additionalProperties: false + } + }, + clear: { type: 'boolean', description: 'Remove what is on the map first. Default false.' } + }, + required: ['places'], + additionalProperties: false + }, + execute: function(input) { + requireMap(); + var places = input && Array.isArray(input.places) ? input.places.slice(0, 50) : []; + var markers = places.map(function(p) { + if (!p || !isLngLat(p.longitude, p.latitude)) { + throw new Error('Each place needs a longitude (-180 to 180) and a latitude (-90 to 90).'); + } + var name = typeof p.name === 'string' ? p.name : ''; + return { coordinates: [p.longitude, p.latitude], name: name, popup: name }; + }); + if (markers.length === 0) throw new Error('places is required.'); + if (input.clear === true) clearMap(); + mergeAdditionalPayload({ layers: [], markers: markers }); + return readMap(); + } + }, + { + name: 'move_map', + description: 'Move the open Mapbox map to a location.', + inputSchema: { + type: 'object', + properties: { + longitude: { type: 'number' }, + latitude: { type: 'number' }, + zoom: { type: 'number', minimum: 0, maximum: 22, description: 'Default 12.' } + }, + required: ['longitude', 'latitude'], + additionalProperties: false + }, + execute: function(input) { + requireMap(); + if (!input || !isLngLat(input.longitude, input.latitude)) { + throw new Error('longitude (-180 to 180) and latitude (-90 to 90) are required.'); + } + var zoom = typeof input.zoom === 'number' ? Math.max(0, Math.min(22, input.zoom)) : 12; + map.flyTo({ center: [input.longitude, input.latitude], zoom: zoom, duration: 600 }); + return JSON.stringify({ + center: { longitude: input.longitude, latitude: input.latitude }, + zoom: zoom + }); + } + } + ]; + + if (document.modelContext && typeof document.modelContext.registerTool === 'function') { + webMcpTools = true; + MAP_TOOLS.forEach(function(tool) { + var run = tool.execute; + // A promise either way, so a thrown error reaches the model as a rejection. + tool.execute = function(input) { + return Promise.resolve(input).then(run); + }; + try { document.modelContext.registerTool(tool); } catch (_) { /* already registered */ } + }); + } + function readResourceExpiryMessage(rr) { if (!rr || !Array.isArray(rr.contents) || rr.contents.length === 0) return null; var first = rr.contents[0]; @@ -618,6 +1053,8 @@ ${initialDataScript} // night mode) when the Satellite button switches style. currentBaseMapConfig = null; markersById = {}; + placesOnMap = []; + clearPinnedPlace(); carouselEl.style.display = 'none'; legendsEl.style.bottom = ''; clearPanel(); @@ -992,12 +1429,21 @@ ${initialDataScript} if (m.popup && !m.id) marker.setPopup(new mapboxgl.Popup().setText(m.popup)); marker.addTo(map); trackedMarkers.push(marker); + if (m.name) { + var place = { name: m.name, longitude: m.coordinates[0], latitude: m.coordinates[1] }; + if (m.id) place.mapbox_id = m.id; + placesOnMap.push(place); + var pinEl = !m.id && typeof marker.getElement === 'function' ? marker.getElement() : null; + if (pinEl && typeof pinEl.addEventListener === 'function') { + pinEl.addEventListener('click', function() { selectPinnedPlace(place); }); + } + } // Tagged by buildSearchMiniPayload with the result's mapbox_id so the // carousel and Place Details enrichment can look the marker back up // later without walking trackedMarkers. if (m.id) { var badgeEl = typeof marker.getElement === 'function' ? marker.getElement() : null; - markersById[m.id] = { marker: marker, el: badgeEl, color: color }; + markersById[m.id] = { marker: marker, el: badgeEl, color: color, name: m.name }; if (badgeEl && m.name) { var nameEl = document.createElement('span'); nameEl.className = 'marker-name'; @@ -2064,6 +2510,13 @@ ${initialDataScript} setActiveClass(pin.el, true); pin.el.style.zIndex = '2'; } + clearPinnedPlace(); + dismissedPlaceKey = null; + shareMapContext(); + } else if (pinnedPlace) { + // Back to the card that was already active. + clearPinnedPlace(); + shareMapContext(); } if (opts.scrollCard && typeof row.li.scrollIntoView === 'function') { row.li.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' }); @@ -2490,13 +2943,14 @@ ${initialDataScript} return params.proximityFromResults ? undefined : params.proximity; } + // Resolves to how many results were drawn. function selfFetchSearch(params) { if (!TOKEN || !isSafeSearchParams(params)) { showError('Could not fetch search results: missing token or invalid parameters.'); - return; + return Promise.resolve(0); } var url = buildSearchAndGeocodeApiUrl(params, TOKEN, API_ENDPOINT); - fetch(url) + return fetch(url) .then(function(res) { if (!res.ok) { return res @@ -2524,14 +2978,16 @@ ${initialDataScript} var mini = buildSearchMiniPayload(fc, params.q, searchCenter(params)); if (!mini) { showError('Search API returned no results.'); - return; + return 0; } mergeAdditionalPayload(mini); + return mini.markers.filter(function(m) { return m.style === 'place'; }).length; }) .catch(function(err) { showError( 'Could not fetch search results: ' + (err && err.message ? err.message : err) ); + return 0; }); } diff --git a/src/tools/BaseTool.ts b/src/tools/BaseTool.ts index d8dc0475..a4491e6f 100644 --- a/src/tools/BaseTool.ts +++ b/src/tools/BaseTool.ts @@ -13,6 +13,28 @@ import { lenientOutputSchema } from './lenientOutputSchema.js'; import type { ZodTypeAny } from 'zod'; import type { z } from 'zod'; +export type ToolMeta = { + ui?: { + resourceUri?: string; + csp?: { + connectDomains?: string[]; + resourceDomains?: string[]; + frameDomains?: string[]; + workerDomains?: string[]; + }; + }; + /** + * OpenAI MCP Extensions: lets users open the app in a conversation's side + * panel (`thread`). + * https://github.com/openai/mcp-extensions/blob/main/docs/spec.md#mcp-app-entrypoints + */ + 'openai/ui'?: { + entrypoints: Array<{ type: 'thread' }>; + /** How a call from the model opens the app. In the SDK, not the spec. */ + preferredModelDisplayMode?: 'inline' | 'fullscreen'; + }; +}; + export abstract class BaseTool< InputSchema extends ZodTypeAny, OutputSchema extends ZodTypeAny = ZodTypeAny @@ -23,17 +45,7 @@ export abstract class BaseTool< readonly inputSchema: InputSchema; readonly outputSchema?: OutputSchema; - readonly meta?: { - ui?: { - resourceUri?: string; - csp?: { - connectDomains?: string[]; - resourceDomains?: string[]; - frameDomains?: string[]; - workerDomains?: string[]; - }; - }; - }; + readonly meta?: ToolMeta; /** * The most recently installed server. Used as a fallback when `run()` is * called directly rather than through a callback registered by `installTo()`. @@ -80,17 +92,7 @@ export abstract class BaseTool< // eslint-disable-next-line @typescript-eslint/no-explicit-any outputSchema?: any; annotations?: ToolAnnotations; - _meta?: { - ui?: { - resourceUri?: string; - csp?: { - connectDomains?: string[]; - resourceDomains?: string[]; - frameDomains?: string[]; - workerDomains?: string[]; - }; - }; - }; + _meta?: ToolMeta; } = { title: this.annotations.title, description: this.description, diff --git a/src/tools/render-map-tool/RenderMapTool.ts b/src/tools/render-map-tool/RenderMapTool.ts index fd3e17b2..a7edb62b 100644 --- a/src/tools/render-map-tool/RenderMapTool.ts +++ b/src/tools/render-map-tool/RenderMapTool.ts @@ -7,11 +7,8 @@ import type { ServerContext } from '@modelcontextprotocol/server'; import { createLocalToolExecutionContext } from '../../utils/tracing.js'; -import { BaseTool } from '../BaseTool.js'; -import { - mapAppCsp, - type MapAppCsp -} from '../../resources/ui-apps/mapAppCsp.js'; +import { BaseTool, type ToolMeta } from '../BaseTool.js'; +import { mapAppCsp } from '../../resources/ui-apps/mapAppCsp.js'; import { RenderMapInputSchema, type RenderMapInput @@ -54,14 +51,54 @@ import type { HttpRequest } from '../../utils/types.js'; * coordinate pairs as tool input — keeping latency in the * hundreds-of-millseconds range instead of 20-30s. */ +const OPEN_MAP_HINT = + 'A new map just opened. Before answering, call read_map on the open map ' + + 'once, if that tool is available, so its tools are ready for follow-ups. ' + + 'Where the host keeps this map open next to the chat, follow-up requests ' + + '(add a route, show more places, mark a place, move it) should change ' + + 'this map instead of opening another: it registers its own app tools ' + + '(WebMCP) redraw_map, search_map, add_places, move_map and read_map. ' + + 'redraw_map takes the same arguments as render_map_tool and draws on the ' + + 'open map. Look them up among the tools of the open app and call them. ' + + 'Call render_map_tool again only if those tools are not available.'; + +// ChatGPT opens the map as a tab that stays open, so there the next call +// decides between one map and a pile of tabs. Said firmly, because the softer +// wording above still got a second render_map_tool call about half the time. +const OPEN_MAP_HINT_CHATGPT = + 'IMPORTANT: this map is now open in a tab next to the chat and stays open. ' + + 'Before answering, call read_map on the open map once, if that tool is ' + + 'available, so its tools are ready for follow-ups. ' + + 'Every later map request in this conversation (a route, more places, a ' + + 'marker, a different area, a style change) must update THIS map: call the ' + + "open map's own app tool redraw_map, which takes exactly the arguments of " + + 'render_map_tool, or search_map / add_places / move_map. These are WebMCP ' + + 'tools registered by the open app; look them up among its tools. Calling ' + + 'render_map_tool again opens a duplicate tab, so do it only after you have ' + + 'looked for those tools and they do not exist.'; + +/** ChatGPT tags every request with `openai/*` metadata. */ +function isChatGpt(extra?: ServerContext): boolean { + const meta = extra?.mcpReq?._meta; + return ( + !!meta && + typeof meta === 'object' && + Object.keys(meta).some((key) => key.startsWith('openai/')) + ); +} + export class RenderMapTool extends BaseTool< typeof RenderMapInputSchema, typeof RenderMapOutputSchema > { readonly name = 'render_map_tool'; readonly description = - 'Display a live, interactive Mapbox GL JS map. ' + - 'Use this when someone wants to see a location, route, or dataset on a map. ' + + 'Open a live, interactive Mapbox GL JS map. Each call opens a NEW map, so ' + + 'check first whether a Mapbox map is already open: an open map registers its ' + + 'own app tools (redraw_map, search_map, add_places, move_map), and redraw_map ' + + 'takes the same arguments as this tool and draws on the open map. Call this ' + + 'tool when no map is open yet and someone wants to see a location, route, ' + + 'or dataset on a map. ' + 'Preferred usage: any other Mapbox tool returns a `mapboxRender.ref` URI in ' + 'its structuredContent — pass that ref via `payload_refs: ["..."]`. ' + 'You can pass multiple refs to merge several datasets (e.g. a search ' + @@ -90,14 +127,14 @@ export class RenderMapTool extends BaseTool< 'number or address) instead.'; readonly annotations = { - title: 'Render Map', + title: 'Mapbox Map', readOnlyHint: true, destructiveHint: false, idempotentHint: true, openWorldHint: false }; - readonly meta: { ui: { resourceUri: string; csp: MapAppCsp } }; + readonly meta: ToolMeta; private readonly httpRequest: HttpRequest; private readonly apiEndpoint: () => string; @@ -118,6 +155,13 @@ export class RenderMapTool extends BaseTool< ui: { resourceUri: 'ui://mapbox/map-app/index.html', csp: mapAppCsp(this.apiEndpoint()) + }, + 'openai/ui': { + // Users open it with {} as a tab next to a chat. + entrypoints: [{ type: 'thread' }], + // A map the model draws opens there too, so it stays open across + // turns and later requests can draw on it. + preferredModelDisplayMode: 'fullscreen' } }; } @@ -137,10 +181,14 @@ export class RenderMapTool extends BaseTool< try { const input = RenderMapInputSchema.parse(rawInput); - const { payload, unresolvedRefs } = this.assemblePayload( - input, - owner - ); + const assembled = this.assemblePayload(input, owner); + const { unresolvedRefs } = assembled; + // Nothing asked for at all: ChatGPT opening the map from its + // thread entrypoint, which calls with {}. Open an empty map. + const explorer = !assembled.payload && unresolvedRefs.length === 0; + const payload = explorer + ? { layers: [], markers: [] } + : assembled.payload; // A summary/legend alone counts as renderable, so without this a // call whose only data ref failed would still "succeed" and draw @@ -202,14 +250,16 @@ export class RenderMapTool extends BaseTool< ? ' Updated the base map style.' : ''; - const text = - `Rendered map with ${layerCount} layer${layerCount === 1 ? '' : 's'}` + - ` and ${markerCount} marker${markerCount === 1 ? '' : 's'}` + - (payload.summary ? ` — ${payload.summary}` : '') + - '.' + - baseMapConfigNote + - selfFetchNote + - staleNote; + const text = explorer + ? 'Opened an empty Mapbox map for the user to explore. ' + + 'To show data on a map, pass `payload_refs` or inline `layers`/`markers`.' + : `Rendered map with ${layerCount} layer${layerCount === 1 ? '' : 's'}` + + ` and ${markerCount} marker${markerCount === 1 ? '' : 's'}` + + (payload.summary ? ` — ${payload.summary}` : '') + + '.' + + baseMapConfigNote + + selfFetchNote + + staleNote; // Claude Desktop strips structuredContent from MCP App iframe // postMessages and replaces large content[] payloads with @@ -240,6 +290,15 @@ export class RenderMapTool extends BaseTool< const content: CallToolResult['content'] = [ { type: 'text' as const, text }, + // The model reads this right after drawing, which is when it + // decides how to handle the next request. Hosts without app + // tools fall through to the last sentence. The map page's + // redraw_map relays only content[0], so this never reaches the + // model on a redraw. + { + type: 'text' as const, + text: isChatGpt(extra) ? OPEN_MAP_HINT_CHATGPT : OPEN_MAP_HINT + }, { type: 'text' as const, text: `[[MAPBOX_RENDER_REF]] ${mergedRef}` @@ -263,6 +322,7 @@ export class RenderMapTool extends BaseTool< layer_count: layerCount, marker_count: markerCount, summary: payload.summary, + ...(explorer ? { explorer: true } : {}), mapboxRender }, ...(tooBigToInline ? { _meta: { ui: { payload } } } : {}), diff --git a/src/utils/serverInstructions.ts b/src/utils/serverInstructions.ts index 47a0d12a..610a41c3 100644 --- a/src/utils/serverInstructions.ts +++ b/src/utils/serverInstructions.ts @@ -16,8 +16,9 @@ export function buildServerInstructions( ): string | undefined { if (!enabledToolNames.includes('render_map_tool')) return undefined; const lines = [ - '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.' + 'To show anything on a map, first check whether a Mapbox map is already open in this conversation: an open map registers its own app tools (redraw_map, search_map, add_places, move_map, read_map). If they exist, update that map with them. redraw_map takes the same arguments as render_map_tool; render_map_tool would open a second map and leave the open one unchanged.', + 'Otherwise use render_map_tool: it renders an interactive Mapbox GL JS map.', + 'When a tool result includes mapboxRender.ref, pass that ref unchanged in payload_refs (several refs can share one map). Never write or edit a ref by hand.' ]; if (enabledToolNames.includes('static_map_image_tool')) { lines.push( diff --git a/src/utils/storeMapPayload.ts b/src/utils/storeMapPayload.ts index 797a1650..366b6e9f 100644 --- a/src/utils/storeMapPayload.ts +++ b/src/utils/storeMapPayload.ts @@ -24,7 +24,7 @@ export const MapAppRefSchema = z ref: z .string() .describe( - 'Server-side payload reference. Pass to `render_map_tool` via `payload_refs: [""]` to display the data on a live Mapbox GL JS map.' + 'Server-side payload reference. Pass to `render_map_tool` via `payload_refs: [""]` to display the data on a live Mapbox GL JS map, or to the `redraw_map` tool of a Mapbox map that is already open.' ) }) .describe( @@ -104,7 +104,10 @@ export function buildMapboxRenderField( export function renderHint(ref: string): string { return ( `\n\nIf the user wants to see this on a map, pass this ref unchanged to render_map_tool (not static_map_image_tool):\n` + - ` render_map_tool({ "payload_refs": ["${ref}"] })` + ` render_map_tool({ "payload_refs": ["${ref}"] })\n` + + // In ChatGPT the map stays open next to the chat; a second + // render_map_tool call would open a second one beside it. + `If a Mapbox map is already open and offers its own redraw_map tool, call that with the same arguments instead, so the open map is updated rather than a second one opened.` ); } diff --git a/test/resources/ui-apps/mapAppHtml.script.test.ts b/test/resources/ui-apps/mapAppHtml.script.test.ts index 5497b221..a301b5df 100644 --- a/test/resources/ui-apps/mapAppHtml.script.test.ts +++ b/test/resources/ui-apps/mapAppHtml.script.test.ts @@ -1,7 +1,7 @@ // Copyright (c) Mapbox, Inc. // Licensed under the MIT License. -import { describe, it, expect, vi } from 'vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; import * as vm from 'node:vm'; import { createHash } from 'node:crypto'; import { renderMapAppHtml } from '../../../src/resources/ui-apps/mapAppHtml.js'; @@ -31,6 +31,14 @@ const mockPoiId = (suffix: string) => { * bridge doesn't support at all), while a ref-only result (Claude * Desktop, which strips structuredContent) still falls back to it. */ +type WebMcpTool = { + name: string; + description: string; + inputSchema: Record; + annotations?: { readOnlyHint?: boolean }; + execute: (input: unknown) => Promise; +}; + function loadScriptSandbox(options?: { initialData?: unknown; /** A page opened directly (export_map_html_tool), not inside a host. */ @@ -39,6 +47,10 @@ function loadScriptSandbox(options?: { noHost?: boolean; /** hostContext the host answers ui/initialize with. */ hostContext?: Record; + /** hostCapabilities the host answers ui/initialize with. */ + hostCapabilities?: Record; + /** The host exposes WebMCP (document.modelContext). */ + webMcp?: boolean; /** The system's prefers-color-scheme: dark, when set. */ systemDark?: boolean; }) { @@ -88,11 +100,14 @@ function loadScriptSandbox(options?: { get firstChild() { return children[0]; }, + value: '', + focus: () => {}, addEventListener: (type: string, cb: (e?: unknown) => void) => { (listeners[type] ??= []).push(cb); }, __fireClick: () => (listeners.click ?? []).forEach((cb) => cb({})), - __fire: (type: string) => (listeners[type] ?? []).forEach((cb) => cb({})) + __fire: (type: string, event?: unknown) => + (listeners[type] ?? []).forEach((cb) => cb(event ?? {})) }; return el; } @@ -107,9 +122,11 @@ function loadScriptSandbox(options?: { appendChild: (child: FakeElement) => FakeElement; removeChild: (child: FakeElement) => FakeElement; readonly firstChild: FakeElement | undefined; + value: string; + focus: () => void; addEventListener: (type: string, cb: (e?: unknown) => void) => void; __fireClick: () => void; - __fire: (type: string) => void; + __fire: (type: string, event?: unknown) => void; scrollLeft?: number; clientWidth?: number; offsetLeft?: number; @@ -150,7 +167,27 @@ function loadScriptSandbox(options?: { // Like a browser's ResizeObserver: the test calls resizeObserved() where // the browser would report a new size for an observed element. const resizeCallbacks: Array<{ target: unknown; cb: () => void }> = []; + const mapHandlers: Record void>> = {}; + const markerElements: FakeElement[] = []; + const webMcpTools: Record = {}; + const flyToCalls: Array> = []; + const mapView = { lng: 24.94, lat: 60.17, zoom: 12.04 }; + const interactions: Record< + string, + { handler: (e: { feature: unknown }) => void } + > = {}; + const featureStates: Array<{ feature: unknown; state: unknown }> = []; const fakeMapInstance = { + addInteraction: ( + id: string, + options: { handler: (e: { feature: unknown }) => void } + ) => { + interactions[id] = options; + }, + setFeatureState: (feature: unknown, state: unknown) => { + featureStates.push({ feature, state }); + }, + getCanvas: () => ({ style: {} }), // GL JS calls a control's onAdd right away and mounts what it returns. addControl: (control: { onAdd?: (map: unknown) => FakeElement }) => { if (control && typeof control.onAdd === 'function') { @@ -163,6 +200,7 @@ function loadScriptSandbox(options?: { }), on: (event: string, cb: () => void) => { if (event === 'load') cb(); + else (mapHandlers[event] ??= []).push(cb); }, // Mirrors GL JS's setStyle(...) followed by a 'style.load' event once // the new style is ready -- fired synchronously here since the test @@ -195,8 +233,18 @@ function loadScriptSandbox(options?: { ) => { fitBoundsCalls.push(opts); }, - flyTo: () => {}, - resize: () => {} + flyTo: (opts: Record) => { + flyToCalls.push(opts); + }, + resize: () => {}, + getCenter: () => ({ lng: mapView.lng, lat: mapView.lat }), + getZoom: () => mapView.zoom, + getBounds: () => ({ + getWest: () => mapView.lng - 0.1, + getSouth: () => mapView.lat - 0.05, + getEast: () => mapView.lng + 0.1, + getNorth: () => mapView.lat + 0.05 + }) }; // Every `new mapboxgl.Marker()` call below returns this same shared @@ -209,8 +257,11 @@ function loadScriptSandbox(options?: { setPopup: () => fakeMarkerInstance, getLngLat: () => ({ lng: 1, lat: 2 }), getElement: () => lastMarkerElement, - remove: () => {} + remove: () => { + markerRemoveCount.count++; + } }; + const markerRemoveCount = { count: 0 }; // The element passed to the most recent `new mapboxgl.Marker()`, which // getElement() returns: the script reads it right after constructing. let lastMarkerElement: unknown; @@ -254,7 +305,15 @@ function loadScriptSandbox(options?: { document: { getElementById: getElementById, createElement: () => fakeElement(), - documentElement: rootEl + documentElement: rootEl, + body: { classList: { add: () => {} } }, + ...(options?.webMcp && { + modelContext: { + registerTool: (tool: WebMcpTool) => { + webMcpTools[tool.name] = tool; + } + } + }) }, mapboxgl: { accessToken: '', @@ -265,7 +324,9 @@ function loadScriptSandbox(options?: { NavigationControl: function NavigationControl() {}, Marker: function Marker(options?: Record) { markerConstructorCalls.push(options ?? {}); - lastMarkerElement = options?.element; + // GL JS builds a default pin's element itself. + lastMarkerElement = options?.element ?? fakeElement(); + markerElements.push(lastMarkerElement as FakeElement); return fakeMarkerInstance; }, Popup: function Popup() { @@ -285,6 +346,7 @@ function loadScriptSandbox(options?: { }, console, setTimeout, + clearTimeout, URLSearchParams, atob, fetch: (url: string, init?: unknown) => fetchImpl(url, init) @@ -325,7 +387,12 @@ function loadScriptSandbox(options?: { postToApp({ jsonrpc: '2.0', id: initId, - result: options?.hostContext ? { hostContext: options.hostContext } : {} + result: { + ...(options?.hostContext && { hostContext: options.hostContext }), + ...(options?.hostCapabilities && { + hostCapabilities: options.hostCapabilities + }) + } }); } @@ -365,6 +432,29 @@ function loadScriptSandbox(options?: { }); }, postMessageCalls, + /** Moves the map and fires 'moveend', as a pan or zoom would. */ + moveMap: (view: Partial) => { + Object.assign(mapView, view); + (mapHandlers.moveend ?? []).forEach((cb) => cb()); + }, + webMcpTools, + flyToCalls, + /** Clicks a point of interest on the Standard base map. */ + clickBaseMapPoi: (feature: unknown) => + interactions['select-base-map-poi'].handler({ feature }), + /** Clicks a label of the given Standard featureset. */ + clickBaseMapLabel: (featuresetId: string, feature: unknown) => + interactions['select-base-map-' + featuresetId].handler({ feature }), + featureStates, + markerRemoveCount, + /** Each marker's element, in the order the markers were created. */ + markerElements, + /** Answers a request the app sent to the host. */ + respond: (id: unknown, result: unknown) => { + postToApp({ jsonrpc: '2.0', id, result }); + }, + selectPlace: (sandbox.window as { __selectPlace?: (id: string) => void }) + .__selectPlace as (id: string) => void, map: fakeMapInstance, marker: fakeMarkerInstance, mapConstructorCalls, @@ -1882,6 +1972,595 @@ describe('mapAppHtml search self-fetch', () => { }); }); +describe('mapAppHtml explorer model context', () => { + const explorerResult = { + structuredContent: { + explorer: true, + mapboxRender: { layers: [], markers: [] } + } + }; + const canShare = { hostCapabilities: { updateModelContext: { text: {} } } }; + + type SharedContext = { + content: Array<{ text: string; _meta?: Record }>; + structuredContent: { + view: { + center: { longitude: number; latitude: number }; + zoom: number; + bounds: Record; + }; + }; + }; + const sharedContexts = (calls: Array>) => + calls + .filter((m) => m.method === 'ui/update-model-context') + .map((m) => m.params as SharedContext); + + // The app reads hostCapabilities in a promise callback. + const settle = async () => { + for (let i = 0; i < 4; i++) await Promise.resolve(); + }; + + afterEach(() => { + vi.useRealTimers(); + }); + + it('shares the view as background context when the explorer opens', async () => { + const { sendToolResult, postMessageCalls } = loadScriptSandbox(canShare); + await settle(); + sendToolResult(explorerResult); + + const shared = sharedContexts(postMessageCalls); + expect(shared).toHaveLength(1); + // Nothing in content, so no chip in the composer. + expect(shared[0].content).toEqual([]); + expect(shared[0].structuredContent.view).toEqual({ + center: { longitude: 24.94, latitude: 60.17 }, + zoom: 12, + bounds: { west: 24.84, south: 60.12, east: 25.04, north: 60.22 } + }); + }); + + it('shares the new view once the map stops moving', async () => { + vi.useFakeTimers(); + const { sendToolResult, postMessageCalls, moveMap } = + loadScriptSandbox(canShare); + await settle(); + sendToolResult(explorerResult); + + moveMap({ lng: 2.35 }); + moveMap({ lng: 2.35, lat: 48.86 }); + expect(sharedContexts(postMessageCalls)).toHaveLength(1); + vi.advanceTimersByTime(400); + + const shared = sharedContexts(postMessageCalls); + expect(shared).toHaveLength(2); + expect(shared[1].structuredContent.view.center).toEqual({ + longitude: 2.35, + latitude: 48.86 + }); + + // A move that ends on the same view isn't sent again. + moveMap({}); + vi.advanceTimersByTime(400); + expect(sharedContexts(postMessageCalls)).toHaveLength(2); + }); + + it('stays quiet outside the explorer and on hosts without model context', async () => { + const plain = loadScriptSandbox(canShare); + await settle(); + plain.sendToolResult({ + structuredContent: { mapboxRender: { layers: [], markers: [] } } + }); + expect(sharedContexts(plain.postMessageCalls)).toHaveLength(0); + + const noCapability = loadScriptSandbox(); + await settle(); + noCapability.sendToolResult(explorerResult); + expect(sharedContexts(noCapability.postMessageCalls)).toHaveLength(0); + }); + + it('shares from a map expanded into a tab, and takes it back when inline again', async () => { + const { sendToolResult, postMessageCalls, sendHostContext } = + loadScriptSandbox({ ...canShare, webMcp: true }); + await settle(); + sendToolResult({ + structuredContent: { mapboxRender: { layers: [], markers: [] } } + }); + expect(sharedContexts(postMessageCalls)).toHaveLength(0); + + sendHostContext({ displayMode: 'fullscreen' }); + let shared = sharedContexts(postMessageCalls); + expect(shared).toHaveLength(1); + expect(shared[0].structuredContent.view.center).toEqual({ + longitude: 24.94, + latitude: 60.17 + }); + // Points the model at the open map's tools. + expect(JSON.stringify(shared[0].structuredContent)).toContain('search_map'); + + sendHostContext({ displayMode: 'inline' }); + shared = sharedContexts(postMessageCalls); + expect(shared).toHaveLength(2); + expect(shared[1]).toEqual({ content: [] }); + }); + + it('adds the selected place as a titled item, until the user removes it', async () => { + vi.useFakeTimers(); + const sandbox = loadScriptSandbox(canShare); + const { sendToolResult, postMessageCalls, sendHostContext, selectPlace } = + sandbox; + // Answers the latest update the way ChatGPT does, with its updateId. + const acknowledge = async (updateId: string) => { + const call = [...postMessageCalls] + .reverse() + .find((m) => m.method === 'ui/update-model-context'); + sandbox.respond(call?.id, { + _meta: { 'openai/modelContext': { updateId } } + }); + await settle(); + }; + const last = () => sharedContexts(postMessageCalls).slice(-1)[0]; + await settle(); + sendToolResult({ + structuredContent: { + explorer: true, + mapboxRender: { + layers: [], + markers: [ + { id: 'id-1', name: 'Café Regatta', coordinates: [24.91, 60.18] }, + { id: 'id-2', name: 'Löyly', coordinates: [24.93, 60.15] } + ] + } + } + }); + await acknowledge('update-1'); + + selectPlace('id-1'); + expect(last().content).toHaveLength(1); + expect(last().content[0]._meta).toEqual({ 'openai/title': 'Café Regatta' }); + expect(last().content[0].text).toContain('mapbox_id id-1'); + await acknowledge('update-2'); + + // The host echoing our own update isn't a removal. + sendHostContext({ + 'openai/modelContext': { updateId: 'update-2', content: [{}] } + }); + sandbox.moveMap({ lng: 24.95 }); + vi.advanceTimersByTime(400); + expect(last().content).toHaveLength(1); + await acknowledge('update-3'); + + // The user removed the chip: later updates leave the place out. + sendHostContext({ + 'openai/modelContext': { updateId: 'host-1', content: [] } + }); + sandbox.moveMap({ lng: 24.96 }); + vi.advanceTimersByTime(400); + expect(last().content).toEqual([]); + await acknowledge('update-4'); + + selectPlace('id-2'); + expect(last().content[0]._meta).toEqual({ 'openai/title': 'Löyly' }); + }); +}); + +describe('mapAppHtml WebMCP tools', () => { + const emptyMap = { + structuredContent: { mapboxRender: { layers: [], markers: [] } } + }; + + it('registers nothing on a host without document.modelContext', () => { + expect(loadScriptSandbox().webMcpTools).toEqual({}); + }); + + it('registers the map tools, with only read_map read-only', () => { + const { webMcpTools } = loadScriptSandbox({ webMcp: true }); + expect(Object.keys(webMcpTools).sort()).toEqual([ + 'add_places', + 'move_map', + 'read_map', + 'redraw_map', + 'search_map' + ]); + expect(webMcpTools.read_map.annotations).toEqual({ readOnlyHint: true }); + expect(webMcpTools.add_places.annotations).toBeUndefined(); + }); + + it('add_places draws pins on the open map and read_map lists them', async () => { + const { webMcpTools, sendToolResult, markerConstructorCalls } = + loadScriptSandbox({ webMcp: true }); + sendToolResult({ + structuredContent: { + mapboxRender: { + layers: [], + markers: [{ name: 'Helsinki', coordinates: [24.94, 60.17] }] + } + } + }); + const before = markerConstructorCalls.length; + + const added = JSON.parse( + await webMcpTools.add_places.execute({ + places: [{ name: 'Löyly', longitude: 24.93, latitude: 60.15 }] + }) + ); + // Added to what was there, not a fresh map. + expect(markerConstructorCalls.length).toBe(before + 1); + expect(added.places).toEqual([ + { name: 'Helsinki', longitude: 24.94, latitude: 60.17 }, + { name: 'Löyly', longitude: 24.93, latitude: 60.15 } + ]); + + const cleared = JSON.parse( + await webMcpTools.add_places.execute({ + places: [{ name: 'Espoo', longitude: 24.66, latitude: 60.21 }], + clear: true + }) + ); + expect(cleared.places).toEqual([ + { name: 'Espoo', longitude: 24.66, latitude: 60.21 } + ]); + expect(JSON.parse(await webMcpTools.read_map.execute({}))).toEqual(cleared); + }); + + it('redraw_map has the server render and draws the result on the open map', async () => { + const sandbox = loadScriptSandbox({ webMcp: true }); + const { webMcpTools, sendToolResult, postMessageCalls } = sandbox; + sendToolResult({ + structuredContent: { + mapboxRender: { + layers: [], + markers: [{ name: 'Helsinki', coordinates: [24.94, 60.17] }] + } + } + }); + + const args = { payload_refs: ['mapbox://inline/route'], summary: 'Route' }; + const pending = webMcpTools.redraw_map.execute(args); + await Promise.resolve(); + const call = postMessageCalls.find((m) => m.method === 'tools/call'); + // Same arguments, passed through to the server tool. + expect(call?.params).toEqual({ name: 'render_map_tool', arguments: args }); + + sandbox.respond(call?.id, { + content: [ + { type: 'text', text: 'Rendered map with 0 layers and 1 marker.' } + ], + structuredContent: { + mapboxRender: { + summary: 'Route', + layers: [], + markers: [{ name: 'Airport', coordinates: [24.96, 60.32] }] + } + } + }); + expect(await pending).toContain('Drawn on the open map'); + // Replaces what was there, as render_map_tool does. + expect(JSON.parse(await webMcpTools.read_map.execute({})).places).toEqual([ + { name: 'Airport', longitude: 24.96, latitude: 60.32 } + ]); + expect(sandbox.summaryEl?.textContent).toBe('Route'); + }); + + it('redraw_map passes a server error on to the model', async () => { + const sandbox = loadScriptSandbox({ webMcp: true }); + const { webMcpTools, sendToolResult, postMessageCalls } = sandbox; + sendToolResult(emptyMap); + + await expect(webMcpTools.redraw_map.execute({})).rejects.toThrow( + /payload_refs/ + ); + + const pending = webMcpTools.redraw_map.execute({ payload_refs: ['x'] }); + await Promise.resolve(); + const call = postMessageCalls.find((m) => m.method === 'tools/call'); + sandbox.respond(call?.id, { + isError: true, + content: [ + { type: 'text', text: 'RenderMapTool: none of the refs resolved' } + ] + }); + await expect(pending).rejects.toThrow(/none of the refs resolved/); + }); + + // "Is it open now?" after clicking a pin the model added: the model read + // the map and found nothing selected. + it('a click on a pin without a result card selects that place', async () => { + const sandbox = loadScriptSandbox({ + webMcp: true, + hostCapabilities: { updateModelContext: { text: {} } }, + hostContext: { displayMode: 'fullscreen' } + }); + const { webMcpTools, sendToolResult, markerElements, postMessageCalls } = + sandbox; + for (let i = 0; i < 4; i++) await Promise.resolve(); + sendToolResult(emptyMap); + await webMcpTools.add_places.execute({ + places: [ + { name: 'Oodi Café', longitude: 24.938, latitude: 60.174 }, + { name: 'Kiasma Café', longitude: 24.937, latitude: 60.172 } + ] + }); + expect( + JSON.parse(await webMcpTools.read_map.execute({})).selectedPlace + ).toBeNull(); + + markerElements[markerElements.length - 1].__fire('click'); + + expect( + JSON.parse(await webMcpTools.read_map.execute({})).selectedPlace + ).toEqual({ name: 'Kiasma Café', longitude: 24.937, latitude: 60.172 }); + // And it reaches the composer as a titled item, with no mapbox_id. + const shared = postMessageCalls + .filter((m) => m.method === 'ui/update-model-context') + .map( + (m) => m.params as { content: Array<{ text: string; _meta: unknown }> } + ); + const item = shared[shared.length - 1].content[0]; + expect(item._meta).toEqual({ 'openai/title': 'Kiasma Café' }); + expect(item.text).not.toContain('mapbox_id'); + }); + + // Standard's poi featureset has only a hide state, so a select state + // showed nothing. + it('a click on a base map POI hides it under a selected pin, and another click moves it', async () => { + const sandbox = loadScriptSandbox({ + webMcp: true, + hostCapabilities: { updateModelContext: { text: {} } }, + hostContext: { displayMode: 'fullscreen' } + }); + const { webMcpTools, sendToolResult, markerElements, featureStates } = + sandbox; + for (let i = 0; i < 4; i++) await Promise.resolve(); + sendToolResult(emptyMap); + const poi = (name: string, maki: string, at: [number, number]) => ({ + properties: { name, maki, class: 'food_and_drink' }, + geometry: { type: 'Point', coordinates: at } + }); + const cafe = poi('Café Regatta', 'cafe', [24.9, 60.18]); + const museum = poi('Ateneum', 'museum', [24.944, 60.17]); + + sandbox.clickBaseMapPoi(cafe); + + expect(featureStates).toEqual([{ feature: cafe, state: { hide: true } }]); + const pin = markerElements[markerElements.length - 1]; + expect(pin.className).toBe('marker-badge place active'); + expect(pin.children[0].textContent).toBe('Café Regatta'); + expect( + JSON.parse(await webMcpTools.read_map.execute({})).selectedPlace + ).toEqual({ + name: 'Café Regatta', + longitude: 24.9, + latitude: 60.18, + category: 'cafe' + }); + + const removedBefore = sandbox.markerRemoveCount.count; + sandbox.clickBaseMapPoi(museum); + + expect(featureStates.slice(1)).toEqual([ + { feature: cafe, state: { hide: false } }, + { feature: museum, state: { hide: true } } + ]); + expect(sandbox.markerRemoveCount.count).toBe(removedBefore + 1); + expect( + markerElements[markerElements.length - 1].children[0].textContent + ).toBe('Ateneum'); + }); + + it('a click on the selected POI deselects it and takes it out of the context', async () => { + const sandbox = loadScriptSandbox({ + webMcp: true, + hostCapabilities: { updateModelContext: { text: {} } }, + hostContext: { displayMode: 'fullscreen' } + }); + const { webMcpTools, sendToolResult, markerElements, featureStates } = + sandbox; + for (let i = 0; i < 4; i++) await Promise.resolve(); + sendToolResult(emptyMap); + const cafe = { + properties: { name: 'Café Regatta', maki: 'cafe' }, + geometry: { type: 'Point', coordinates: [24.9, 60.18] } + }; + sandbox.clickBaseMapPoi(cafe); + const removedBefore = sandbox.markerRemoveCount.count; + let stopped = false; + + markerElements[markerElements.length - 1].__fire('click', { + stopPropagation: () => { + stopped = true; + } + }); + + expect(stopped).toBe(true); + expect(featureStates[featureStates.length - 1]).toEqual({ + feature: cafe, + state: { hide: false } + }); + expect(sandbox.markerRemoveCount.count).toBe(removedBefore + 1); + expect( + JSON.parse(await webMcpTools.read_map.execute({})).selectedPlace + ).toBeNull(); + const shared = sandbox.postMessageCalls.filter( + (m) => m.method === 'ui/update-model-context' + ); + expect( + (shared[shared.length - 1].params as { content: unknown[] }).content + ).toEqual([]); + }); + + it('a place name is shown with its select state, and a second click deselects it', async () => { + const sandbox = loadScriptSandbox({ + webMcp: true, + hostCapabilities: { updateModelContext: { text: {} } }, + hostContext: { displayMode: 'fullscreen' } + }); + const { webMcpTools, sendToolResult, featureStates } = sandbox; + for (let i = 0; i < 4; i++) await Promise.resolve(); + sendToolResult(emptyMap); + const markersBefore = sandbox.markerElements.length; + const kallio = { + properties: { name: 'Kallio', class: 'settlement_subdivision' }, + geometry: { type: 'Point', coordinates: [24.95, 60.184] } + }; + + sandbox.clickBaseMapLabel('place-labels', kallio); + + expect(featureStates).toEqual([ + { feature: kallio, state: { select: true } } + ]); + // The label itself shows the selection, so no pin goes on top. + expect(sandbox.markerElements.length).toBe(markersBefore); + expect( + JSON.parse(await webMcpTools.read_map.execute({})).selectedPlace + ).toEqual({ + name: 'Kallio', + longitude: 24.95, + latitude: 60.184, + category: 'settlement subdivision' + }); + + sandbox.clickBaseMapLabel('place-labels', kallio); + + expect(featureStates[featureStates.length - 1]).toEqual({ + feature: kallio, + state: { select: false } + }); + expect( + JSON.parse(await webMcpTools.read_map.execute({})).selectedPlace + ).toBeNull(); + }); + + // ChatGPT sends null both when the user removes the item and when a sent + // message uses it up, so the selection must survive it. + it('keeps the POI selected on the map when the host clears the context', async () => { + const sandbox = loadScriptSandbox({ + webMcp: true, + hostCapabilities: { updateModelContext: { text: {} } }, + hostContext: { displayMode: 'fullscreen' } + }); + const { webMcpTools, sendToolResult, featureStates, postMessageCalls } = + sandbox; + for (let i = 0; i < 4; i++) await Promise.resolve(); + sendToolResult(emptyMap); + const cafe = { + properties: { name: 'Café Regatta', maki: 'cafe' }, + geometry: { type: 'Point', coordinates: [24.9, 60.18] } + }; + sandbox.clickBaseMapPoi(cafe); + const share = [...postMessageCalls] + .reverse() + .find((m) => m.method === 'ui/update-model-context'); + sandbox.respond(share?.id, { + _meta: { 'openai/modelContext': { updateId: 'update-1' } } + }); + for (let i = 0; i < 4; i++) await Promise.resolve(); + + const statesBefore = featureStates.length; + sandbox.sendHostContext({ 'openai/modelContext': null }); + + expect(featureStates.length).toBe(statesBefore); + expect( + JSON.parse(await webMcpTools.read_map.execute({})).selectedPlace + ).toMatchObject({ name: 'Café Regatta' }); + // Not attached again: the next shared context leaves it out. + sandbox.moveMap({ lng: 24.95 }); + await new Promise((resolve) => setTimeout(resolve, 450)); + const shared = postMessageCalls.filter( + (m) => m.method === 'ui/update-model-context' + ); + expect( + (shared[shared.length - 1].params as { content: unknown[] }).content + ).toEqual([]); + }); + + it('add_places rejects coordinates that are out of range', async () => { + const { webMcpTools, sendToolResult } = loadScriptSandbox({ webMcp: true }); + sendToolResult(emptyMap); + await expect( + webMcpTools.add_places.execute({ + places: [{ name: 'Swapped', longitude: 60.17, latitude: 124.94 }] + }) + ).rejects.toThrow(/latitude/); + await expect(webMcpTools.add_places.execute({})).rejects.toThrow( + /places is required/ + ); + }); + + it('search_map searches near the map center and returns the results', async () => { + const { webMcpTools, sendToolResult, setFetchImpl } = loadScriptSandbox({ + webMcp: true + }); + const fetchSpy = vi.fn(async (_url: string) => ({ + ok: true, + json: async () => ({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + properties: { name: 'Café Regatta', mapbox_id: 'id-1' }, + geometry: { type: 'Point', coordinates: [24.91, 60.18] } + } + ] + }) + })); + setFetchImpl(fetchSpy); + sendToolResult(emptyMap); + + const result = JSON.parse( + await webMcpTools.search_map.execute({ query: ' coffee ' }) + ); + const url = new URL(String(fetchSpy.mock.calls[0][0])); + expect(url.searchParams.get('q')).toBe('coffee'); + expect(url.searchParams.get('proximity')).toBe('24.94,60.17'); + expect(result.places).toContainEqual({ + name: 'Café Regatta', + longitude: 24.91, + latitude: 60.18, + mapbox_id: 'id-1' + }); + }); + + it('search_map fails when the search finds nothing', async () => { + const { webMcpTools, sendToolResult, setFetchImpl } = loadScriptSandbox({ + webMcp: true + }); + setFetchImpl(async () => ({ + ok: true, + json: async () => ({ type: 'FeatureCollection', features: [] }) + })); + sendToolResult(emptyMap); + + await expect( + webMcpTools.search_map.execute({ query: 'nothing here' }) + ).rejects.toThrow(/No results for "nothing here"/); + await expect(webMcpTools.search_map.execute({})).rejects.toThrow( + /query is required/ + ); + }); + + it('move_map flies to the location, clamping the zoom', async () => { + const { webMcpTools, sendToolResult, flyToCalls } = loadScriptSandbox({ + webMcp: true + }); + sendToolResult(emptyMap); + const before = flyToCalls.length; + + await webMcpTools.move_map.execute({ + longitude: 2.35, + latitude: 48.86, + zoom: 40 + }); + expect(flyToCalls.slice(before)).toEqual([ + { center: [2.35, 48.86], zoom: 22, duration: 600 } + ]); + await expect( + webMcpTools.move_map.execute({ longitude: 200, latitude: 0 }) + ).rejects.toThrow(/longitude/); + }); +}); + describe('mapAppHtml category search self-fetch', () => { it('fetches and draws numbered POI markers from a selfFetch descriptor', async () => { const { sendToolResult, setFetchImpl, map, summaryEl, errorEl } = diff --git a/test/tools/render-map-tool/RenderMapTool.test.ts b/test/tools/render-map-tool/RenderMapTool.test.ts index bf5c66d8..cef9341c 100644 --- a/test/tools/render-map-tool/RenderMapTool.test.ts +++ b/test/tools/render-map-tool/RenderMapTool.test.ts @@ -691,11 +691,68 @@ describe('RenderMapTool', () => { expect(text).toContain('Updated the base map style'); }); - it('still errors with nothing to render when baseMapConfig and everything else are absent', async () => { + // ChatGPT opens the thread entrypoint with {}. + it('opens an empty map for {}', async () => { const tool = new RenderMapTool({ httpRequest: vi.fn() }); const result = await tool.run({}); + expect(result.isError).toBe(false); + expect(result.structuredContent).toMatchObject({ + explorer: true, + layer_count: 0, + marker_count: 0, + mapboxRender: { layers: [], markers: [] } + }); + // A model that calls it empty by mistake still learns what to pass. + const text = (result.content[0] as { type: 'text'; text: string }).text; + expect(text).toContain('payload_refs'); + }); + + it('still errors when the only refs given cannot be resolved', async () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run({ + payload_refs: ['mapbox://temp/does-not-exist'] + }); + expect(result.isError).toBe(true); + expect(result.structuredContent).toBeUndefined(); + }); + + it('is a thread entrypoint that opens next to the chat', () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + expect(tool.annotations.title).toBe('Mapbox Map'); + expect(tool.meta['openai/ui']).toEqual({ + entrypoints: [{ type: 'thread' }], + preferredModelDisplayMode: 'fullscreen' + }); + }); + + it('points the model at the open map for follow-ups', async () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run({ + markers: [{ coordinates: [24.94, 60.17] }] + }); + const texts = result.content.map((c) => (c as { text: string }).text); + expect(texts.join(' ')).toContain('search_map'); + expect(texts.join(' ')).toContain('if those tools are not available'); + }); + + it('is firm about it in ChatGPT, where the map stays open in a tab', async () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run( + { markers: [{ coordinates: [24.94, 60.17] }] }, + { + mcpReq: { _meta: { 'openai/userLocation': { city: 'Helsinki' } } } + } as never + ); + const text = result.content + .map((c) => (c as { text: string }).text) + .join(' '); + expect(text).toContain('must update THIS map'); + expect(text).toContain('call read_map on the open map once'); + expect(text).toContain('redraw_map'); + // Still a way out for a chat where the model gets no app tools. + expect(text).toContain('they do not exist'); }); it('carries baseMapConfig through when merged with a payload_ref', async () => { @@ -789,4 +846,34 @@ describe('RenderMapTool', () => { const tool = new RenderMapTool({ httpRequest: vi.fn() }); expect(tool.description).toContain('showTraffic: true'); }); + + describe('open map hint', () => { + const run = (input: object) => + new RenderMapTool({ httpRequest: vi.fn() }).run( + input, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + { http: { authInfo: { token: tokenFor('account-hint') } } } as any + ); + const texts = (r: Awaited>) => + (r.content as { type: string; text: string }[]).map((c) => c.text); + + it('asks the model to call read_map once, if available, and keeps the reuse guidance', async () => { + const result = await run({ markers: [{ coordinates: [-77, 38] }] }); + const hint = texts(result).find((t) => t.includes('read_map')); + expect(hint).toContain('call read_map on the open map once'); + expect(hint).toContain('if that tool is available'); + expect(hint).toContain('instead of opening another'); + }); + + it('keeps the summary as the first text, which redraw_map relays', async () => { + const result = await run({ markers: [{ coordinates: [-77, 38] }] }); + expect(texts(result)[0]).toMatch(/^Rendered map with/); + expect(texts(result)[0]).not.toContain('read_map'); + }); + + it('asks the same for the empty explorer', async () => { + const result = await run({}); + expect(texts(result).some((t) => t.includes('call read_map'))).toBe(true); + }); + }); }); diff --git a/test/utils/renderHint.test.ts b/test/utils/renderHint.test.ts new file mode 100644 index 00000000..c54fa44d --- /dev/null +++ b/test/utils/renderHint.test.ts @@ -0,0 +1,18 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { renderHint } from '../../src/utils/storeMapPayload.js'; + +describe('renderHint', () => { + it('gives the exact render_map_tool call for the ref', () => { + expect(renderHint('mapbox://temp/abc')).toContain( + 'render_map_tool({ "payload_refs": ["mapbox://temp/abc"] })' + ); + }); + + // A second render_map_tool call opens a second map next to the open one. + it('points at the open map first when there is one', () => { + expect(renderHint('mapbox://temp/abc')).toContain('redraw_map'); + }); +}); diff --git a/test/utils/serverInstructions.test.ts b/test/utils/serverInstructions.test.ts index 8bafe8ce..03f2b0e3 100644 --- a/test/utils/serverInstructions.test.ts +++ b/test/utils/serverInstructions.test.ts @@ -19,6 +19,13 @@ describe('buildServerInstructions', () => { ); }); + // "Show coffee shops on it" opened a second map next to the open one. + it('sends changes to an open map to its own tools', () => { + const text = buildServerInstructions(['render_map_tool']); + expect(text).toContain('search_map'); + expect(text).toContain('render_map_tool would open a second map'); + }); + // Agents building a site with a map asked the user for a token instead // of knowing the server could provide one (#281). it('points page building at list_public_tokens_tool when it is enabled', () => { From f4507259722d9dcf87162dea5b92511444c42007 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Wed, 11 Nov 2026 15:29:48 +0200 Subject: [PATCH 2/3] feat: tileset explorer that previews a tileset's tiles in ChatGPT Adds tileset_explorer_tool and its MCP App. The tool reads a tileset's TileJSON and returns its source layers, fields and zoom range, without the tile URLs, which carry the caller's token. The app draws every source layer in its own color, toggles layers, shows tile boundaries and a base map, and inspects a clicked feature. In ChatGPT it opens as a tab, is a thread entrypoint, shares the tileset, view and selected feature as model context, and registers load_tileset and read_tileset_view through WebMCP. --- CHANGELOG.md | 4 + README.md | 8 + src/resources/resourceRegistry.ts | 3 + .../ui-apps/TilesetExplorerUIResource.ts | 73 ++ src/resources/ui-apps/tilesetExplorerHtml.ts | 712 ++++++++++++++++++ .../TilesetExplorerTool.input.schema.ts | 25 + .../TilesetExplorerTool.output.schema.ts | 31 + .../TilesetExplorerTool.ts | 193 +++++ src/tools/toolRegistry.ts | 2 + .../ui-apps/TilesetExplorerUIResource.test.ts | 73 ++ .../tilesetExplorerHtml.script.test.ts | 543 +++++++++++++ .../TilesetExplorerTool.test.ts | 172 +++++ 12 files changed, 1839 insertions(+) create mode 100644 src/resources/ui-apps/TilesetExplorerUIResource.ts create mode 100644 src/resources/ui-apps/tilesetExplorerHtml.ts create mode 100644 src/tools/tileset-explorer-tool/TilesetExplorerTool.input.schema.ts create mode 100644 src/tools/tileset-explorer-tool/TilesetExplorerTool.output.schema.ts create mode 100644 src/tools/tileset-explorer-tool/TilesetExplorerTool.ts create mode 100644 test/resources/ui-apps/TilesetExplorerUIResource.test.ts create mode 100644 test/resources/ui-apps/tilesetExplorerHtml.script.test.ts create mode 100644 test/tools/tileset-explorer-tool/TilesetExplorerTool.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 691c1a35..694ed745 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ - **Map to chat:** the open map shares its view (center, zoom, bounds) as background context (`ui/update-model-context` `structuredContent`), so "what's around here?" works without naming the place. A place the user selects is a titled item in the composer. A place is a pin, a search result, or a label of the Standard base map: points of interest, place names, landmark icons and indoor labels, the label featuresets Standard offers. A selected label is highlighted (POIs, which have only a `hide` state, are hidden under a selected pin), a second click deselects it, and a click on another moves the selection. ChatGPT clears the context both when a message is sent and when the user removes the item, so the selection stays on the map, and the place isn't attached again until the user selects one. An inline map shares nothing. - **Chat to map (WebMCP):** the map registers tools through `document.modelContext` that act on the open map, so "show coffee shops on it" no longer opens a second map: `redraw_map` (takes `render_map_tool`'s arguments; the map calls `render_map_tool` itself and redraws in place), `search_map`, `add_places`, `move_map` and `read_map`. ChatGPT offers them in agent mode (Codex) only, not in an Instant chat, and not when several maps are open. - **Pointing the model at the open map:** the server instructions, the `render_map_tool` description and result, the render hint data tools append, and the map's shared context point the model at these tools. A new map asks the model to call `read_map` once, since the model only started using the map's tools after calling one; in ChatGPT it also says firmly to update this map rather than open another. +- **Tileset explorer: `tileset_explorer_tool`.** Previews a tileset's tiles on an interactive map (`ui://mapbox/tileset-explorer/index.html`). Takes a `tileset_id` (`username.id`; several vector tilesets joined with commas), reads its TileJSON and returns the source layers, their fields and the zoom range. + - **The map:** every source layer is drawn in its own color (polygons, lines and points) over a blank background, framed to the tileset's bounds and never below its minzoom. The panel switches layers, lists their fields, shows tile boundaries and adds a base map. A click shows a feature's layer, geometry, ID and properties. A raster tileset is one raster layer. + - **In ChatGPT:** opens as a tab next to the chat, and is a thread entrypoint (**Mapbox Tileset Explorer**) that starts on `mapbox.mapbox-streets-v8`. An open explorer shares the tileset, its hidden layers and the view as background context, and the selected feature as a titled item the user can remove. It registers two WebMCP tools: `load_tileset` (calls `tileset_explorer_tool` and shows the result in the open explorer) and `read_tileset_view` (features in view per source layer, with up to three samples each). + - **Tokens:** the TileJSON `tiles` URLs carry the caller's token, so only metadata is returned. The page loads tiles through `mapbox://` with the account's public token; without one it still lists layers and fields. ## 0.18.0 - 2026-11-11 diff --git a/README.md b/README.md index 7bcb1c9b..d528079c 100644 --- a/README.md +++ b/README.md @@ -191,6 +191,14 @@ export CLIENT_NEEDS_RESOURCE_FALLBACK=true ## Tools +### Tileset explorer (`tileset_explorer_tool`) + +Previews a Mapbox tileset's tiles on an interactive map. Pass a `tileset_id` (`username.id`, for example `mapbox.mapbox-streets-v8`, `mapbox.satellite` or one of your own; join several vector tilesets with commas). The tool returns the tileset's source layers, their fields and its zoom range, and the map draws every source layer in its own color over a blank background. The panel switches layers on and off, lists each layer's fields, shows tile boundaries, and puts a light or dark base map underneath. Click a feature to see its properties. Raster tilesets are drawn as they are. + +In ChatGPT the explorer opens as a tab next to the chat, and also from "+" → "More tools" → **Mapbox Tileset Explorer**, where it starts on Mapbox Streets. While open it shares the tileset, the view and the selected feature with the conversation, and in agent mode it registers two WebMCP tools, `load_tileset` and `read_tileset_view`, so the model switches tilesets and reads the features in view in the open explorer instead of opening another. + +The server reads the TileJSON with the caller's token and passes on only its metadata, never the tile URLs, which carry that token. The map loads tiles with the account's public token, so a private tileset shows only for its own account. + ### Utility Tools #### Resource Reader Tool diff --git a/src/resources/resourceRegistry.ts b/src/resources/resourceRegistry.ts index 8ba7012b..62c8241e 100644 --- a/src/resources/resourceRegistry.ts +++ b/src/resources/resourceRegistry.ts @@ -9,6 +9,7 @@ import { InlinePayloadResource } from './inline-payload/InlinePayloadResource.js import { ToolResponseResource } from './tool-response/ToolResponseResource.js'; import { MapAppUIResource } from './ui-apps/MapAppUIResource.js'; import { StaticMapViewerUIResource } from './ui-apps/StaticMapViewerUIResource.js'; +import { TilesetExplorerUIResource } from './ui-apps/TilesetExplorerUIResource.js'; import { VersionResource } from './version/VersionResource.js'; import { PlacePhotoResource } from './place-photo/PlacePhotoResource.js'; import { httpRequest } from '../utils/httpPipeline.js'; @@ -25,6 +26,8 @@ export const ALL_RESOURCES = [ new MapAppUIResource({ httpRequest }), // Full-width viewer for static_map_image_tool's image. new StaticMapViewerUIResource(), + // Tileset preview, targeted by tileset_explorer_tool. + new TilesetExplorerUIResource({ httpRequest }), // Photos for the map app's results carousel, which its CSP can't load directly. new PlacePhotoResource({ httpRequest }), new VersionResource() diff --git a/src/resources/ui-apps/TilesetExplorerUIResource.ts b/src/resources/ui-apps/TilesetExplorerUIResource.ts new file mode 100644 index 00000000..ae4048f1 --- /dev/null +++ b/src/resources/ui-apps/TilesetExplorerUIResource.ts @@ -0,0 +1,73 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { + ReadResourceResult, + ServerContext +} from '@modelcontextprotocol/server'; +import { RESOURCE_MIME_TYPE } from '@modelcontextprotocol/ext-apps/server'; +import { BaseResource } from '../BaseResource.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { resolveMapboxPublicToken } from '../../utils/mapboxPublicToken.js'; +import { renderTilesetExplorerHtml } from './tilesetExplorerHtml.js'; +import { mapAppCsp } from './mapAppCsp.js'; + +export const TILESET_EXPLORER_URI = 'ui://mapbox/tileset-explorer/index.html'; + +/** UI for `tileset_explorer_tool`. */ +export class TilesetExplorerUIResource extends BaseResource { + readonly name = 'Mapbox Tileset Explorer UI'; + readonly uri = TILESET_EXPLORER_URI; + readonly description = + "Interactive preview of a Mapbox tileset's tiles, source layers and features (MCP Apps)"; + readonly mimeType = RESOURCE_MIME_TYPE; + + private readonly httpRequest: HttpRequest; + private readonly apiEndpoint: () => string; + + constructor(params: { + httpRequest: HttpRequest; + apiEndpoint?: () => string; + }) { + super(); + this.httpRequest = params.httpRequest; + this.apiEndpoint = + params.apiEndpoint ?? + (() => process.env.MAPBOX_API_ENDPOINT || 'https://api.mapbox.com/'); + } + + async read(_uri: string, extra?: ServerContext): Promise { + const accessToken = + (extra?.http?.authInfo?.token as string | undefined) || + process.env.MAPBOX_ACCESS_TOKEN || + ''; + + const publicToken = accessToken + ? await resolveMapboxPublicToken({ + accessToken, + apiEndpoint: this.apiEndpoint(), + httpRequest: this.httpRequest + }) + : undefined; + + return { + contents: [ + { + uri: this.uri, + mimeType: RESOURCE_MIME_TYPE, + text: renderTilesetExplorerHtml({ publicToken: publicToken ?? '' }), + _meta: { + ui: { + csp: mapAppCsp(this.apiEndpoint()), + preferredSize: { width: 1000, height: 600 } + }, + 'openai/ui': { + preferredDisplayMode: 'fullscreen', + availableDisplayModes: ['inline', 'fullscreen'] + } + } + } + ] + }; + } +} diff --git a/src/resources/ui-apps/tilesetExplorerHtml.ts b/src/resources/ui-apps/tilesetExplorerHtml.ts new file mode 100644 index 00000000..18b27c70 --- /dev/null +++ b/src/resources/ui-apps/tilesetExplorerHtml.ts @@ -0,0 +1,712 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { MAPBOX_GL_VERSION } from './mapAppHtml.js'; + +/** + * HTML for the tileset explorer (`tileset_explorer_tool`): a GL JS map that + * draws every source layer of a tileset in its own color over a blank + * background, with a panel to switch layers, read their fields and inspect a + * clicked feature. Tiles load through `mapbox://` with the public token. + * + * While open next to a chat it shares the tileset, the view and the selected + * feature as model context, and registers WebMCP tools (`load_tileset`, + * `read_tileset_view`) so the model works on this explorer instead of opening + * another. Tileset data only goes in through textContent. + */ +export function renderTilesetExplorerHtml(params: { + publicToken: string; + glVersion?: string; +}): string { + const glVersion = params.glVersion ?? MAPBOX_GL_VERSION; + // Safe inside " can survive the escaping. + const json = (value: unknown) => + JSON.stringify(value).replace(/ + + + + +Mapbox Tileset Explorer + + + +
+
+
+ + +
+ +

+
Loading the tileset…
+
+ + +
+ +

Feature

+
Click a feature to inspect it.
+
+ + + + +`; +} diff --git a/src/tools/tileset-explorer-tool/TilesetExplorerTool.input.schema.ts b/src/tools/tileset-explorer-tool/TilesetExplorerTool.input.schema.ts new file mode 100644 index 00000000..a55a5bdb --- /dev/null +++ b/src/tools/tileset-explorer-tool/TilesetExplorerTool.input.schema.ts @@ -0,0 +1,25 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +/** `username.id`, or several joined with commas (a composite source). */ +export const TILESET_ID_PATTERN = + /^[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+(,[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+)*$/; + +export const DEFAULT_TILESET_ID = 'mapbox.mapbox-streets-v8'; + +export const TilesetExplorerInputSchema = z.object({ + tileset_id: z + .string() + .trim() + .max(500) + .regex( + TILESET_ID_PATTERN, + 'Expected username.id, for example mapbox.mapbox-streets-v8' + ) + .optional() + .describe( + `Tileset ID as username.id, for example mapbox.mapbox-streets-v8 or mapbox.satellite. Join several vector tilesets with commas to see them together. Defaults to ${DEFAULT_TILESET_ID}.` + ) +}); diff --git a/src/tools/tileset-explorer-tool/TilesetExplorerTool.output.schema.ts b/src/tools/tileset-explorer-tool/TilesetExplorerTool.output.schema.ts new file mode 100644 index 00000000..1a036ba0 --- /dev/null +++ b/src/tools/tileset-explorer-tool/TilesetExplorerTool.output.schema.ts @@ -0,0 +1,31 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { z } from 'zod'; + +export const TilesetLayerSchema = z.object({ + id: z.string(), + description: z.string().optional(), + minzoom: z.number().optional(), + maxzoom: z.number().optional(), + /** Field name to its type or description, as TileJSON reports it. */ + fields: z.record(z.string(), z.string()) +}); + +export const TilesetExplorerOutputSchema = z.object({ + tilesetExplorer: z.literal(true), + tileset_id: z.string(), + name: z.string().optional(), + type: z.enum(['vector', 'raster']), + /** [west, south, east, north] */ + bounds: z.tuple([z.number(), z.number(), z.number(), z.number()]).optional(), + /** [longitude, latitude, zoom] */ + center: z.tuple([z.number(), z.number(), z.number()]).optional(), + minzoom: z.number().optional(), + maxzoom: z.number().optional(), + /** Source layers; empty for a raster tileset. */ + layers: z.array(TilesetLayerSchema) +}); + +export type TilesetLayer = z.infer; +export type TilesetExplorerOutput = z.infer; diff --git a/src/tools/tileset-explorer-tool/TilesetExplorerTool.ts b/src/tools/tileset-explorer-tool/TilesetExplorerTool.ts new file mode 100644 index 00000000..8ab41a91 --- /dev/null +++ b/src/tools/tileset-explorer-tool/TilesetExplorerTool.ts @@ -0,0 +1,193 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import type { CallToolResult } from '@modelcontextprotocol/server'; +import { MapboxApiBasedTool } from '../MapboxApiBasedTool.js'; +import type { ToolMeta } from '../BaseTool.js'; +import type { HttpRequest } from '../../utils/types.js'; +import { TILESET_EXPLORER_URI } from '../../resources/ui-apps/TilesetExplorerUIResource.js'; +import { mapAppCsp } from '../../resources/ui-apps/mapAppCsp.js'; +import { + DEFAULT_TILESET_ID, + TilesetExplorerInputSchema +} from './TilesetExplorerTool.input.schema.js'; +import { + TilesetExplorerOutputSchema, + type TilesetExplorerOutput, + type TilesetLayer +} from './TilesetExplorerTool.output.schema.js'; + +const MAX_LAYERS = 100; +const MAX_FIELDS = 60; +/** Fields named per layer in the text result; the rest are counted. */ +const TEXT_FIELDS = 12; + +function num(value: unknown): number | undefined { + return typeof value === 'number' && Number.isFinite(value) + ? value + : undefined; +} + +function numbers( + value: unknown, + length: N +): number[] | undefined { + return Array.isArray(value) && + value.length === length && + value.every((n) => num(n) !== undefined) + ? (value as number[]) + : undefined; +} + +/** + * `tileset_explorer_tool` — preview a Mapbox tileset's tiles on an + * interactive map: every source layer in its own color, with its fields, and + * a click to inspect a feature. + * + * Reads the tileset's TileJSON with the caller's token and returns only its + * metadata. The TileJSON `tiles` URLs carry that token, so they are never + * passed on; the page loads tiles through `mapbox://` with a public token. + * Called with `{}`, as a ChatGPT entrypoint does, it opens Mapbox Streets. + */ +export class TilesetExplorerTool extends MapboxApiBasedTool< + typeof TilesetExplorerInputSchema, + typeof TilesetExplorerOutputSchema +> { + readonly name = 'tileset_explorer_tool'; + readonly description = + "Open the Mapbox tileset explorer: preview a tileset's tiles on an interactive map, with each source layer in its own color, and list its layers, fields and zoom range. Use it to look inside a vector (MVT) or raster tileset, for example mapbox.mapbox-streets-v8 or one of the user's own. If a tileset explorer is already open and offers its own load_tileset tool, call that instead: this tool opens another explorer."; + readonly annotations = { + title: 'Mapbox Tileset Explorer', + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true + }; + readonly meta: ToolMeta; + + constructor(params: { httpRequest: HttpRequest }) { + super({ + inputSchema: TilesetExplorerInputSchema, + outputSchema: TilesetExplorerOutputSchema, + httpRequest: params.httpRequest + }); + this.meta = { + ui: { + resourceUri: TILESET_EXPLORER_URI, + csp: mapAppCsp(MapboxApiBasedTool.mapboxApiEndpoint) + }, + 'openai/ui': { + entrypoints: [{ type: 'thread' }], + preferredModelDisplayMode: 'fullscreen' + } + }; + } + + protected async execute( + input: { tileset_id?: string }, + accessToken: string + ): Promise { + const tilesetId = input.tileset_id || DEFAULT_TILESET_ID; + const url = new URL( + `${MapboxApiBasedTool.mapboxApiEndpoint}v4/${tilesetId}.json` + ); + url.searchParams.set('secure', ''); + url.searchParams.set('access_token', accessToken); + + const response = await this.httpRequest(url.toString()); + if (!response.ok) { + const message = await this.getErrorMessage(response); + const hint = + response.status === 404 + ? ' Check the tileset ID (username.id); a private tileset needs a token from its own account.' + : ''; + return { + content: [ + { + type: 'text', + text: `Couldn't load tileset ${tilesetId}: ${message}.${hint}` + } + ], + isError: true + }; + } + + const output = toOutput( + tilesetId, + (await response.json()) as Record + ); + return { + content: [{ type: 'text', text: describe(output) }], + structuredContent: output, + isError: false + }; + } +} + +function toLayer(raw: unknown): TilesetLayer | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const layer = raw as Record; + if (typeof layer.id !== 'string' || !layer.id) return undefined; + const fields: Record = {}; + if (layer.fields && typeof layer.fields === 'object') { + for (const [name, value] of Object.entries(layer.fields).slice( + 0, + MAX_FIELDS + )) { + fields[name] = typeof value === 'string' ? value : ''; + } + } + return { + id: layer.id, + description: + typeof layer.description === 'string' && layer.description + ? layer.description + : undefined, + minzoom: num(layer.minzoom), + maxzoom: num(layer.maxzoom), + fields + }; +} + +function toOutput( + tilesetId: string, + tileJson: Record +): TilesetExplorerOutput { + const vectorLayers = Array.isArray(tileJson.vector_layers) + ? tileJson.vector_layers + : undefined; + return { + tilesetExplorer: true, + tileset_id: tilesetId, + name: + typeof tileJson.name === 'string' && tileJson.name + ? tileJson.name + : undefined, + type: vectorLayers ? 'vector' : 'raster', + bounds: numbers(tileJson.bounds, 4) as TilesetExplorerOutput['bounds'], + center: numbers(tileJson.center, 3) as TilesetExplorerOutput['center'], + minzoom: num(tileJson.minzoom), + maxzoom: num(tileJson.maxzoom), + layers: (vectorLayers ?? []) + .map(toLayer) + .filter((layer): layer is TilesetLayer => layer !== undefined) + .slice(0, MAX_LAYERS) + }; +} + +function describe(output: TilesetExplorerOutput): string { + const zooms = + output.minzoom !== undefined && output.maxzoom !== undefined + ? `, zoom ${output.minzoom} to ${output.maxzoom}` + : ''; + const head = `Tileset ${output.tileset_id}${output.name ? ` (${output.name})` : ''}: ${output.type}${zooms}. It is open in the tileset explorer.`; + if (output.type === 'raster') return head; + const layers = output.layers.map((layer) => { + const names = Object.keys(layer.fields); + const shown = names.slice(0, TEXT_FIELDS).join(', '); + const more = + names.length > TEXT_FIELDS ? `, +${names.length - TEXT_FIELDS} more` : ''; + return `- ${layer.id}${names.length ? `: ${shown}${more}` : ''}`; + }); + return `${head}\n${output.layers.length} source layer${output.layers.length === 1 ? '' : 's'} and their fields:\n${layers.join('\n')}`; +} diff --git a/src/tools/toolRegistry.ts b/src/tools/toolRegistry.ts index f334c1a6..7619b96a 100644 --- a/src/tools/toolRegistry.ts +++ b/src/tools/toolRegistry.ts @@ -32,6 +32,7 @@ import { MapMatchingTool } from './map-matching-tool/MapMatchingTool.js'; import { MatrixTool } from './matrix-tool/MatrixTool.js'; import { OptimizationTool } from './optimization-tool/OptimizationTool.js'; import { RenderMapTool } from './render-map-tool/RenderMapTool.js'; +import { TilesetExplorerTool } from './tileset-explorer-tool/TilesetExplorerTool.js'; import { ResourceReaderTool } from './resource-reader-tool/ResourceReaderTool.js'; import { ReverseGeocodeTool } from './reverse-geocode-tool/ReverseGeocodeTool.js'; import { StaticMapImageTool } from './static-map-image-tool/StaticMapImageTool.js'; @@ -73,6 +74,7 @@ export const CORE_TOOLS = [ new MatrixTool({ httpRequest }), new OptimizationTool({ httpRequest }), new RenderMapTool({ httpRequest }), + new TilesetExplorerTool({ httpRequest }), new ReverseGeocodeTool({ httpRequest }), new StaticMapImageTool({ httpRequest }), new SearchAndGeocodeTool({ httpRequest }) diff --git a/test/resources/ui-apps/TilesetExplorerUIResource.test.ts b/test/resources/ui-apps/TilesetExplorerUIResource.test.ts new file mode 100644 index 00000000..7cd21181 --- /dev/null +++ b/test/resources/ui-apps/TilesetExplorerUIResource.test.ts @@ -0,0 +1,73 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +// JWT with payload {"sub":"test","u":"testuser"} (base64) +const SK_TOKEN = 'sk.eyJzdWIiOiJ0ZXN0IiwidSI6InRlc3R1c2VyIn0.signature'; + +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { + TilesetExplorerUIResource, + TILESET_EXPLORER_URI +} from '../../../src/resources/ui-apps/TilesetExplorerUIResource.js'; +import { __resetMapboxPublicTokenCache } from '../../../src/utils/mapboxPublicToken.js'; +import { resourceTextOf } from '../../utils/resultTestUtils.js'; +import type { HttpRequest } from '../../../src/utils/types.js'; + +const authed = { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + http: { authInfo: { token: SK_TOKEN } as any } + // eslint-disable-next-line @typescript-eslint/no-explicit-any +} as any; + +describe('TilesetExplorerUIResource', () => { + beforeEach(() => { + __resetMapboxPublicTokenCache(); + delete process.env.MAPBOX_PUBLIC_TOKEN; + }); + + it('serves the explorer with the public token and opens it as a tab', async () => { + const httpRequest = vi.fn( + async () => + ({ + ok: true, + status: 200, + json: async () => [ + { + usage: 'pk', + default: true, + token: 'pk.eyJ1IjoidGVzdHVzZXIifQ.pub' + } + ] + }) as unknown as Response + ); + + const result = await new TilesetExplorerUIResource({ httpRequest }).read( + TILESET_EXPLORER_URI, + authed + ); + + const entry = result.contents[0]; + expect(entry.uri).toBe(TILESET_EXPLORER_URI); + expect(entry.mimeType).toBe('text/html;profile=mcp-app'); + const html = resourceTextOf(result); + expect(html).toContain('Mapbox Tileset Explorer'); + expect(html).toContain('"pk.eyJ1IjoidGVzdHVzZXIifQ.pub"'); + expect(html).not.toContain(SK_TOKEN); + expect(entry._meta).toMatchObject({ + ui: { + csp: { + connectDomains: [ + 'https://api.mapbox.com', + 'https://events.mapbox.com' + ], + resourceDomains: ['https://api.mapbox.com'], + workerDomains: ['blob:'] + } + }, + 'openai/ui': { + preferredDisplayMode: 'fullscreen', + availableDisplayModes: ['inline', 'fullscreen'] + } + }); + }); +}); diff --git a/test/resources/ui-apps/tilesetExplorerHtml.script.test.ts b/test/resources/ui-apps/tilesetExplorerHtml.script.test.ts new file mode 100644 index 00000000..fb22305d --- /dev/null +++ b/test/resources/ui-apps/tilesetExplorerHtml.script.test.ts @@ -0,0 +1,543 @@ +// Copyright (c) Mapbox, Inc. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import * as vm from 'node:vm'; +import { renderTilesetExplorerHtml } from '../../../src/resources/ui-apps/tilesetExplorerHtml.js'; + +type Listener = (event?: unknown) => void; +type FakeElement = { + id: string; + hidden: boolean; + className: string; + children: FakeElement[]; + style: Record; + textContent: string; + value: string; + checked: boolean; + appendChild: (child: FakeElement) => FakeElement; + addEventListener: (type: string, fn: Listener) => void; + fire: (type: string) => void; + [key: string]: unknown; +}; + +function fakeElement(id = ''): FakeElement { + const listeners: Record = {}; + const attributes: Record = {}; + let text = ''; + const el: FakeElement = { + id, + hidden: false, + className: '', + children: [], + style: { setProperty: () => {} }, + value: '', + checked: false, + get textContent() { + return text + el.children.map((c) => c.textContent).join(''); + }, + set textContent(value: string) { + text = value; + el.children = []; + }, + appendChild: (child) => { + el.children.push(child); + return child; + }, + addEventListener: (type, fn) => { + (listeners[type] ??= []).push(fn); + }, + fire: (type) => listeners[type]?.forEach((fn) => fn({})), + setAttribute: (name: string, value: string) => { + attributes[name] = value; + }, + getAttribute: (name: string) => attributes[name] ?? null + }; + return el; +} + +const STATIC_IDS = [ + 'map', + 'panel', + 'load-form', + 'tileset-input', + 'load-button', + 'error', + 'tileset-name', + 'tileset-meta', + 'opt-boundaries', + 'opt-basemap', + 'layers-section', + 'layers-title', + 'layers-all', + 'layers-none', + 'layers', + 'feature', + 'zoom' +]; + +type LayerSpec = { + id: string; + type: string; + 'source-layer'?: string; + layout?: { visibility?: string }; +}; + +/** The parts of a GL JS map the explorer uses. */ +class FakeMap { + static last: FakeMap | undefined; + sources: Record> = {}; + layers: LayerSpec[] = []; + handlers: Record void>> = {}; + zoom: number; + rendered: unknown[] = []; + fitted: unknown; + showTileBoundaries = false; + styles: unknown[] = []; + constructor(options: { zoom: number; style: unknown }) { + this.zoom = options.zoom; + this.styles.push(options.style); + FakeMap.last = this; + } + addControl() {} + on(type: string, fn: (e?: unknown) => void) { + (this.handlers[type] ??= []).push(fn); + } + emit(type: string, event?: unknown) { + this.handlers[type]?.forEach((fn) => fn(event)); + } + addSource(id: string, source: Record) { + this.sources[id] = source; + } + getSource(id: string) { + return this.sources[id]; + } + removeSource(id: string) { + delete this.sources[id]; + } + addLayer(layer: LayerSpec) { + this.layers.push(layer); + } + getLayer(id: string) { + return this.layers.find((l) => l.id === id); + } + removeLayer(id: string) { + this.layers = this.layers.filter((l) => l.id !== id); + } + setLayoutProperty(id: string, name: string, value: string) { + const layer = this.getLayer(id)!; + layer.layout = { ...layer.layout, [name]: value }; + } + setStyle(style: unknown) { + this.styles.push(style); + this.sources = {}; + this.layers = []; + } + jumpTo(camera: { zoom: number }) { + this.zoom = camera.zoom; + } + fitBounds(bounds: unknown) { + this.fitted = bounds; + this.zoom = 3; + } + setZoom(zoom: number) { + this.zoom = zoom; + } + getZoom() { + return this.zoom; + } + getCenter() { + return { lng: 24.9, lat: 60.2 }; + } + getBounds() { + return { + getWest: () => 24, + getSouth: () => 60, + getEast: () => 25, + getNorth: () => 61 + }; + } + resize() {} + queryRenderedFeatures() { + return this.rendered; + } +} + +class FakePopup { + setLngLat() { + return this; + } + setText() { + return this; + } + addTo() { + return this; + } + remove() {} +} + +type WebMcpTool = { + name: string; + execute: (input?: unknown) => unknown; +}; + +function loadExplorer( + options: { + publicToken?: string; + hostContext?: Record; + hostCapabilities?: Record; + webMcp?: boolean; + } = {} +) { + const html = renderTilesetExplorerHtml({ + publicToken: options.publicToken ?? 'pk.test' + }); + const source = html.match(/