From 5634bdafefca7ed9fbe23ff3e40b13854cdc7844 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Fri, 13 Nov 2026 14:31:16 +0200 Subject: [PATCH 1/5] 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 | 77 +- src/utils/serverInstructions.ts | 3 +- src/utils/storeMapPayload.ts | 7 +- .../ui-apps/mapAppHtml.script.test.ts | 697 +++++++++++++++++- .../render-map-tool/RenderMapTool.test.ts | 83 ++- test/utils/renderHint.test.ts | 18 + test/utils/serverInstructions.test.ts | 7 + 12 files changed, 1362 insertions(+), 58 deletions(-) create mode 100644 test/utils/renderHint.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9b77d74b..4e9c7702 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, the render hint data tools append, and the map's shared context name these tools, each saying it applies in ChatGPT. In ChatGPT, the result of a call that opens a map names them too. Other clients get the same instructions as before plus that one ChatGPT line. + ## 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..c77a72b8 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 provides these tools: redraw_map (same arguments as render_map_tool), search_map, add_places, move_map, read_map. Use them to change this map: render_map_tool 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..2ca0086f 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,6 +51,25 @@ import type { HttpRequest } from '../../utils/types.js'; * coordinate pairs as tool input — keeping latency in the * hundreds-of-millseconds range instead of 20-30s. */ +// In ChatGPT the map stays open in a tab, so the next request decides between +// one map and a pile of tabs. The model reads this right after drawing. +const OPEN_MAP_HINT_CHATGPT = + 'This map is now open in a tab next to the chat and stays open. For later ' + + 'map requests in this conversation it provides these tools: redraw_map ' + + '(exactly the arguments of render_map_tool), search_map, add_places, ' + + 'move_map and read_map. Use them to change this map: calling ' + + 'render_map_tool again opens a duplicate tab.'; + +/** 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 @@ -62,6 +78,10 @@ export class RenderMapTool extends BaseTool< 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. ' + + 'In ChatGPT the map stays open next to the chat and provides its own tools ' + + '(redraw_map, which takes the same arguments as this tool, search_map, ' + + 'add_places, move_map, read_map): use them to change that map, because ' + + 'another call to this tool opens a second 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 +110,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 +138,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 +164,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 +233,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 +273,11 @@ export class RenderMapTool extends BaseTool< const content: CallToolResult['content'] = [ { type: 'text' as const, text }, + // The map page's redraw_map relays only content[0], so this + // never reaches the model on a redraw. + ...(isChatGpt(extra) + ? [{ type: 'text' as const, text: OPEN_MAP_HINT_CHATGPT }] + : []), { type: 'text' as const, text: `[[MAPBOX_RENDER_REF]] ${mergedRef}` @@ -263,6 +301,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..9e3761fc 100644 --- a/src/utils/serverInstructions.ts +++ b/src/utils/serverInstructions.ts @@ -17,7 +17,8 @@ export function buildServerInstructions( 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.' + '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.', + 'In ChatGPT, a Mapbox map stays open next to the chat and provides these tools: redraw_map (same arguments as render_map_tool), search_map, add_places, move_map, read_map. Use them to change the open map ("show coffee shops on it", "add the route"): render_map_tool would open a second map.' ]; if (enabledToolNames.includes('static_map_image_tool')) { lines.push( diff --git a/src/utils/storeMapPayload.ts b/src/utils/storeMapPayload.ts index 797a1650..c76fe80b 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, in ChatGPT, to the `redraw_map` tool of the open Mapbox map.' ) }) .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. + `In ChatGPT, if a Mapbox map is already open next to the chat, call its redraw_map tool with the same arguments instead, so that map is updated.` ); } 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..9189525a 100644 --- a/test/tools/render-map-tool/RenderMapTool.test.ts +++ b/test/tools/render-map-tool/RenderMapTool.test.ts @@ -691,11 +691,70 @@ 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' + }); + }); + + const chatGpt = { + mcpReq: { _meta: { 'openai/userLocation': { city: 'Helsinki' } } } + } as never; + const allText = (result: { content: unknown[] }) => + result.content.map((c) => (c as { text: string }).text).join(' '); + + // In ChatGPT the map stays open in a tab, so a second render_map_tool + // call piles up tabs. + it("in ChatGPT, names the open map's tools in the result", async () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run( + { markers: [{ coordinates: [24.94, 60.17] }] }, + chatGpt + ); + const text = allText(result); + for (const name of ['redraw_map', 'search_map', 'add_places', 'move_map']) + expect(text).toContain(name); + expect(text).toContain('opens a duplicate tab'); + // The tools are named directly, not left for the model to discover. + expect(text).not.toContain('look them up'); + }); + + it('adds nothing about the open map elsewhere', async () => { + const tool = new RenderMapTool({ httpRequest: vi.fn() }); + const result = await tool.run({ + markers: [{ coordinates: [24.94, 60.17] }] + }); + expect(allText(result)).not.toContain('redraw_map'); }); it('carries baseMapConfig through when merged with a payload_ref', async () => { @@ -789,4 +848,26 @@ describe('RenderMapTool', () => { const tool = new RenderMapTool({ httpRequest: vi.fn() }); expect(tool.description).toContain('showTraffic: true'); }); + + describe('open map hint in ChatGPT', () => { + const run = (input: object) => + new RenderMapTool({ httpRequest: vi.fn() }).run(input, { + http: { authInfo: { token: tokenFor('account-hint') } }, + mcpReq: { _meta: { 'openai/userLocation': { city: 'Helsinki' } } } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + } as any); + const texts = (r: Awaited>) => + (r.content as { type: string; text: string }[]).map((c) => c.text); + + 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('redraw_map'); + }); + + it('names the tools for the empty map too', async () => { + const result = await run({}); + expect(texts(result).some((t) => t.includes('redraw_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 ea9a034b4f0136f62e2806e1a0ed0e1fdd556bc9 Mon Sep 17 00:00:00 2001 From: Mofei Zhu Date: Fri, 13 Nov 2026 15:41:07 +0200 Subject: [PATCH 2/5] refactor: move map selection, model context and WebMCP tools out of mapAppHtml.ts mapAppHtml.ts had grown past 3,300 lines. The new map page code now lives in three files whose scripts renderMapAppHtml splices into the page's single