feat(map-server): add screenshot to updateModelContext and remove reverse geocode + rounded corners (#340)

* feat(map-server): add screenshot to updateModelContext and remove rounded corners

- Remove border-radius and overflow: hidden from cesiumContainer CSS
- Capture map canvas as base64 PNG and include in updateModelContext
- Only include image if host supports image content blocks

* feat(map-server): reduce screenshot size for fewer tokens

- Scale down to max 256px (longest dimension)
- Use PNG for better quality with map graphics

* prettier
This commit is contained in:
Olivier Chafik 2026-01-26 13:04:43 +00:00 • committed by GitHub
commit 9967693621
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 50 additions and 142 deletions

View file

@ -19,8 +19,6 @@
#cesiumContainer {
width: 100%;
height: 100%;
border-radius: .75rem;
overflow: hidden;
}
#fullscreen-btn {
position: absolute;

View file

@ -6,6 +6,7 @@
* a navigate-to tool for the host to control navigation.
*/
import { App } from "@modelcontextprotocol/ext-apps";
import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
// TypeScript declaration for Cesium loaded from CDN
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@ -14,6 +15,8 @@ declare let Cesium: any;
const CESIUM_VERSION = "1.123";
const CESIUM_BASE_URL = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VERSION}/Build/Cesium`;
const MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION = 768; // Max width/height for screenshots in pixels for updateModelContext
/**
* Dynamically load CesiumJS from CDN
* This is necessary because external <script src=""> tags don't work in srcdoc iframes
@ -262,133 +265,6 @@ function getScaleDimensions(extent: BoundingBox): {
return { widthKm, heightKm };
}
// Rate limiting for Nominatim (1 request per second per their usage policy)
let lastNominatimRequest = 0;
const NOMINATIM_RATE_LIMIT_MS = 1100; // 1.1 seconds to be safe
/**
* Wait for rate limit before making a Nominatim request
*/
async function waitForRateLimit(): Promise<void> {
const now = Date.now();
const timeSinceLastRequest = now - lastNominatimRequest;
if (timeSinceLastRequest < NOMINATIM_RATE_LIMIT_MS) {
await new Promise((resolve) =>
setTimeout(resolve, NOMINATIM_RATE_LIMIT_MS - timeSinceLastRequest),
);
}
lastNominatimRequest = Date.now();
}
/**
* Reverse geocode a single point using Nominatim
* Returns the place name for that location
*/
async function reverseGeocode(
lat: number,
lon: number,
): Promise<string | null> {
try {
await waitForRateLimit();
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`;
const response = await fetch(url, {
headers: {
"User-Agent": "CesiumJS-Globe-MCP-App/1.0",
},
});
if (!response.ok) {
log.warn("Reverse geocode failed:", response.status);
return null;
}
const data = await response.json();
// Extract short place name from address
const addr = data.address;
if (!addr) return data.display_name?.split(",")[0] || null;
// Prefer city > town > village > county > state
return (
addr.city ||
addr.town ||
addr.village ||
addr.county ||
addr.state ||
data.display_name?.split(",")[0] ||
null
);
} catch (error) {
log.warn("Reverse geocode error:", error);
return null;
}
}
/**
* Get sample points within an extent based on the visible area size.
* For small areas (city zoom), just sample center.
* For larger areas, sample center + corners to discover multiple places.
*/
function getSamplePoints(
extent: BoundingBox,
extentSizeKm: number,
): Array<{ lat: number; lon: number }> {
const centerLat = (extent.north + extent.south) / 2;
const centerLon = (extent.east + extent.west) / 2;
// Always include center
const points: Array<{ lat: number; lon: number }> = [
{ lat: centerLat, lon: centerLon },
];
// For larger extents, add more sample points
if (extentSizeKm > 100) {
// > 100km: sample 4 quadrant centers
const latOffset = (extent.north - extent.south) / 4;
const lonOffset = (extent.east - extent.west) / 4;
points.push(
{ lat: centerLat + latOffset, lon: centerLon - lonOffset }, // NW
{ lat: centerLat + latOffset, lon: centerLon + lonOffset }, // NE
{ lat: centerLat - latOffset, lon: centerLon - lonOffset }, // SW
{ lat: centerLat - latOffset, lon: centerLon + lonOffset }, // SE
);
} else if (extentSizeKm > 30) {
// 30-100km: sample 2 opposite corners
const latOffset = (extent.north - extent.south) / 4;
const lonOffset = (extent.east - extent.west) / 4;
points.push(
{ lat: centerLat + latOffset, lon: centerLon - lonOffset }, // NW
{ lat: centerLat - latOffset, lon: centerLon + lonOffset }, // SE
);
}
// < 30km: just center (likely same city)
return points;
}
/**
* Get places visible in the extent by sampling multiple points
* Returns array of unique place names
*/
async function getVisiblePlaces(extent: BoundingBox): Promise<string[]> {
const { widthKm, heightKm } = getScaleDimensions(extent);
const extentSizeKm = Math.max(widthKm, heightKm);
const samplePoints = getSamplePoints(extent, extentSizeKm);
log.info(
`Sampling ${samplePoints.length} points for extent ${extentSizeKm.toFixed(0)}km`,
);
const places = new Set<string>();
for (const point of samplePoints) {
const place = await reverseGeocode(point.lat, point.lon);
if (place) {
places.add(place);
log.info(
`Found place: ${place} at ${point.lat.toFixed(4)}, ${point.lon.toFixed(4)}`,
);
}
}
return [...places];
}
/**
* Debounced location update using multi-point reverse geocoding.
* Samples multiple points in the visible extent to discover places.
@ -410,19 +286,53 @@ function scheduleLocationUpdate(cesiumViewer: any): void {
}
const { widthKm, heightKm } = getScaleDimensions(extent);
const places = await getVisiblePlaces(extent);
// Update the model's context with the current map location.
// If the host doesn't support this, the request will silently fail.
const content = [
`The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall `,
`and has changed to the following location: [${places.join(", ")}] `,
`lat. / long. of center of map = [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`,
].join("\n");
log.info("Updating model context:", content);
app.updateModelContext({
content: [{ type: "text", text: content }],
});
// Update the model's context with the current map location and screenshot.
const text =
`The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall, ` +
`centered on lat. / long. [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`;
// Build content array with text and optional screenshot
const content: ContentBlock[] = [{ type: "text", text }];
// Add screenshot if host supports image content
if (app.getHostCapabilities()?.updateModelContext?.image) {
try {
// Scale down to reduce token usage (tokens depend on dimensions)
const sourceCanvas = cesiumViewer.canvas;
const scale = Math.min(
1,
MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION /
Math.max(sourceCanvas.width, sourceCanvas.height),
);
const targetWidth = Math.round(sourceCanvas.width * scale);
const targetHeight = Math.round(sourceCanvas.height * scale);
const tempCanvas = document.createElement("canvas");
tempCanvas.width = targetWidth;
tempCanvas.height = targetHeight;
const ctx = tempCanvas.getContext("2d");
if (ctx) {
ctx.drawImage(sourceCanvas, 0, 0, targetWidth, targetHeight);
const dataUrl = tempCanvas.toDataURL("image/png");
const base64Data = dataUrl.split(",")[1];
if (base64Data) {
content.push({
type: "image",
data: base64Data,
mimeType: "image/png",
});
log.info(
`Added screenshot to model context (${targetWidth}x${targetHeight})`,
);
}
}
} catch (err) {
log.warn("Failed to capture screenshot:", err);
}
}
app.updateModelContext({ content });
}, 1500);
}