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:
parent
081dad83c4
commit
366aa9b66e
12 changed files with 1617 additions and 35 deletions
90
examples/map-server/README.md
Normal file
90
examples/map-server/README.md
Normal 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
|
||||
73
examples/map-server/mcp-app.html
Normal file
73
examples/map-server/mcp-app.html
Normal 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>
|
||||
46
examples/map-server/package.json
Normal file
46
examples/map-server/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
68
examples/map-server/server-utils.ts
Normal file
68
examples/map-server/server-utils.ts
Normal 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);
|
||||
}
|
||||
268
examples/map-server/server.ts
Normal file
268
examples/map-server/server.ts
Normal 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);
|
||||
});
|
||||
843
examples/map-server/src/mcp-app.ts
Normal file
843
examples/map-server/src/mcp-app.ts
Normal 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();
|
||||
134
examples/map-server/test-standalone.html
Normal file
134
examples/map-server/test-standalone.html
Normal 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>
|
||||
19
examples/map-server/tsconfig.json
Normal file
19
examples/map-server/tsconfig.json
Normal 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"]
|
||||
}
|
||||
23
examples/map-server/vite.config.ts
Normal file
23
examples/map-server/vite.config.ts
Normal 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
75
package-lock.json
generated
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
BIN
tests/e2e/servers.spec.ts-snapshots/map-server.png
Normal file
BIN
tests/e2e/servers.spec.ts-snapshots/map-server.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 386 KiB |
Loading…
Reference in a new issue