diff --git a/examples/map-server/README.md b/examples/map-server/README.md new file mode 100644 index 00000000..c504eeab --- /dev/null +++ b/examples/map-server/README.md @@ -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 diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html new file mode 100644 index 00000000..e46c7ebb --- /dev/null +++ b/examples/map-server/mcp-app.html @@ -0,0 +1,73 @@ + + + + + + CesiumJS Globe + + + + +
+ +
Loading globe...
+ + + diff --git a/examples/map-server/package.json b/examples/map-server/package.json new file mode 100644 index 00000000..2385f503 --- /dev/null +++ b/examples/map-server/package.json @@ -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" + } +} diff --git a/examples/map-server/server-utils.ts b/examples/map-server/server-utils.ts new file mode 100644 index 00000000..c700c818 --- /dev/null +++ b/examples/map-server/server-utils.ts @@ -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 { + 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); +} diff --git a/examples/map-server/server.ts b/examples/map-server/server.ts new file mode 100644 index 00000000..b36db931 --- /dev/null +++ b/examples/map-server/server.ts @@ -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 { + // 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 => { + 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 => ({ + 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 => { + 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); +}); diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts new file mode 100644 index 00000000..b78aee79 --- /dev/null +++ b/examples/map-server/src/mcp-app.ts @@ -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 + + + diff --git a/examples/map-server/tsconfig.json b/examples/map-server/tsconfig.json new file mode 100644 index 00000000..535267b2 --- /dev/null +++ b/examples/map-server/tsconfig.json @@ -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"] +} diff --git a/examples/map-server/vite.config.ts b/examples/map-server/vite.config.ts new file mode 100644 index 00000000..005ecaf7 --- /dev/null +++ b/examples/map-server/vite.config.ts @@ -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, + }, +}); diff --git a/package-lock.json b/package-lock.json index 115e4154..8b74a9ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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 diff --git a/tests/e2e/servers.spec.ts b/tests/e2e/servers.spec.ts index e554bc3b..7c7099c5 100644 --- a/tests/e2e/servers.spec.ts +++ b/tests/e2e/servers.spec.ts @@ -13,6 +13,7 @@ const DYNAMIC_MASKS: Record = { "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 = { "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 = { + "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); diff --git a/tests/e2e/servers.spec.ts-snapshots/map-server.png b/tests/e2e/servers.spec.ts-snapshots/map-server.png new file mode 100644 index 00000000..d53de01e Binary files /dev/null and b/tests/e2e/servers.spec.ts-snapshots/map-server.png differ