66 * a navigate-to tool for the host to control navigation.
77 */
88import { 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;
1415const CESIUM_VERSION = "1.123" ;
1516const 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