examples: Add CesiumJS Map App example (#235)

* feat: Add CesiumJS map server example

A WebGL-based 3D globe example that demonstrates:
- CesiumJS integration with MCP Apps
- CSP configuration for external tile servers
- worker-src directive usage for tile decoding

Tools:
- geocode: Search places via OpenStreetMap Nominatim
- show-map: Display interactive 3D globe at coordinates

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* comment out app.registerTool call for now

* prettier

* fix error handling in server-utils.ts

* refactor: rename cesium-map-server to map-server

- Rename examples/cesium-map-server to examples/map-server
- Update package name to @modelcontextprotocol/server-map
- Add README.md with documentation

* style: add min-height of 400px to map container

* feat(map-server): add fullscreen support, clean UI, sharp rendering

- Add fullscreen toggle button (only shown if host supports it)
- Use autoResize: false and manually send height of 400px
- Hide Cesium UI controls (home, scene mode picker)
- Remove location label (gets stale quickly)
- Use full device pixel ratio for sharp rendering on high-DPI displays
- Resize Cesium viewer when display mode changes

* feat(map-server): add reverse geocoding (logging only) and rounded corners

- Add camera move end listener for reverse geocoding
- Debounce Nominatim API calls (1.5s) to respect rate limits
- Log location name to console on camera move
- Add nominatim.openstreetmap.org to CSP connectDomains
- Add 8px rounded corners to map container

* fix(map-server): fix pixelated rendering on high-DPI displays

- Set canvas imageRendering to 'auto' (CesiumJS defaults to pixelated)
- Set resolutionScale to window.devicePixelRatio for native resolution

* tweaks

* update model context w/ current location

* fix(map-server): improve rendering quality and add visible extent logging

Rendering fixes:
- Remove incorrect resolutionScale = devicePixelRatio which was doubling
  the scaling factor on Retina displays (2x2=4x), causing blurriness
- Disable FXAA anti-aliasing which can cause blurriness on high-DPI displays
- Keep useBrowserRecommendedResolution: false to use native devicePixelRatio

Reverse geocoding improvements:
- Add getVisibleExtent() using camera.computeViewRectangle() to get the
  visible bounding box (west, south, east, north)
- Add getScaleDimensions() to calculate visible area in km
- Add calculateNominatimZoom() to map visible extent to appropriate
  Nominatim zoom level (3=country to 18=building)
- Log visible extent with dimensions on each camera move
- Pass zoom parameter to Nominatim for context-aware geocoding results
- Update model context with visible area dimensions

* fix(map-server): direct positioning + wait for tiles before showing

Changes:
- Remove zoom granularity logic from reverse geocoding (was not working)
- Keep extent logging in both console and model context
- Replace flyTo animation with instant setView positioning
- Add waitForTilesLoaded() to detect when tiles finish loading
- Keep loading spinner visible until:
  1. Tool input positions camera to destination
  2. Tiles at destination are fully loaded
- Add 5-second fallback timeout if no tool input received
- Add 10-second timeout for tile loading to prevent infinite wait

* feat(map-server): use viewbox search to find visible places

Replace center-point reverse geocoding with Nominatim viewbox search:
- Add searchPlacesInBox() that queries Nominatim with bounded viewbox
- Uses layer=address&featuretype=settlement to find cities/towns
- Returns up to 5 visible places in the current view
- Update model context with 'Visible places: City1, City2, ...'
- Still includes center coordinates and extent dimensions

This provides better context about what's actually visible on the map
rather than just the address at the center point.

* fix(map-server): use CARTO @2x retina tiles for sharp rendering

Root cause: Standard OSM tiles are 256x256 pixels. On Retina displays
(devicePixelRatio=2), these get scaled up 2x, making them look pixelated.

Solution: Switch to CARTO Voyager tiles which provide @2x retina support:
- Detect high-DPI displays using window.devicePixelRatio
- Use 512x512 tiles with @2x suffix on Retina displays
- Use 256x256 tiles on standard displays
- Set tileWidth/tileHeight to match actual tile size
- Use CARTO subdomains (a,b,c,d) for load balancing
- Update CSP to allow CARTO tile domains

CARTO Voyager provides clean OSM-based tiles with free @2x retina support,
no API key required.

* fix(map-server): use OSM tiles and fix reverse geocoding

- Switch back from CARTO to standard OSM tiles (tile.openstreetmap.org)
  for better detail and label rendering
- Fix geocoding: use reverse geocode API instead of search API
  (search API requires a q= parameter, returns 400 without it)
- Update CSP domains for OSM tile server

* fix(map-server): use *.openstreetmap.org wildcard in CSP

Simplify CSP to use wildcard for both tiles and geocoding:
- connectDomains: *.openstreetmap.org covers tile.* and nominatim.*
- resourceDomains: *.openstreetmap.org for tile images

* feat(map-server): multi-point sampling for visible places

- Sample multiple points in visible extent to discover places
- Adaptive sampling based on extent size:
  - < 30km: center only (city zoom)
  - 30-100km: center + 2 corners
  - > 100km: center + 4 quadrants
- Proper rate limiting for Nominatim (1.1s between requests)
- Shows 'Visible places: City1, City2, ...' in model context
- Fix: move geocode tool registration inside createServer()

* format

* fix(map-server): fix Express listen callback + add e2e tests

- Fix incorrect Express listen() callback signature (err param doesn't exist)
- Add map-server to e2e test suite with SLOW_SERVERS timeout (5s for tiles)
- Improve comment about experimental ui/update-model-context API

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Olivier Chafik 2026-01-12 12:13:56 +00:00 • committed by GitHub
commit 366aa9b66e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 1617 additions and 35 deletions

View file

@ -0,0 +1,90 @@
# Example: Interactive Map
Interactive 3D globe viewer using CesiumJS with OpenStreetMap tiles. Demonstrates geocoding integration and full MCP App capabilities.
## Features
- **3D Globe Rendering**: Interactive CesiumJS globe with rotation, zoom, and 3D perspective
- **Geocoding**: Search for places using OpenStreetMap Nominatim (no API key required)
- **OpenStreetMap Tiles**: Uses free OSM tile server (no Cesium Ion token needed)
- **Dynamic Loading**: CesiumJS loaded from CDN at runtime for smaller bundle size
## Running
1. Install dependencies:
```bash
npm install
```
2. Build and start the server:
```bash
npm run start:http # for Streamable HTTP transport
# OR
npm run start:stdio # for stdio transport
```
3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.
## Tools
### `geocode`
Search for places by name or address. Returns coordinates and bounding boxes.
```json
{
"query": "Eiffel Tower"
}
```
Returns up to 5 matches with lat/lon coordinates and bounding boxes.
### `show-map`
Display the 3D globe zoomed to a bounding box.
```json
{
"west": 2.29,
"south": 48.85,
"east": 2.3,
"north": 48.86,
"label": "Eiffel Tower"
}
```
Defaults to London if no coordinates provided.
## Architecture
### Server (`server.ts`)
Exposes two tools:
- `geocode` - Queries OpenStreetMap Nominatim API with rate limiting
- `show-map` - Renders the CesiumJS globe UI at a specified location
Configures Content Security Policy to allow fetching tiles from OSM and Cesium CDN.
### App (`src/mcp-app.ts`)
Vanilla TypeScript app that:
- Dynamically loads CesiumJS from CDN
- Initializes globe with OpenStreetMap imagery (no Ion token)
- Receives tool inputs via the MCP App SDK
- Handles camera navigation to specified bounding boxes
## Key Files
- [`server.ts`](server.ts) - MCP server with geocode and show-map tools
- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - CesiumJS globe UI
- [`server-utils.ts`](server-utils.ts) - HTTP server utilities
## Notes
- Rate limiting is applied to Nominatim requests (1 request per second per their usage policy)
- The globe works in sandboxed iframes with appropriate CSP configuration
- No external API keys required - uses only open data sources

View file

@ -0,0 +1,73 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CesiumJS Globe</title>
<!-- CesiumJS is loaded dynamically from CDN in mcp-app.ts because static
<script src=""> tags don't work in srcdoc iframes -->
<style>
html, body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
}
#cesiumContainer {
width: 100%;
height: 100%;
border-radius: .75rem;
overflow: hidden;
}
#fullscreen-btn {
position: absolute;
top: 10px;
right: 10px;
width: 36px;
height: 36px;
background: rgba(0, 0, 0, 0.7);
border: none;
border-radius: 6px;
cursor: pointer;
z-index: 1000;
display: none;
align-items: center;
justify-content: center;
transition: background 0.2s;
}
#fullscreen-btn:hover {
background: rgba(0, 0, 0, 0.85);
}
#fullscreen-btn svg {
width: 20px;
height: 20px;
fill: white;
}
#loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(0, 0, 0, 0.8);
color: white;
padding: 20px 30px;
border-radius: 8px;
font-size: 16px;
z-index: 1001;
}
</style>
</head>
<body>
<div id="cesiumContainer"></div>
<button id="fullscreen-btn" title="Toggle fullscreen">
<!-- Expand icon (shown when inline) -->
<svg id="expand-icon" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
<!-- Compress icon (shown when fullscreen) -->
<svg id="compress-icon" style="display:none" viewBox="0 0 24 24"><path d="M5 16h3v3h2v-5H5v2zm3-8H5v2h5V5H8v3zm6 11h2v-3h3v-2h-5v5zm2-11V5h-2v5h5V8h-3z"/></svg>
</button>
<div id="loading">Loading globe...</div>
<script type="module" src="/src/mcp-app.ts"></script>
</body>
</html>

View file

@ -0,0 +1,46 @@
{
"name": "@modelcontextprotocol/server-map",
"version": "0.1.0",
"type": "module",
"description": "MCP App Server example with CesiumJS 3D globe and geocoding",
"repository": {
"type": "git",
"url": "https://github.com/modelcontextprotocol/ext-apps",
"directory": "examples/map-server"
},
"license": "MIT",
"main": "server.ts",
"files": [
"server.ts",
"server-utils.ts",
"dist"
],
"scripts": {
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build",
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
"serve:http": "bun server.ts",
"serve:stdio": "bun server.ts --stdio",
"start": "npm run start:http",
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
"start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
"dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'",
"prepublishOnly": "npm run build"
},
"dependencies": {
"@modelcontextprotocol/ext-apps": "^0.3.1",
"@modelcontextprotocol/sdk": "^1.24.0",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
}

View file

@ -0,0 +1,68 @@
/**
* Shared utilities for running MCP servers with Streamable HTTP transport.
*/
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
import cors from "cors";
import type { Request, Response } from "express";
export interface ServerOptions {
port: number;
name?: string;
}
/**
* Starts an MCP server with Streamable HTTP transport in stateless mode.
*
* @param createServer - Factory function that creates a new McpServer instance per request.
* @param options - Server configuration options.
*/
export async function startServer(
createServer: () => McpServer,
options: ServerOptions,
): Promise<void> {
const { port, name = "MCP Server" } = options;
const app = createMcpExpressApp({ host: "0.0.0.0" });
app.use(cors());
app.all("/mcp", async (req: Request, res: Response) => {
const server = createServer();
const transport = new StreamableHTTPServerTransport({
sessionIdGenerator: undefined,
});
res.on("close", () => {
transport.close().catch(() => {});
server.close().catch(() => {});
});
try {
await server.connect(transport);
await transport.handleRequest(req, res, req.body);
} catch (error) {
console.error("MCP error:", error);
if (!res.headersSent) {
res.status(500).json({
jsonrpc: "2.0",
error: { code: -32603, message: "Internal server error" },
id: null,
});
}
}
});
const httpServer = app.listen(port, () => {
console.log(`${name} listening on http://localhost:${port}/mcp`);
});
const shutdown = () => {
console.log("\nShutting down...");
httpServer.close(() => process.exit(0));
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
}

View file

@ -0,0 +1,268 @@
/**
* CesiumJS Map MCP Server
*
* Provides tools for:
* - geocode: Search for places using OpenStreetMap Nominatim
* - show-map: Display an interactive 3D globe at a given location
*/
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
import type {
CallToolResult,
ReadResourceResult,
} from "@modelcontextprotocol/sdk/types.js";
import fs from "node:fs/promises";
import path from "node:path";
import { z } from "zod";
import {
registerAppTool,
registerAppResource,
RESOURCE_MIME_TYPE,
RESOURCE_URI_META_KEY,
} from "@modelcontextprotocol/ext-apps/server";
import { startServer } from "./server-utils.js";
const DIST_DIR = path.join(import.meta.dirname, "dist");
const RESOURCE_URI = "ui://cesium-map/mcp-app.html";
// Nominatim API response type
interface NominatimResult {
place_id: number;
licence: string;
osm_type: string;
osm_id: number;
lat: string;
lon: string;
display_name: string;
boundingbox: [string, string, string, string]; // [south, north, west, east]
class: string;
type: string;
importance: number;
}
// 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
/**
* Query Nominatim geocoding API with rate limiting
*/
async function geocodeWithNominatim(query: string): Promise<NominatimResult[]> {
// Respect rate limit
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();
const params = new URLSearchParams({
q: query,
format: "json",
limit: "5",
});
const response = await fetch(
`https://nominatim.openstreetmap.org/search?${params}`,
{
headers: {
"User-Agent":
"MCP-CesiumMap-Example/1.0 (https://github.com/modelcontextprotocol)",
},
},
);
if (!response.ok) {
throw new Error(
`Nominatim API error: ${response.status} ${response.statusText}`,
);
}
return response.json();
}
/**
* Creates a new MCP server instance with tools and resources registered.
* Each HTTP session needs its own server instance because McpServer only supports one transport.
*/
export function createServer(): McpServer {
const server = new McpServer({
name: "CesiumJS Map Server",
version: "1.0.0",
});
// CSP configuration for external tile sources
const cspMeta = {
ui: {
csp: {
// Allow fetching tiles from OSM (tiles + geocoding) and Cesium assets
connectDomains: [
"https://*.openstreetmap.org", // OSM tiles + Nominatim geocoding
"https://cesium.com",
"https://*.cesium.com",
],
// Allow loading tile images, scripts, and Cesium CDN resources
resourceDomains: [
"https://*.openstreetmap.org", // OSM map tiles (covers tile.openstreetmap.org)
"https://cesium.com",
"https://*.cesium.com",
],
},
},
};
// Register the CesiumJS map resource with CSP for external tile sources
registerAppResource(
server,
RESOURCE_URI,
RESOURCE_URI,
{ mimeType: RESOURCE_MIME_TYPE },
async (): Promise<ReadResourceResult> => {
const html = await fs.readFile(
path.join(DIST_DIR, "mcp-app.html"),
"utf-8",
);
return {
contents: [
// _meta must be on the content item, not the resource metadata
{
uri: RESOURCE_URI,
mimeType: RESOURCE_MIME_TYPE,
text: html,
_meta: cspMeta,
},
],
};
},
);
// show-map tool - displays the CesiumJS globe
// Default bounding box: London area
registerAppTool(
server,
"show-map",
{
title: "Show Map",
description:
"Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location.",
inputSchema: {
west: z
.number()
.optional()
.default(-0.5)
.describe("Western longitude (-180 to 180)"),
south: z
.number()
.optional()
.default(51.3)
.describe("Southern latitude (-90 to 90)"),
east: z
.number()
.optional()
.default(0.3)
.describe("Eastern longitude (-180 to 180)"),
north: z
.number()
.optional()
.default(51.7)
.describe("Northern latitude (-90 to 90)"),
label: z
.string()
.optional()
.describe("Optional label to display on the map"),
},
_meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI },
},
async ({ west, south, east, north, label }): Promise<CallToolResult> => ({
content: [
{
type: "text",
text: `Displaying globe at: W:${west.toFixed(4)}, S:${south.toFixed(4)}, E:${east.toFixed(4)}, N:${north.toFixed(4)}${label ? ` (${label})` : ""}`,
},
],
}),
);
// geocode tool - searches for places using Nominatim (no UI)
server.registerTool(
"geocode",
{
title: "Geocode",
description:
"Search for places using OpenStreetMap. Returns coordinates and bounding boxes for up to 5 matches.",
inputSchema: {
query: z
.string()
.describe(
"Place name or address to search for (e.g., 'Paris', 'Golden Gate Bridge', '1600 Pennsylvania Ave')",
),
},
},
async ({ query }): Promise<CallToolResult> => {
try {
const results = await geocodeWithNominatim(query);
if (results.length === 0) {
return {
content: [
{ type: "text", text: `No results found for "${query}"` },
],
};
}
const formattedResults = results.map((r) => ({
displayName: r.display_name,
lat: parseFloat(r.lat),
lon: parseFloat(r.lon),
boundingBox: {
south: parseFloat(r.boundingbox[0]),
north: parseFloat(r.boundingbox[1]),
west: parseFloat(r.boundingbox[2]),
east: parseFloat(r.boundingbox[3]),
},
type: r.type,
importance: r.importance,
}));
const textContent = formattedResults
.map(
(r, i) =>
`${i + 1}. ${r.displayName}\n Coordinates: ${r.lat.toFixed(6)}, ${r.lon.toFixed(6)}\n Bounding box: W:${r.boundingBox.west.toFixed(4)}, S:${r.boundingBox.south.toFixed(4)}, E:${r.boundingBox.east.toFixed(4)}, N:${r.boundingBox.north.toFixed(4)}`,
)
.join("\n\n");
return {
content: [{ type: "text", text: textContent }],
};
} catch (error) {
return {
content: [
{
type: "text",
text: `Geocoding error: ${error instanceof Error ? error.message : String(error)}`,
},
],
isError: true,
};
}
},
);
return server;
}
async function main() {
if (process.argv.includes("--stdio")) {
await createServer().connect(new StdioServerTransport());
} else {
const port = parseInt(process.env.PORT ?? "3001", 10);
await startServer(createServer, { port, name: "CesiumJS Map Server" });
}
}
main().catch((e) => {
console.error(e);
process.exit(1);
});

View file

@ -0,0 +1,843 @@
/**
* CesiumJS Globe MCP App
*
* Displays a 3D globe using CesiumJS with OpenStreetMap tiles.
* Receives initial bounding box from the show-map tool and exposes
* a navigate-to tool for the host to control navigation.
*/
import { App } from "@modelcontextprotocol/ext-apps";
import { z } from "zod";
// TypeScript declaration for Cesium loaded from CDN
// eslint-disable-next-line @typescript-eslint/no-explicit-any
declare let Cesium: any;
const CESIUM_VERSION = "1.123";
const CESIUM_BASE_URL = `https://cesium.com/downloads/cesiumjs/releases/${CESIUM_VERSION}/Build/Cesium`;
/**
* Dynamically load CesiumJS from CDN
* This is necessary because external <script src=""> tags don't work in srcdoc iframes
*/
async function loadCesium(): Promise<void> {
// Check if already loaded
if (typeof Cesium !== "undefined") {
return;
}
// Load CSS first
const cssLink = document.createElement("link");
cssLink.rel = "stylesheet";
cssLink.href = `${CESIUM_BASE_URL}/Widgets/widgets.css`;
document.head.appendChild(cssLink);
// Load JS
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = `${CESIUM_BASE_URL}/Cesium.js`;
script.onload = () => {
// Set CESIUM_BASE_URL for asset loading
(window as any).CESIUM_BASE_URL = CESIUM_BASE_URL;
resolve();
};
script.onerror = () =>
reject(new Error("Failed to load CesiumJS from CDN"));
document.head.appendChild(script);
});
}
const log = {
info: console.log.bind(console, "[APP]"),
warn: console.warn.bind(console, "[APP]"),
error: console.error.bind(console, "[APP]"),
};
interface BoundingBox {
west: number;
south: number;
east: number;
north: number;
}
// CesiumJS viewer instance
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let viewer: any = null;
// Debounce timer for reverse geocoding
let reverseGeocodeTimer: ReturnType<typeof setTimeout> | null = null;
/**
* Get the center point of the current camera view
*/
function getCameraCenter(
cesiumViewer: any,
): { lat: number; lon: number } | null {
try {
const cartographic = cesiumViewer.camera.positionCartographic;
return {
lat: Cesium.Math.toDegrees(cartographic.latitude),
lon: Cesium.Math.toDegrees(cartographic.longitude),
};
} catch {
return null;
}
}
/**
* Get the visible extent (bounding box) of the current camera view
* Returns null if the view doesn't intersect the ellipsoid (e.g., looking at sky)
*/
function getVisibleExtent(cesiumViewer: any): BoundingBox | null {
try {
const rect = cesiumViewer.camera.computeViewRectangle();
if (!rect) return null;
return {
west: Cesium.Math.toDegrees(rect.west),
south: Cesium.Math.toDegrees(rect.south),
east: Cesium.Math.toDegrees(rect.east),
north: Cesium.Math.toDegrees(rect.north),
};
} catch {
return null;
}
}
/**
* Calculate approximate map scale dimensions in kilometers
*/
function getScaleDimensions(extent: BoundingBox): {
widthKm: number;
heightKm: number;
} {
// Approximate conversion: 1 degree latitude ≈ 111 km
// Longitude varies by latitude, use midpoint latitude for approximation
const midLat = (extent.north + extent.south) / 2;
const latRad = (midLat * Math.PI) / 180;
const heightDeg = Math.abs(extent.north - extent.south);
const widthDeg = Math.abs(extent.east - extent.west);
// Handle wrap-around at 180/-180 longitude
const adjustedWidthDeg = widthDeg > 180 ? 360 - widthDeg : widthDeg;
const heightKm = heightDeg * 111;
const widthKm = adjustedWidthDeg * 111 * Math.cos(latRad);
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
*/
function scheduleLocationUpdate(cesiumViewer: any): void {
if (reverseGeocodeTimer) {
clearTimeout(reverseGeocodeTimer);
}
// Debounce to 1.5 seconds before starting geocoding
reverseGeocodeTimer = setTimeout(async () => {
const center = getCameraCenter(cesiumViewer);
const extent = getVisibleExtent(cesiumViewer);
if (!extent) {
log.info("No visible extent (camera looking at sky?)");
return;
}
const { widthKm, heightKm } = getScaleDimensions(extent);
const extentInfo =
`Extent: [${extent.west.toFixed(4)}, ${extent.south.toFixed(4)}, ` +
`${extent.east.toFixed(4)}, ${extent.north.toFixed(4)}] ` +
`(${widthKm.toFixed(1)}km × ${heightKm.toFixed(1)}km)`;
log.info(extentInfo);
// Get places visible in the extent (samples multiple points for large areas)
const places = await getVisiblePlaces(extent);
const placesText =
places.length > 0 ? `Visible places: ${places.join(", ")}` : "";
if (places.length > 0 || center) {
const centerText = center
? `Center: ${center.lat.toFixed(4)}, ${center.lon.toFixed(4)}`
: "";
const contextText = [placesText, centerText, extentInfo]
.filter(Boolean)
.join("\n");
log.info("Updating model context:", contextText);
// EXPERIMENTAL: This request method isn't standard yet and may change.
// It updates the model's context with the current map location.
// See https://github.com/modelcontextprotocol/ext-apps/pull/125
// If the host doesn't support this, the request will silently fail.
app.request(
<any>{
method: "ui/update-model-context",
params: {
role: "user",
content: [{ type: "text", text: contextText }],
},
},
z.object({}),
);
}
}, 1500);
}
/**
* Initialize CesiumJS with OpenStreetMap imagery (no Ion token required)
* Based on: https://gist.github.com/banesullivan/e3cc15a3e2e865d5ab8bae6719733752
*/
async function initCesium(): Promise<any> {
log.info("Starting CesiumJS initialization...");
log.info("Window location:", window.location.href);
log.info("Document origin:", document.location.origin);
// Disable Cesium Ion completely - we use open tile sources
Cesium.Ion.defaultAccessToken = undefined;
log.info("Ion disabled");
// Set default camera view rectangle (required when Ion is disabled)
Cesium.Camera.DEFAULT_VIEW_RECTANGLE = Cesium.Rectangle.fromDegrees(
-130,
20,
-60,
55, // USA bounding box
);
log.info("Default view rectangle set");
// Create viewer first with NO base layer, then add OSM imagery
const cesiumViewer = new Cesium.Viewer("cesiumContainer", {
// Start with no base layer - we'll add OSM manually
baseLayer: false,
// Disable Ion-dependent features
geocoder: false,
baseLayerPicker: false,
// Simplify UI - hide all controls
animation: false,
timeline: false,
homeButton: false,
sceneModePicker: false,
navigationHelpButton: false,
fullscreenButton: false,
// Disable terrain (requires Ion)
terrainProvider: undefined,
// WebGL context options for sandboxed iframe rendering
contextOptions: {
webgl: {
preserveDrawingBuffer: true,
alpha: true,
},
},
// Use full device pixel ratio for sharp rendering on high-DPI displays
useBrowserRecommendedResolution: false,
});
log.info("Viewer created");
// Ensure the globe is visible
cesiumViewer.scene.globe.show = true;
cesiumViewer.scene.globe.enableLighting = false;
cesiumViewer.scene.globe.baseColor = Cesium.Color.DARKSLATEGRAY;
// Disable request render mode - helps with initial rendering
cesiumViewer.scene.requestRenderMode = false;
// Fix pixelated rendering on high-DPI displays
// CesiumJS sets image-rendering: pixelated by default which looks bad on scaled displays
// Setting to "auto" allows the browser to apply smooth interpolation
cesiumViewer.canvas.style.imageRendering = "auto";
// Note: DO NOT set resolutionScale = devicePixelRatio here!
// When useBrowserRecommendedResolution: false, Cesium already uses devicePixelRatio.
// Setting resolutionScale = devicePixelRatio would double the scaling (e.g., 2x2=4x on Retina)
// which causes blurriness when scaled back down. Leave resolutionScale at default (1.0).
// Disable FXAA anti-aliasing which can cause blurriness on high-DPI displays
cesiumViewer.scene.postProcessStages.fxaa.enabled = false;
log.info("Globe configured");
// Create and add map imagery layer
// Use standard OSM tiles - they render sharply with Cesium's settings
log.info("Creating OpenStreetMap imagery provider...");
try {
// Use standard OpenStreetMap tile server
// While these are 256x256 tiles, Cesium handles the rendering well
// with useBrowserRecommendedResolution: false
const osmProvider = new Cesium.UrlTemplateImageryProvider({
url: "https://tile.openstreetmap.org/{z}/{x}/{y}.png",
minimumLevel: 0,
maximumLevel: 19,
credit: new Cesium.Credit(
'© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
true,
),
});
log.info("OSM provider created (256x256 tiles)");
// Log any imagery provider errors
osmProvider.errorEvent.addEventListener((error: any) => {
log.error("OSM imagery provider error:", error);
});
// Wait for provider to be ready
if (osmProvider.ready !== undefined && !osmProvider.ready) {
log.info("Waiting for OSM provider to be ready...");
await osmProvider.readyPromise;
log.info("OSM provider ready");
}
// Add the imagery layer to the viewer
cesiumViewer.imageryLayers.addImageryProvider(osmProvider);
log.info(
"OSM imagery layer added, layer count:",
cesiumViewer.imageryLayers.length,
);
// Log tile load events for debugging
cesiumViewer.scene.globe.tileLoadProgressEvent.addEventListener(
(queueLength: number) => {
if (queueLength > 0) {
log.info("Tiles loading, queue length:", queueLength);
}
},
);
// Force a render
cesiumViewer.scene.requestRender();
log.info("Render requested");
} catch (error) {
log.error("Failed to create OSM provider:", error);
}
// Fly to default USA view - using Rectangle is most reliable
log.info("Flying to USA rectangle...");
cesiumViewer.camera.flyTo({
destination: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55),
duration: 0,
});
// Force a few initial renders to ensure the globe is visible
// This helps with sandboxed iframe contexts where initial rendering may be delayed
let renderCount = 0;
const initialRenderLoop = () => {
cesiumViewer.render();
cesiumViewer.scene.requestRender();
renderCount++;
if (renderCount < 20) {
setTimeout(initialRenderLoop, 50);
} else {
log.info("Initial rendering complete");
}
};
initialRenderLoop();
log.info("Camera positioned, initial rendering started");
// Set up camera move end listener for reverse geocoding
cesiumViewer.camera.moveEnd.addEventListener(() => {
scheduleLocationUpdate(cesiumViewer);
});
log.info("Camera move listener registered");
return cesiumViewer;
}
/**
* Calculate camera destination for a bounding box
*/
function calculateDestination(bbox: BoundingBox): {
destination: any;
centerLon: number;
centerLat: number;
height: number;
} {
const centerLon = (bbox.west + bbox.east) / 2;
const centerLat = (bbox.south + bbox.north) / 2;
const lonSpan = Math.abs(bbox.east - bbox.west);
const latSpan = Math.abs(bbox.north - bbox.south);
const maxSpan = Math.max(lonSpan, latSpan);
// Height in meters - larger bbox = higher altitude
// Minimum 100km for small areas, scale up for larger areas
const height = Math.max(100000, maxSpan * 111000 * 5);
const actualHeight = Math.max(height, 500000);
const destination = Cesium.Cartesian3.fromDegrees(
centerLon,
centerLat,
actualHeight,
);
return { destination, centerLon, centerLat, height: actualHeight };
}
/**
* Position camera instantly to view a bounding box (no animation)
*/
function setViewToBoundingBox(cesiumViewer: any, bbox: BoundingBox): void {
const { destination, centerLon, centerLat, height } =
calculateDestination(bbox);
log.info("setView destination:", centerLon, centerLat, "height:", height);
cesiumViewer.camera.setView({
destination,
orientation: {
heading: 0,
pitch: Cesium.Math.toRadians(-90), // Look straight down
roll: 0,
},
});
log.info(
"setView complete, camera height:",
cesiumViewer.camera.positionCartographic.height,
);
}
/**
* Wait for globe tiles to finish loading
*/
function waitForTilesLoaded(cesiumViewer: any): Promise<void> {
return new Promise((resolve) => {
// Check if already loaded
if (cesiumViewer.scene.globe.tilesLoaded) {
log.info("Tiles already loaded");
resolve();
return;
}
log.info("Waiting for tiles to load...");
const removeListener =
cesiumViewer.scene.globe.tileLoadProgressEvent.addEventListener(
(queueLength: number) => {
log.info("Tile queue:", queueLength);
if (queueLength === 0 && cesiumViewer.scene.globe.tilesLoaded) {
log.info("All tiles loaded");
removeListener();
resolve();
}
},
);
// Timeout after 10 seconds to prevent infinite wait
setTimeout(() => {
log.warn("Tile loading timeout, proceeding anyway");
removeListener();
resolve();
}, 10000);
});
}
/**
* Hide the loading indicator
*/
function hideLoading(): void {
const loadingEl = document.getElementById("loading");
if (loadingEl) {
loadingEl.style.display = "none";
}
}
// Preferred height for inline mode (px)
const PREFERRED_INLINE_HEIGHT = 400;
// Current display mode
let currentDisplayMode: "inline" | "fullscreen" | "pip" = "inline";
// Create App instance with tool capabilities
// autoResize: false - we manually send size since map fills its container
const app = new App(
{ name: "CesiumJS Globe", version: "1.0.0" },
{ tools: { listChanged: true } },
{ autoResize: false },
);
/**
* Update fullscreen button visibility and icon based on current state
*/
function updateFullscreenButton(): void {
const btn = document.getElementById("fullscreen-btn");
const expandIcon = document.getElementById("expand-icon");
const compressIcon = document.getElementById("compress-icon");
if (!btn || !expandIcon || !compressIcon) return;
// Check if fullscreen is available from host
const context = app.getHostContext();
const availableModes = context?.availableDisplayModes ?? ["inline"];
const canFullscreen = availableModes.includes("fullscreen");
// Show button only if fullscreen is available
btn.style.display = canFullscreen ? "flex" : "none";
// Toggle icons based on current mode
const isFullscreen = currentDisplayMode === "fullscreen";
expandIcon.style.display = isFullscreen ? "none" : "block";
compressIcon.style.display = isFullscreen ? "block" : "none";
btn.title = isFullscreen ? "Exit fullscreen" : "Enter fullscreen";
}
/**
* Request display mode change from host
*/
async function toggleFullscreen(): Promise<void> {
const targetMode =
currentDisplayMode === "fullscreen" ? "inline" : "fullscreen";
log.info("Requesting display mode:", targetMode);
try {
const result = await app.requestDisplayMode({ mode: targetMode });
log.info("Display mode result:", result.mode);
// Note: actual mode change will come via onhostcontextchanged
} catch (error) {
log.error("Failed to change display mode:", error);
}
}
/**
* Handle display mode changes - resize Cesium and update UI
*/
function handleDisplayModeChange(
newMode: "inline" | "fullscreen" | "pip",
): void {
if (newMode === currentDisplayMode) return;
log.info("Display mode changed:", currentDisplayMode, "->", newMode);
currentDisplayMode = newMode;
// Update button state
updateFullscreenButton();
// Tell Cesium to resize to new container dimensions
if (viewer) {
// Small delay to let the host finish resizing
setTimeout(() => {
viewer.resize();
viewer.scene.requestRender();
log.info("Cesium resized for", newMode, "mode");
}, 100);
}
}
// Register handlers BEFORE connecting
app.onteardown = async () => {
log.info("App is being torn down");
if (viewer) {
viewer.destroy();
viewer = null;
}
return {};
};
app.onerror = log.error;
// Listen for host context changes (display mode, theme, etc.)
app.onhostcontextchanged = (params) => {
log.info("Host context changed:", params);
if (params.displayMode) {
handleDisplayModeChange(
params.displayMode as "inline" | "fullscreen" | "pip",
);
}
// Update button if available modes changed
if (params.availableDisplayModes) {
updateFullscreenButton();
}
};
// Handle initial tool input (bounding box from show-map tool)
app.ontoolinput = async (params) => {
log.info("Received tool input:", params);
const args = params.arguments as
| {
boundingBox?: BoundingBox;
west?: number;
south?: number;
east?: number;
north?: number;
label?: string;
}
| undefined;
if (args && viewer) {
// Handle both nested boundingBox and flat format
let bbox: BoundingBox | null = null;
if (args.boundingBox) {
bbox = args.boundingBox;
} else if (
args.west !== undefined &&
args.south !== undefined &&
args.east !== undefined &&
args.north !== undefined
) {
bbox = {
west: args.west,
south: args.south,
east: args.east,
north: args.north,
};
}
if (bbox) {
log.info("Positioning camera to bbox:", bbox);
// Position camera instantly (no animation)
setViewToBoundingBox(viewer, bbox);
// Wait for tiles to load at this location
await waitForTilesLoaded(viewer);
// Now hide loading indicator
hideLoading();
log.info(
"Camera positioned, tiles loaded. Height:",
viewer.camera.positionCartographic.height,
);
}
}
};
/*
Register tools for the model to interact w/ this component
Needs https://github.com/modelcontextprotocol/ext-apps/pull/72
*/
// app.registerTool(
// "navigate-to",
// {
// title: "Navigate To",
// description: "Navigate the globe to a new bounding box location",
// inputSchema: z.object({
// west: z.number().describe("Western longitude (-180 to 180)"),
// south: z.number().describe("Southern latitude (-90 to 90)"),
// east: z.number().describe("Eastern longitude (-180 to 180)"),
// north: z.number().describe("Northern latitude (-90 to 90)"),
// duration: z
// .number()
// .optional()
// .describe("Animation duration in seconds (default: 2)"),
// label: z.string().optional().describe("Optional label to display"),
// }),
// },
// async (args) => {
// if (!viewer) {
// return {
// content: [
// { type: "text" as const, text: "Error: Viewer not initialized" },
// ],
// isError: true,
// };
// }
// const bbox: BoundingBox = {
// west: args.west,
// south: args.south,
// east: args.east,
// north: args.north,
// };
// await flyToBoundingBox(viewer, bbox, args.duration ?? 2);
// setLabel(args.label);
// return {
// content: [
// {
// type: "text" as const,
// text: `Navigated to: W:${bbox.west.toFixed(4)}, S:${bbox.south.toFixed(4)}, E:${bbox.east.toFixed(4)}, N:${bbox.north.toFixed(4)}${args.label ? ` (${args.label})` : ""}`,
// },
// ],
// };
// },
// );
// Initialize Cesium and connect to host
async function init() {
try {
log.info("Loading CesiumJS from CDN...");
await loadCesium();
log.info("CesiumJS loaded successfully");
viewer = await initCesium();
// Don't hide loading here - we wait for tool input to position camera
// and for tiles to load before hiding the loading indicator
log.info("CesiumJS initialized, waiting for tool input...");
// Fallback: if no tool input received within 5 seconds, hide loading anyway
// (handles case where map is shown without a specific bbox)
setTimeout(async () => {
const loadingEl = document.getElementById("loading");
if (loadingEl && loadingEl.style.display !== "none") {
log.info("No tool input received, waiting for initial tiles...");
await waitForTilesLoaded(viewer!);
hideLoading();
}
}, 5000);
// Connect to host (auto-creates PostMessageTransport)
await app.connect();
log.info("Connected to host");
// Get initial display mode from host context
const context = app.getHostContext();
if (context?.displayMode) {
currentDisplayMode = context.displayMode as
| "inline"
| "fullscreen"
| "pip";
}
log.info("Initial display mode:", currentDisplayMode);
// Tell host our preferred size for inline mode
if (currentDisplayMode === "inline") {
app.sendSizeChanged({ height: PREFERRED_INLINE_HEIGHT });
log.info("Sent initial size:", PREFERRED_INLINE_HEIGHT);
}
// Set up fullscreen button
updateFullscreenButton();
const fullscreenBtn = document.getElementById("fullscreen-btn");
if (fullscreenBtn) {
fullscreenBtn.addEventListener("click", toggleFullscreen);
}
} catch (error) {
log.error("Failed to initialize:", error);
const loadingEl = document.getElementById("loading");
if (loadingEl) {
loadingEl.textContent = `Error: ${error instanceof Error ? error.message : String(error)}`;
loadingEl.style.background = "rgba(200, 0, 0, 0.8)";
}
}
}
init();

View file

@ -0,0 +1,134 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CesiumJS Standalone Test</title>
<link href="https://cesium.com/downloads/cesiumjs/releases/1.123/Build/Cesium/Widgets/widgets.css" rel="stylesheet">
<style>
html, body, #cesiumContainer {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
#status {
position: absolute;
top: 10px;
left: 10px;
background: rgba(0, 0, 0, 0.8);
color: #0f0;
padding: 10px;
font-family: monospace;
font-size: 12px;
z-index: 1000;
max-width: 400px;
max-height: 300px;
overflow-y: auto;
}
</style>
</head>
<body>
<div id="cesiumContainer"></div>
<div id="status">Loading CesiumJS...</div>
<script src="https://cesium.com/downloads/cesiumjs/releases/1.123/Build/Cesium/Cesium.js"></script>
<script>
const status = document.getElementById('status');
function log(msg) {
console.log(msg);
status.innerHTML += '<br>' + msg;
status.scrollTop = status.scrollHeight;
}
try {
log('CesiumJS version: ' + Cesium.VERSION);
// Disable Cesium Ion
Cesium.Ion.defaultAccessToken = undefined;
log('Ion disabled');
// Set default camera view rectangle (required when Ion is disabled)
Cesium.Camera.DEFAULT_VIEW_RECTANGLE = Cesium.Rectangle.fromDegrees(
-130, 20, -60, 55 // USA bounding box
);
log('Default view rectangle set');
// Create viewer with no base layer - expose globally for debugging
const viewer = new Cesium.Viewer('cesiumContainer', {
baseLayer: false,
geocoder: false,
baseLayerPicker: false,
animation: false,
timeline: false,
homeButton: true,
sceneModePicker: true,
navigationHelpButton: false,
fullscreenButton: false,
terrainProvider: undefined,
});
window.viewer = viewer; // Expose for debugging
log('Viewer created');
// Set globe properties
viewer.scene.globe.show = true;
viewer.scene.globe.enableLighting = false;
viewer.scene.globe.baseColor = Cesium.Color.DARKSLATEGRAY;
// Disable request render mode - force continuous rendering
viewer.scene.requestRenderMode = false;
log('Globe configured, continuous rendering enabled');
// Create OSM imagery provider
log('Creating OSM provider...');
const osmProvider = new Cesium.UrlTemplateImageryProvider({
url: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
minimumLevel: 0,
maximumLevel: 19,
credit: new Cesium.Credit('OpenStreetMap'),
});
// Add error handler
osmProvider.errorEvent.addEventListener((error) => {
log('OSM ERROR: ' + JSON.stringify(error));
});
// Add the imagery layer
const layer = viewer.imageryLayers.addImageryProvider(osmProvider);
log('OSM layer added: ' + (layer ? 'success' : 'failed'));
// Track tile loading
viewer.scene.globe.tileLoadProgressEvent.addEventListener((queueLength) => {
if (queueLength > 0) {
log('Tiles loading: ' + queueLength);
}
});
// Use flyTo with Rectangle - most reliable for initial view
log('Flying to USA rectangle...');
viewer.camera.flyTo({
destination: Cesium.Rectangle.fromDegrees(-130, 20, -60, 55),
duration: 0
});
// Force renders using setInterval (bypasses rAF throttling)
let renderCount = 0;
const intervalId = setInterval(() => {
viewer.render();
renderCount++;
if (renderCount <= 3) {
log('Interval render #' + renderCount);
}
if (renderCount > 20) {
clearInterval(intervalId);
log('Stopped interval rendering');
}
}, 50);
log('FlyTo called, interval rendering started');
log('Initialization complete!');
} catch (e) {
log('ERROR: ' + e.message);
console.error(e);
}
</script>
</body>
</html>

View file

@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ESNext",
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"verbatimModuleSyntax": true,
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src", "server.ts"]
}

View file

@ -0,0 +1,23 @@
import { defineConfig } from "vite";
import { viteSingleFile } from "vite-plugin-singlefile";
const INPUT = process.env.INPUT;
if (!INPUT) {
throw new Error("INPUT environment variable is not set");
}
const isDevelopment = process.env.NODE_ENV === "development";
export default defineConfig({
plugins: [viteSingleFile()],
build: {
sourcemap: isDevelopment ? "inline" : undefined,
cssMinify: !isDevelopment,
minify: !isDevelopment,
rollupOptions: {
input: INPUT,
},
outDir: "dist",
emptyOutDir: false,
},
});

75
package-lock.json generated
View file

@ -96,8 +96,6 @@
},
"examples/basic-host/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -137,8 +135,6 @@
},
"examples/basic-server-preact/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -181,8 +177,6 @@
},
"examples/basic-server-react/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -222,8 +216,6 @@
},
"examples/basic-server-solid/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -263,8 +255,6 @@
},
"examples/basic-server-svelte/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -302,8 +292,6 @@
},
"examples/basic-server-vanillajs/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -343,8 +331,6 @@
},
"examples/basic-server-vue/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -383,8 +369,6 @@
},
"examples/budget-allocator-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -427,8 +411,6 @@
},
"examples/cohort-heatmap-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -467,8 +449,6 @@
},
"examples/customer-segmentation-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -508,8 +488,6 @@
},
"examples/integration-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -523,6 +501,43 @@
"dev": true,
"license": "MIT"
},
"examples/map-server": {
"name": "@modelcontextprotocol/server-map",
"version": "0.1.0",
"license": "MIT",
"dependencies": {
"@modelcontextprotocol/ext-apps": "^0.3.1",
"@modelcontextprotocol/sdk": "^1.24.0",
"zod": "^4.1.13"
},
"devDependencies": {
"@types/cors": "^2.8.19",
"@types/express": "^5.0.0",
"@types/node": "^22.0.0",
"concurrently": "^9.2.1",
"cors": "^2.8.5",
"cross-env": "^10.1.0",
"express": "^5.1.0",
"typescript": "^5.9.3",
"vite": "^6.0.0",
"vite-plugin-singlefile": "^2.3.0"
}
},
"examples/map-server/node_modules/@types/node": {
"version": "22.19.1",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"examples/map-server/node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"examples/scenario-modeler-server": {
"name": "@modelcontextprotocol/server-scenario-modeler",
"version": "0.1.0",
@ -553,8 +568,6 @@
},
"examples/scenario-modeler-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -593,8 +606,6 @@
},
"examples/sheet-music-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -634,8 +645,6 @@
},
"examples/system-monitor-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -680,8 +689,6 @@
},
"examples/threejs-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -719,8 +726,6 @@
},
"examples/video-resource-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -760,8 +765,6 @@
},
"examples/wiki-explorer-server/node_modules/@types/node": {
"version": "22.19.3",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.3.tgz",
"integrity": "sha512-1N9SBnWYOJTrNZCdh/yJE+t910Y128BoyY+zBLWhL3r0TYzlTmFdXrPwHL9DyFZmlEXNQQolTZh3KHV31QDhyA==",
"dev": true,
"license": "MIT",
"dependencies": {
@ -1777,6 +1780,10 @@
"resolved": "examples/customer-segmentation-server",
"link": true
},
"node_modules/@modelcontextprotocol/server-map": {
"resolved": "examples/map-server",
"link": true
},
"node_modules/@modelcontextprotocol/server-scenario-modeler": {
"resolved": "examples/scenario-modeler-server",
"link": true

View file

@ -13,6 +13,7 @@ const DYNAMIC_MASKS: Record<string, string[]> = {
"basic-vue": ["#server-time"], // Server time display
"cohort-heatmap": ['[class*="heatmapWrapper"]'], // Heatmap grid (random data)
"customer-segmentation": [".chart-container"], // Scatter plot (random data)
// Note: map-server uses SLOW_SERVERS timeout instead of masking to wait for tiles
"system-monitor": [
".chart-container", // CPU chart (highly dynamic)
"#status-text", // Current timestamp
@ -25,6 +26,12 @@ const DYNAMIC_MASKS: Record<string, string[]> = {
"wiki-explorer": ["#graph"], // Force-directed graph (dynamic layout)
};
// Servers that need extra stabilization time (e.g., for tile loading, WebGL init)
const SLOW_SERVERS: Record<string, number> = {
"map-server": 5000, // CesiumJS needs time for tiles to load
threejs: 2000, // Three.js WebGL initialization
};
// Server configurations (key is used for screenshot filenames, name is the MCP server name)
const SERVERS = [
{ key: "integration", name: "Integration Test Server" },
@ -37,6 +44,7 @@ const SERVERS = [
{ key: "budget-allocator", name: "Budget Allocator Server" },
{ key: "cohort-heatmap", name: "Cohort Heatmap Server" },
{ key: "customer-segmentation", name: "Customer Segmentation Server" },
{ key: "map-server", name: "CesiumJS Map Server" },
{ key: "scenario-modeler", name: "SaaS Scenario Modeler" },
{ key: "sheet-music", name: "Sheet Music Server" },
{ key: "system-monitor", name: "System Monitor Server" },
@ -121,7 +129,10 @@ SERVERS.forEach((server) => {
test("screenshot matches golden", async ({ page }) => {
await loadServer(page, server.name);
await page.waitForTimeout(500); // Brief stabilization
// Some servers (WebGL, tile-based) need extra stabilization time
const stabilizationMs = SLOW_SERVERS[server.key] ?? 500;
await page.waitForTimeout(stabilizationMs);
// Get mask locators for dynamic content (timestamps, charts, etc.)
const mask = getMaskLocators(page, server.key);

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB