From 23b9e0c793cdef03d21617fd07e885a97077feb7 Mon Sep 17 00:00:00 2001 From: ochafik Date: Sun, 11 Jan 2026 12:52:21 +0000 Subject: [PATCH 01/20] feat: Add CesiumJS map server example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- examples/cesium-map-server/mcp-app.html | 70 ++++ examples/cesium-map-server/package.json | 46 ++ examples/cesium-map-server/server-utils.ts | 68 +++ examples/cesium-map-server/server.ts | 233 +++++++++++ examples/cesium-map-server/src/mcp-app.ts | 395 ++++++++++++++++++ .../cesium-map-server/test-standalone.html | 134 ++++++ examples/cesium-map-server/tsconfig.json | 19 + examples/cesium-map-server/vite.config.ts | 23 + 8 files changed, 988 insertions(+) create mode 100644 examples/cesium-map-server/mcp-app.html create mode 100644 examples/cesium-map-server/package.json create mode 100644 examples/cesium-map-server/server-utils.ts create mode 100644 examples/cesium-map-server/server.ts create mode 100644 examples/cesium-map-server/src/mcp-app.ts create mode 100644 examples/cesium-map-server/test-standalone.html create mode 100644 examples/cesium-map-server/tsconfig.json create mode 100644 examples/cesium-map-server/vite.config.ts diff --git a/examples/cesium-map-server/mcp-app.html b/examples/cesium-map-server/mcp-app.html new file mode 100644 index 00000000..62f0b6cd --- /dev/null +++ b/examples/cesium-map-server/mcp-app.html @@ -0,0 +1,70 @@ + + + + + + CesiumJS Globe + + + + + + +
+
+
Loading globe...
+ + + diff --git a/examples/cesium-map-server/package.json b/examples/cesium-map-server/package.json new file mode 100644 index 00000000..16f68a0e --- /dev/null +++ b/examples/cesium-map-server/package.json @@ -0,0 +1,46 @@ +{ + "name": "@modelcontextprotocol/server-cesium-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/cesium-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/cesium-map-server/server-utils.ts b/examples/cesium-map-server/server-utils.ts new file mode 100644 index 00000000..c700c818 --- /dev/null +++ b/examples/cesium-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/cesium-map-server/server.ts b/examples/cesium-map-server/server.ts new file mode 100644 index 00000000..24675457 --- /dev/null +++ b/examples/cesium-map-server/server.ts @@ -0,0 +1,233 @@ +/** + * 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 and Cesium assets + connectDomains: [ + "https://tile.openstreetmap.org", + "https://*.tile.openstreetmap.org", + "https://cesium.com", + "https://*.cesium.com", + "https://assets.cesium.com", + "https://api.cesium.com", + ], + // Allow loading tile images, scripts, and Cesium CDN resources + resourceDomains: [ + "https://tile.openstreetmap.org", + "https://*.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 }, + ], + }; + }, + ); + + // geocode tool - searches for places using Nominatim (no UI) + server.registerTool( + "geocode", + { + title: "Geocode", + description: + "Search for places using OpenStreetMap Nominatim. 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, + }; + } + }, + ); + + // show-map tool - displays the CesiumJS globe + // Default bounding box: London area + registerAppTool( + server, + "show-map", + { + title: "Show Map", + description: + "Display an interactive 3D globe zoomed to a specific bounding box. The globe uses OpenStreetMap tiles and supports rotation, zoom, and 3D perspective. Defaults to London if no coordinates provided.", + inputSchema: { + west: z.number().optional().default(-0.50).describe("Western longitude (-180 to 180)"), + south: z.number().optional().default(51.30).describe("Southern latitude (-90 to 90)"), + east: z.number().optional().default(0.30).describe("Eastern longitude (-180 to 180)"), + north: z.number().optional().default(51.70).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})` : ""}`, + }, + ], + }), + ); + + 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/cesium-map-server/src/mcp-app.ts b/examples/cesium-map-server/src/mcp-app.ts new file mode 100644 index 00000000..564f5718 --- /dev/null +++ b/examples/cesium-map-server/src/mcp-app.ts @@ -0,0 +1,395 @@ +/** + * 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/cesium-map-server/tsconfig.json b/examples/cesium-map-server/tsconfig.json new file mode 100644 index 00000000..535267b2 --- /dev/null +++ b/examples/cesium-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/cesium-map-server/vite.config.ts b/examples/cesium-map-server/vite.config.ts new file mode 100644 index 00000000..005ecaf7 --- /dev/null +++ b/examples/cesium-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, + }, +}); From bdb85d0f41a492d8144aa24967e674d798240fdf Mon Sep 17 00:00:00 2001 From: ochafik Date: Sun, 11 Jan 2026 13:26:14 +0000 Subject: [PATCH 02/20] comment out app.registerTool call for now --- examples/cesium-map-server/src/mcp-app.ts | 100 +++++++++++----------- package-lock.json | 43 ++++++++++ 2 files changed, 94 insertions(+), 49 deletions(-) diff --git a/examples/cesium-map-server/src/mcp-app.ts b/examples/cesium-map-server/src/mcp-app.ts index 564f5718..de5aa97a 100644 --- a/examples/cesium-map-server/src/mcp-app.ts +++ b/examples/cesium-map-server/src/mcp-app.ts @@ -6,7 +6,6 @@ * 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 @@ -319,54 +318,57 @@ app.ontoolinput = (params) => { } }; -// Register navigate-to tool that host can call -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})` : ""}`, - }, - ], - }; - }, -); +/* + 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() { diff --git a/package-lock.json b/package-lock.json index 115e4154..691c0966 100644 --- a/package-lock.json +++ b/package-lock.json @@ -398,6 +398,45 @@ "dev": true, "license": "MIT" }, + "examples/cesium-map-server": { + "name": "@modelcontextprotocol/server-cesium-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/cesium-map-server/node_modules/@types/node": { + "version": "22.19.5", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz", + "integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "examples/cesium-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/cohort-heatmap-server": { "name": "@modelcontextprotocol/server-cohort-heatmap", "version": "0.1.0", @@ -1769,6 +1808,10 @@ "resolved": "examples/budget-allocator-server", "link": true }, + "node_modules/@modelcontextprotocol/server-cesium-map": { + "resolved": "examples/cesium-map-server", + "link": true + }, "node_modules/@modelcontextprotocol/server-cohort-heatmap": { "resolved": "examples/cohort-heatmap-server", "link": true From 83ecc372d8f806973e59388acc4033d2180b4f55 Mon Sep 17 00:00:00 2001 From: ochafik Date: Sun, 11 Jan 2026 13:27:46 +0000 Subject: [PATCH 03/20] prettier --- examples/cesium-map-server/server.ts | 63 ++++++++++++++++++----- examples/cesium-map-server/src/mcp-app.ts | 57 ++++++++++++++------ 2 files changed, 93 insertions(+), 27 deletions(-) diff --git a/examples/cesium-map-server/server.ts b/examples/cesium-map-server/server.ts index 24675457..a334d2d6 100644 --- a/examples/cesium-map-server/server.ts +++ b/examples/cesium-map-server/server.ts @@ -7,7 +7,10 @@ */ 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 type { + CallToolResult, + ReadResourceResult, +} from "@modelcontextprotocol/sdk/types.js"; import fs from "node:fs/promises"; import path from "node:path"; import { z } from "zod"; @@ -65,13 +68,16 @@ async function geocodeWithNominatim(query: string): Promise { `https://nominatim.openstreetmap.org/search?${params}`, { headers: { - "User-Agent": "MCP-CesiumMap-Example/1.0 (https://github.com/modelcontextprotocol)", + "User-Agent": + "MCP-CesiumMap-Example/1.0 (https://github.com/modelcontextprotocol)", }, }, ); if (!response.ok) { - throw new Error(`Nominatim API error: ${response.status} ${response.statusText}`); + throw new Error( + `Nominatim API error: ${response.status} ${response.statusText}`, + ); } return response.json(); @@ -118,11 +124,19 @@ export function createServer(): McpServer { RESOURCE_URI, { mimeType: RESOURCE_MIME_TYPE }, async (): Promise => { - const html = await fs.readFile(path.join(DIST_DIR, "mcp-app.html"), "utf-8"); + 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 }, + { + uri: RESOURCE_URI, + mimeType: RESOURCE_MIME_TYPE, + text: html, + _meta: cspMeta, + }, ], }; }, @@ -136,7 +150,11 @@ export function createServer(): McpServer { description: "Search for places using OpenStreetMap Nominatim. 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')"), + query: z + .string() + .describe( + "Place name or address to search for (e.g., 'Paris', 'Golden Gate Bridge', '1600 Pennsylvania Ave')", + ), }, }, async ({ query }): Promise => { @@ -145,7 +163,9 @@ export function createServer(): McpServer { if (results.length === 0) { return { - content: [{ type: "text", text: `No results found for "${query}"` }], + content: [ + { type: "text", text: `No results found for "${query}"` }, + ], }; } @@ -197,11 +217,30 @@ export function createServer(): McpServer { description: "Display an interactive 3D globe zoomed to a specific bounding box. The globe uses OpenStreetMap tiles and supports rotation, zoom, and 3D perspective. Defaults to London if no coordinates provided.", inputSchema: { - west: z.number().optional().default(-0.50).describe("Western longitude (-180 to 180)"), - south: z.number().optional().default(51.30).describe("Southern latitude (-90 to 90)"), - east: z.number().optional().default(0.30).describe("Eastern longitude (-180 to 180)"), - north: z.number().optional().default(51.70).describe("Northern latitude (-90 to 90)"), - label: z.string().optional().describe("Optional label to display on the map"), + 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 }, }, diff --git a/examples/cesium-map-server/src/mcp-app.ts b/examples/cesium-map-server/src/mcp-app.ts index de5aa97a..33d93324 100644 --- a/examples/cesium-map-server/src/mcp-app.ts +++ b/examples/cesium-map-server/src/mcp-app.ts @@ -39,7 +39,8 @@ async function loadCesium(): Promise { (window as any).CESIUM_BASE_URL = CESIUM_BASE_URL; resolve(); }; - script.onerror = () => reject(new Error("Failed to load CesiumJS from CDN")); + script.onerror = () => + reject(new Error("Failed to load CesiumJS from CDN")); document.head.appendChild(script); }); } @@ -76,7 +77,10 @@ async function initCesium(): Promise { // 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 + -130, + 20, + -60, + 55, // USA bounding box ); log.info("Default view rectangle set"); @@ -139,19 +143,23 @@ async function initCesium(): Promise { // Add the imagery layer to the viewer cesiumViewer.imageryLayers.addImageryProvider(osmProvider); - log.info("OSM imagery layer added, layer count:", cesiumViewer.imageryLayers.length); + 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); - } - }); + 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); } @@ -206,9 +214,19 @@ function flyToBoundingBox( const height = Math.max(100000, maxSpan * 111000 * 5); // Calculate destination - use a higher altitude to ensure globe is visible - const destination = Cesium.Cartesian3.fromDegrees(centerLon, centerLat, Math.max(height, 500000)); - - log.info("flyTo destination:", centerLon, centerLat, "height:", Math.max(height, 500000)); + const destination = Cesium.Cartesian3.fromDegrees( + centerLon, + centerLat, + Math.max(height, 500000), + ); + + log.info( + "flyTo destination:", + centerLon, + centerLat, + "height:", + Math.max(height, 500000), + ); // Always use flyTo with animation - setView doesn't work reliably // Use minimum 0.5s duration for reliability @@ -217,7 +235,10 @@ function flyToBoundingBox( destination, duration: actualDuration, complete: () => { - log.info("flyTo complete, camera height:", cesiumViewer.camera.positionCartographic.height); + log.info( + "flyTo complete, camera height:", + cesiumViewer.camera.positionCartographic.height, + ); resolve(); }, }); @@ -309,8 +330,14 @@ app.ontoolinput = (params) => { log.info("Executing flyToBoundingBox now..."); flyToBoundingBox(viewer!, bbox).then(() => { log.info("flyToBoundingBox completed!"); - log.info("Camera height:", viewer!.camera.positionCartographic.height); - log.info("Camera pitch:", Cesium.Math.toDegrees(viewer!.camera.pitch)); + log.info( + "Camera height:", + viewer!.camera.positionCartographic.height, + ); + log.info( + "Camera pitch:", + Cesium.Math.toDegrees(viewer!.camera.pitch), + ); }); setLabel(args?.label); }, 500); From e841af3bd9c5125fcf1ce084c4bda6f912a050b9 Mon Sep 17 00:00:00 2001 From: ochafik Date: Sun, 11 Jan 2026 13:47:01 +0000 Subject: [PATCH 04/20] fix error handling in server-utils.ts --- examples/cesium-map-server/server-utils.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/examples/cesium-map-server/server-utils.ts b/examples/cesium-map-server/server-utils.ts index c700c818..9fe9745a 100644 --- a/examples/cesium-map-server/server-utils.ts +++ b/examples/cesium-map-server/server-utils.ts @@ -54,7 +54,11 @@ export async function startServer( } }); - const httpServer = app.listen(port, () => { + const httpServer = app.listen(port, (err) => { + if (err) { + console.error("Failed to start server:", err); + process.exit(1); + } console.log(`${name} listening on http://localhost:${port}/mcp`); }); From 661f388fb25b3d7b8d2cad2b0739a084f02023ae Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sun, 11 Jan 2026 14:48:06 +0000 Subject: [PATCH 05/20] 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 --- examples/map-server/README.md | 90 ++++++++++++++ .../mcp-app.html | 0 .../package.json | 4 +- .../server-utils.ts | 0 .../server.ts | 0 .../src/mcp-app.ts | 0 .../test-standalone.html | 0 .../tsconfig.json | 0 .../vite.config.ts | 0 package-lock.json | 112 ++++++------------ 10 files changed, 130 insertions(+), 76 deletions(-) create mode 100644 examples/map-server/README.md rename examples/{cesium-map-server => map-server}/mcp-app.html (100%) rename examples/{cesium-map-server => map-server}/package.json (93%) rename examples/{cesium-map-server => map-server}/server-utils.ts (100%) rename examples/{cesium-map-server => map-server}/server.ts (100%) rename examples/{cesium-map-server => map-server}/src/mcp-app.ts (100%) rename examples/{cesium-map-server => map-server}/test-standalone.html (100%) rename examples/{cesium-map-server => map-server}/tsconfig.json (100%) rename examples/{cesium-map-server => map-server}/vite.config.ts (100%) diff --git a/examples/map-server/README.md b/examples/map-server/README.md new file mode 100644 index 00000000..f03db7c3 --- /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.30, + "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/cesium-map-server/mcp-app.html b/examples/map-server/mcp-app.html similarity index 100% rename from examples/cesium-map-server/mcp-app.html rename to examples/map-server/mcp-app.html diff --git a/examples/cesium-map-server/package.json b/examples/map-server/package.json similarity index 93% rename from examples/cesium-map-server/package.json rename to examples/map-server/package.json index 16f68a0e..2385f503 100644 --- a/examples/cesium-map-server/package.json +++ b/examples/map-server/package.json @@ -1,12 +1,12 @@ { - "name": "@modelcontextprotocol/server-cesium-map", + "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/cesium-map-server" + "directory": "examples/map-server" }, "license": "MIT", "main": "server.ts", diff --git a/examples/cesium-map-server/server-utils.ts b/examples/map-server/server-utils.ts similarity index 100% rename from examples/cesium-map-server/server-utils.ts rename to examples/map-server/server-utils.ts diff --git a/examples/cesium-map-server/server.ts b/examples/map-server/server.ts similarity index 100% rename from examples/cesium-map-server/server.ts rename to examples/map-server/server.ts diff --git a/examples/cesium-map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts similarity index 100% rename from examples/cesium-map-server/src/mcp-app.ts rename to examples/map-server/src/mcp-app.ts diff --git a/examples/cesium-map-server/test-standalone.html b/examples/map-server/test-standalone.html similarity index 100% rename from examples/cesium-map-server/test-standalone.html rename to examples/map-server/test-standalone.html diff --git a/examples/cesium-map-server/tsconfig.json b/examples/map-server/tsconfig.json similarity index 100% rename from examples/cesium-map-server/tsconfig.json rename to examples/map-server/tsconfig.json diff --git a/examples/cesium-map-server/vite.config.ts b/examples/map-server/vite.config.ts similarity index 100% rename from examples/cesium-map-server/vite.config.ts rename to examples/map-server/vite.config.ts diff --git a/package-lock.json b/package-lock.json index 691c0966..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": { @@ -398,19 +382,24 @@ "dev": true, "license": "MIT" }, - "examples/cesium-map-server": { - "name": "@modelcontextprotocol/server-cesium-map", + "examples/cohort-heatmap-server": { + "name": "@modelcontextprotocol/server-cohort-heatmap", "version": "0.1.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/ext-apps": "^0.3.1", "@modelcontextprotocol/sdk": "^1.24.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", "zod": "^4.1.13" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.2.1", "cors": "^2.8.5", "cross-env": "^10.1.0", @@ -420,41 +409,35 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/cesium-map-server/node_modules/@types/node": { - "version": "22.19.5", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.5.tgz", - "integrity": "sha512-HfF8+mYcHPcPypui3w3mvzuIErlNOh2OAG+BCeBZCEwyiD5ls2SiCwEyT47OELtf7M3nHxBdu0FsmzdKxkN52Q==", + "examples/cohort-heatmap-server/node_modules/@types/node": { + "version": "22.19.3", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, - "examples/cesium-map-server/node_modules/undici-types": { + "examples/cohort-heatmap-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/cohort-heatmap-server": { - "name": "@modelcontextprotocol/server-cohort-heatmap", + "examples/customer-segmentation-server": { + "name": "@modelcontextprotocol/server-customer-segmentation", "version": "0.1.0", "license": "MIT", "dependencies": { "@modelcontextprotocol/ext-apps": "^0.3.1", "@modelcontextprotocol/sdk": "^1.24.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", + "chart.js": "^4.4.0", "zod": "^4.1.13" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "@vitejs/plugin-react": "^4.3.4", "concurrently": "^9.2.1", "cors": "^2.8.5", "cross-env": "^10.1.0", @@ -464,98 +447,91 @@ "vite-plugin-singlefile": "^2.3.0" } }, - "examples/cohort-heatmap-server/node_modules/@types/node": { + "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": { "undici-types": "~6.21.0" } }, - "examples/cohort-heatmap-server/node_modules/undici-types": { + "examples/customer-segmentation-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/customer-segmentation-server": { - "name": "@modelcontextprotocol/server-customer-segmentation", - "version": "0.1.0", - "license": "MIT", + "examples/integration-server": { + "version": "1.0.0", "dependencies": { - "@modelcontextprotocol/ext-apps": "^0.3.1", + "@modelcontextprotocol/ext-apps": "../..", "@modelcontextprotocol/sdk": "^1.24.0", - "chart.js": "^4.4.0", + "react": "^19.2.0", + "react-dom": "^19.2.0", "zod": "^4.1.13" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", + "@types/react": "^19.2.2", + "@types/react-dom": "^19.2.2", + "@vitejs/plugin-react": "^4.3.4", "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/customer-segmentation-server/node_modules/@types/node": { + "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": { "undici-types": "~6.21.0" } }, - "examples/customer-segmentation-server/node_modules/undici-types": { + "examples/integration-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/integration-server": { - "version": "1.0.0", + "examples/map-server": { + "name": "@modelcontextprotocol/server-map", + "version": "0.1.0", + "license": "MIT", "dependencies": { - "@modelcontextprotocol/ext-apps": "../..", + "@modelcontextprotocol/ext-apps": "^0.3.1", "@modelcontextprotocol/sdk": "^1.24.0", - "react": "^19.2.0", - "react-dom": "^19.2.0", "zod": "^4.1.13" }, "devDependencies": { "@types/cors": "^2.8.19", "@types/express": "^5.0.0", "@types/node": "^22.0.0", - "@types/react": "^19.2.2", - "@types/react-dom": "^19.2.2", - "@vitejs/plugin-react": "^4.3.4", "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/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==", + "examples/map-server/node_modules/@types/node": { + "version": "22.19.1", "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" } }, - "examples/integration-server/node_modules/undici-types": { + "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==", @@ -592,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": { @@ -632,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": { @@ -673,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": { @@ -719,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": { @@ -758,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": { @@ -799,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": { @@ -1808,10 +1772,6 @@ "resolved": "examples/budget-allocator-server", "link": true }, - "node_modules/@modelcontextprotocol/server-cesium-map": { - "resolved": "examples/cesium-map-server", - "link": true - }, "node_modules/@modelcontextprotocol/server-cohort-heatmap": { "resolved": "examples/cohort-heatmap-server", "link": true @@ -1820,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 From c1e3cd6c1879ff6025834d0bfcb66886af146ca2 Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sun, 11 Jan 2026 15:05:11 +0000 Subject: [PATCH 06/20] style: add min-height of 400px to map container --- examples/map-server/mcp-app.html | 1 + 1 file changed, 1 insertion(+) diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index 62f0b6cd..9b65d783 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -27,6 +27,7 @@ html, body, #cesiumContainer { width: 100%; height: 100%; + min-height: 400px; margin: 0; padding: 0; overflow: hidden; From 3ffa9b6d7c1f5cbd0f8b080a32a443daa07c10dc Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sun, 11 Jan 2026 15:22:35 +0000 Subject: [PATCH 07/20] 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 --- examples/map-server/mcp-app.html | 33 ++++--- examples/map-server/src/mcp-app.ts | 138 ++++++++++++++++++++++++----- 2 files changed, 141 insertions(+), 30 deletions(-) diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index 9b65d783..1b540940 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -27,26 +27,34 @@ html, body, #cesiumContainer { width: 100%; height: 100%; - min-height: 400px; margin: 0; padding: 0; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; } - #label { + #fullscreen-btn { position: absolute; top: 10px; - left: 10px; + right: 10px; + width: 36px; + height: 36px; background: rgba(0, 0, 0, 0.7); - color: white; - padding: 8px 16px; + border: none; border-radius: 6px; - font-size: 14px; - font-weight: 500; + cursor: pointer; z-index: 1000; display: none; - max-width: 300px; - word-wrap: break-word; + 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; @@ -64,7 +72,12 @@
-
+
Loading globe...
diff --git a/examples/map-server/src/mcp-app.ts b/examples/map-server/src/mcp-app.ts index 33d93324..2333c15b 100644 --- a/examples/map-server/src/mcp-app.ts +++ b/examples/map-server/src/mcp-app.ts @@ -91,11 +91,11 @@ async function initCesium(): Promise { // Disable Ion-dependent features geocoder: false, baseLayerPicker: false, - // Simplify UI + // Simplify UI - hide all controls animation: false, timeline: false, - homeButton: true, - sceneModePicker: true, + homeButton: false, + sceneModePicker: false, navigationHelpButton: false, fullscreenButton: false, // Disable terrain (requires Ion) @@ -107,6 +107,8 @@ async function initCesium(): Promise { alpha: true, }, }, + // Use full device pixel ratio for sharp rendering on high-DPI displays + useBrowserRecommendedResolution: false, }); log.info("Viewer created"); @@ -245,21 +247,6 @@ function flyToBoundingBox( }); } -/** - * Update the label display - */ -function setLabel(text: string | undefined): void { - const labelEl = document.getElementById("label"); - if (labelEl) { - if (text) { - labelEl.textContent = text; - labelEl.style.display = "block"; - } else { - labelEl.style.display = "none"; - } - } -} - /** * Hide the loading indicator */ @@ -270,13 +257,86 @@ function hideLoading(): void { } } +// 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 }, // Cesium handles its own sizing + { 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 { + 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"); @@ -289,6 +349,22 @@ app.onteardown = async () => { 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 = (params) => { log.info("Received tool input:", params); @@ -339,7 +415,6 @@ app.ontoolinput = (params) => { Cesium.Math.toDegrees(viewer!.camera.pitch), ); }); - setLabel(args?.label); }, 500); } } @@ -411,6 +486,29 @@ async function init() { // 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"); From c9c3eefaec817aca76d7a98de40ed34a5851622e Mon Sep 17 00:00:00 2001 From: Olivier Chafik Date: Sun, 11 Jan 2026 15:34:21 +0000 Subject: [PATCH 08/20] 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 --- examples/map-server/mcp-app.html | 11 ++++- examples/map-server/src/mcp-app.ts | 69 ++++++++++++++++++++++++++++++ 2 files changed, 78 insertions(+), 2 deletions(-) diff --git a/examples/map-server/mcp-app.html b/examples/map-server/mcp-app.html index 1b540940..3ad8b02f 100644 --- a/examples/map-server/mcp-app.html +++ b/examples/map-server/mcp-app.html @@ -12,7 +12,8 @@ "https://cesium.com", "https://*.cesium.com", "https://assets.cesium.com", - "https://api.cesium.com" + "https://api.cesium.com", + "https://nominatim.openstreetmap.org" ], "resourceDomains": [ "https://tile.openstreetmap.org", @@ -24,7 +25,7 @@