Skip to content

Commit 9967693

Browse files
authored
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
1 parent c2037f3 commit 9967693

File tree

2 files changed

+50
-142
lines changed

2 files changed

+50
-142
lines changed

examples/map-server/mcp-app.html

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,6 @@
1919
#cesiumContainer {
2020
width: 100%;
2121
height: 100%;
22-
border-radius: .75rem;
23-
overflow: hidden;
2422
}
2523
#fullscreen-btn {
2624
position: absolute;

examples/map-server/src/mcp-app.ts

Lines changed: 50 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
* a navigate-to tool for the host to control navigation.
77
*/
88
import { App } from "@modelcontextprotocol/ext-apps";
9+
import type { ContentBlock } from "@modelcontextprotocol/sdk/spec.types.js";
910

1011
// TypeScript declaration for Cesium loaded from CDN
1112
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -14,6 +15,8 @@ declare let Cesium: any;
1415
const CESIUM_VERSION = "1.123";
1516
const CESIUM_BASE_URL = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VERSION}/Build/Cesium`;
1617

18+
const MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION = 768; // Max width/height for screenshots in pixels for updateModelContext
19+
1720
/**
1821
* Dynamically load CesiumJS from CDN
1922
* This is necessary because external <script src=""> tags don't work in srcdoc iframes
@@ -262,133 +265,6 @@ function getScaleDimensions(extent: BoundingBox): {
262265
return { widthKm, heightKm };
263266
}
264267

265-
// Rate limiting for Nominatim (1 request per second per their usage policy)
266-
let lastNominatimRequest = 0;
267-
const NOMINATIM_RATE_LIMIT_MS = 1100; // 1.1 seconds to be safe
268-
269-
/**
270-
* Wait for rate limit before making a Nominatim request
271-
*/
272-
async function waitForRateLimit(): Promise<void> {
273-
const now = Date.now();
274-
const timeSinceLastRequest = now - lastNominatimRequest;
275-
if (timeSinceLastRequest < NOMINATIM_RATE_LIMIT_MS) {
276-
await new Promise((resolve) =>
277-
setTimeout(resolve, NOMINATIM_RATE_LIMIT_MS - timeSinceLastRequest),
278-
);
279-
}
280-
lastNominatimRequest = Date.now();
281-
}
282-
283-
/**
284-
* Reverse geocode a single point using Nominatim
285-
* Returns the place name for that location
286-
*/
287-
async function reverseGeocode(
288-
lat: number,
289-
lon: number,
290-
): Promise<string | null> {
291-
try {
292-
await waitForRateLimit();
293-
const url = `https://nominatim.openstreetmap.org/reverse?lat=${lat}&lon=${lon}&format=json&zoom=10`;
294-
const response = await fetch(url, {
295-
headers: {
296-
"User-Agent": "CesiumJS-Globe-MCP-App/1.0",
297-
},
298-
});
299-
if (!response.ok) {
300-
log.warn("Reverse geocode failed:", response.status);
301-
return null;
302-
}
303-
const data = await response.json();
304-
// Extract short place name from address
305-
const addr = data.address;
306-
if (!addr) return data.display_name?.split(",")[0] || null;
307-
// Prefer city > town > village > county > state
308-
return (
309-
addr.city ||
310-
addr.town ||
311-
addr.village ||
312-
addr.county ||
313-
addr.state ||
314-
data.display_name?.split(",")[0] ||
315-
null
316-
);
317-
} catch (error) {
318-
log.warn("Reverse geocode error:", error);
319-
return null;
320-
}
321-
}
322-
323-
/**
324-
* Get sample points within an extent based on the visible area size.
325-
* For small areas (city zoom), just sample center.
326-
* For larger areas, sample center + corners to discover multiple places.
327-
*/
328-
function getSamplePoints(
329-
extent: BoundingBox,
330-
extentSizeKm: number,
331-
): Array<{ lat: number; lon: number }> {
332-
const centerLat = (extent.north + extent.south) / 2;
333-
const centerLon = (extent.east + extent.west) / 2;
334-
335-
// Always include center
336-
const points: Array<{ lat: number; lon: number }> = [
337-
{ lat: centerLat, lon: centerLon },
338-
];
339-
340-
// For larger extents, add more sample points
341-
if (extentSizeKm > 100) {
342-
// > 100km: sample 4 quadrant centers
343-
const latOffset = (extent.north - extent.south) / 4;
344-
const lonOffset = (extent.east - extent.west) / 4;
345-
points.push(
346-
{ lat: centerLat + latOffset, lon: centerLon - lonOffset }, // NW
347-
{ lat: centerLat + latOffset, lon: centerLon + lonOffset }, // NE
348-
{ lat: centerLat - latOffset, lon: centerLon - lonOffset }, // SW
349-
{ lat: centerLat - latOffset, lon: centerLon + lonOffset }, // SE
350-
);
351-
} else if (extentSizeKm > 30) {
352-
// 30-100km: sample 2 opposite corners
353-
const latOffset = (extent.north - extent.south) / 4;
354-
const lonOffset = (extent.east - extent.west) / 4;
355-
points.push(
356-
{ lat: centerLat + latOffset, lon: centerLon - lonOffset }, // NW
357-
{ lat: centerLat - latOffset, lon: centerLon + lonOffset }, // SE
358-
);
359-
}
360-
// < 30km: just center (likely same city)
361-
362-
return points;
363-
}
364-
365-
/**
366-
* Get places visible in the extent by sampling multiple points
367-
* Returns array of unique place names
368-
*/
369-
async function getVisiblePlaces(extent: BoundingBox): Promise<string[]> {
370-
const { widthKm, heightKm } = getScaleDimensions(extent);
371-
const extentSizeKm = Math.max(widthKm, heightKm);
372-
const samplePoints = getSamplePoints(extent, extentSizeKm);
373-
374-
log.info(
375-
`Sampling ${samplePoints.length} points for extent ${extentSizeKm.toFixed(0)}km`,
376-
);
377-
378-
const places = new Set<string>();
379-
for (const point of samplePoints) {
380-
const place = await reverseGeocode(point.lat, point.lon);
381-
if (place) {
382-
places.add(place);
383-
log.info(
384-
`Found place: ${place} at ${point.lat.toFixed(4)}, ${point.lon.toFixed(4)}`,
385-
);
386-
}
387-
}
388-
389-
return [...places];
390-
}
391-
392268
/**
393269
* Debounced location update using multi-point reverse geocoding.
394270
* Samples multiple points in the visible extent to discover places.
@@ -410,19 +286,53 @@ function scheduleLocationUpdate(cesiumViewer: any): void {
410286
}
411287

412288
const { widthKm, heightKm } = getScaleDimensions(extent);
413-
const places = await getVisiblePlaces(extent);
414-
415-
// Update the model's context with the current map location.
416-
// If the host doesn't support this, the request will silently fail.
417-
const content = [
418-
`The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall `,
419-
`and has changed to the following location: [${places.join(", ")}] `,
420-
`lat. / long. of center of map = [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`,
421-
].join("\n");
422-
log.info("Updating model context:", content);
423-
app.updateModelContext({
424-
content: [{ type: "text", text: content }],
425-
});
289+
290+
// Update the model's context with the current map location and screenshot.
291+
const text =
292+
`The map view of ${app.getHostContext()?.toolInfo?.id} is now ${widthKm.toFixed(1)}km wide × ${heightKm.toFixed(1)}km tall, ` +
293+
`centered on lat. / long. [${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}]`;
294+
295+
// Build content array with text and optional screenshot
296+
const content: ContentBlock[] = [{ type: "text", text }];
297+
298+
// Add screenshot if host supports image content
299+
if (app.getHostCapabilities()?.updateModelContext?.image) {
300+
try {
301+
// Scale down to reduce token usage (tokens depend on dimensions)
302+
const sourceCanvas = cesiumViewer.canvas;
303+
const scale = Math.min(
304+
1,
305+
MAX_MODEL_CONTEXT_UPDATE_IMAGE_DIMENSION /
306+
Math.max(sourceCanvas.width, sourceCanvas.height),
307+
);
308+
const targetWidth = Math.round(sourceCanvas.width * scale);
309+
const targetHeight = Math.round(sourceCanvas.height * scale);
310+
311+
const tempCanvas = document.createElement("canvas");
312+
tempCanvas.width = targetWidth;
313+
tempCanvas.height = targetHeight;
314+
const ctx = tempCanvas.getContext("2d");
315+
if (ctx) {
316+
ctx.drawImage(sourceCanvas, 0, 0, targetWidth, targetHeight);
317+
const dataUrl = tempCanvas.toDataURL("image/png");
318+
const base64Data = dataUrl.split(",")[1];
319+
if (base64Data) {
320+
content.push({
321+
type: "image",
322+
data: base64Data,
323+
mimeType: "image/png",
324+
});
325+
log.info(
326+
`Added screenshot to model context (${targetWidth}x${targetHeight})`,
327+
);
328+
}
329+
}
330+
} catch (err) {
331+
log.warn("Failed to capture screenshot:", err);
332+
}
333+
}
334+
335+
app.updateModelContext({ content });
426336
}, 1500);
427337
}
428338

0 commit comments

Comments
 (0)