Skip to content

Commit 366aa9b

Browse files
ochafikclaude
andauthored
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>
1 parent 081dad8 commit 366aa9b

12 files changed

Lines changed: 1617 additions & 35 deletions

File tree

examples/map-server/README.md

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
# Example: Interactive Map
2+
3+
Interactive 3D globe viewer using CesiumJS with OpenStreetMap tiles. Demonstrates geocoding integration and full MCP App capabilities.
4+
5+
## Features
6+
7+
- **3D Globe Rendering**: Interactive CesiumJS globe with rotation, zoom, and 3D perspective
8+
- **Geocoding**: Search for places using OpenStreetMap Nominatim (no API key required)
9+
- **OpenStreetMap Tiles**: Uses free OSM tile server (no Cesium Ion token needed)
10+
- **Dynamic Loading**: CesiumJS loaded from CDN at runtime for smaller bundle size
11+
12+
## Running
13+
14+
1. Install dependencies:
15+
16+
```bash
17+
npm install
18+
```
19+
20+
2. Build and start the server:
21+
22+
```bash
23+
npm run start:http # for Streamable HTTP transport
24+
# OR
25+
npm run start:stdio # for stdio transport
26+
```
27+
28+
3. View using the [`basic-host`](https://github.com/modelcontextprotocol/ext-apps/tree/main/examples/basic-host) example or another MCP Apps-compatible host.
29+
30+
## Tools
31+
32+
### `geocode`
33+
34+
Search for places by name or address. Returns coordinates and bounding boxes.
35+
36+
```json
37+
{
38+
"query": "Eiffel Tower"
39+
}
40+
```
41+
42+
Returns up to 5 matches with lat/lon coordinates and bounding boxes.
43+
44+
### `show-map`
45+
46+
Display the 3D globe zoomed to a bounding box.
47+
48+
```json
49+
{
50+
"west": 2.29,
51+
"south": 48.85,
52+
"east": 2.3,
53+
"north": 48.86,
54+
"label": "Eiffel Tower"
55+
}
56+
```
57+
58+
Defaults to London if no coordinates provided.
59+
60+
## Architecture
61+
62+
### Server (`server.ts`)
63+
64+
Exposes two tools:
65+
66+
- `geocode` - Queries OpenStreetMap Nominatim API with rate limiting
67+
- `show-map` - Renders the CesiumJS globe UI at a specified location
68+
69+
Configures Content Security Policy to allow fetching tiles from OSM and Cesium CDN.
70+
71+
### App (`src/mcp-app.ts`)
72+
73+
Vanilla TypeScript app that:
74+
75+
- Dynamically loads CesiumJS from CDN
76+
- Initializes globe with OpenStreetMap imagery (no Ion token)
77+
- Receives tool inputs via the MCP App SDK
78+
- Handles camera navigation to specified bounding boxes
79+
80+
## Key Files
81+
82+
- [`server.ts`](server.ts) - MCP server with geocode and show-map tools
83+
- [`mcp-app.html`](mcp-app.html) / [`src/mcp-app.ts`](src/mcp-app.ts) - CesiumJS globe UI
84+
- [`server-utils.ts`](server-utils.ts) - HTTP server utilities
85+
86+
## Notes
87+
88+
- Rate limiting is applied to Nominatim requests (1 request per second per their usage policy)
89+
- The globe works in sandboxed iframes with appropriate CSP configuration
90+
- No external API keys required - uses only open data sources

examples/map-server/mcp-app.html

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
<!DOCTYPE html>
2+
<html lang="en">
3+
<head>
4+
<meta charset="UTF-8">
5+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6+
<title>CesiumJS Globe</title>
7+
<!-- CesiumJS is loaded dynamically from CDN in mcp-app.ts because static
8+
<script src=""> tags don't work in srcdoc iframes -->
9+
<style>
10+
html, body {
11+
width: 100%;
12+
height: 100%;
13+
margin: 0;
14+
padding: 0;
15+
overflow: hidden;
16+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
17+
}
18+
#cesiumContainer {
19+
width: 100%;
20+
height: 100%;
21+
border-radius: .75rem;
22+
overflow: hidden;
23+
}
24+
#fullscreen-btn {
25+
position: absolute;
26+
top: 10px;
27+
right: 10px;
28+
width: 36px;
29+
height: 36px;
30+
background: rgba(0, 0, 0, 0.7);
31+
border: none;
32+
border-radius: 6px;
33+
cursor: pointer;
34+
z-index: 1000;
35+
display: none;
36+
align-items: center;
37+
justify-content: center;
38+
transition: background 0.2s;
39+
}
40+
#fullscreen-btn:hover {
41+
background: rgba(0, 0, 0, 0.85);
42+
}
43+
#fullscreen-btn svg {
44+
width: 20px;
45+
height: 20px;
46+
fill: white;
47+
}
48+
#loading {
49+
position: absolute;
50+
top: 50%;
51+
left: 50%;
52+
transform: translate(-50%, -50%);
53+
background: rgba(0, 0, 0, 0.8);
54+
color: white;
55+
padding: 20px 30px;
56+
border-radius: 8px;
57+
font-size: 16px;
58+
z-index: 1001;
59+
}
60+
</style>
61+
</head>
62+
<body>
63+
<div id="cesiumContainer"></div>
64+
<button id="fullscreen-btn" title="Toggle fullscreen">
65+
<!-- Expand icon (shown when inline) -->
66+
<svg id="expand-icon" viewBox="0 0 24 24"><path d="M7 14H5v5h5v-2H7v-3zm-2-4h2V7h3V5H5v5zm12 7h-3v2h5v-5h-2v3zM14 5v2h3v3h2V5h-5z"/></svg>
67+
<!-- Compress icon (shown when fullscreen) -->
68+
<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>
69+
</button>
70+
<div id="loading">Loading globe...</div>
71+
<script type="module" src="/src/mcp-app.ts"></script>
72+
</body>
73+
</html>

examples/map-server/package.json

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
{
2+
"name": "@modelcontextprotocol/server-map",
3+
"version": "0.1.0",
4+
"type": "module",
5+
"description": "MCP App Server example with CesiumJS 3D globe and geocoding",
6+
"repository": {
7+
"type": "git",
8+
"url": "https://github.com/modelcontextprotocol/ext-apps",
9+
"directory": "examples/map-server"
10+
},
11+
"license": "MIT",
12+
"main": "server.ts",
13+
"files": [
14+
"server.ts",
15+
"server-utils.ts",
16+
"dist"
17+
],
18+
"scripts": {
19+
"build": "tsc --noEmit && cross-env INPUT=mcp-app.html vite build",
20+
"watch": "cross-env INPUT=mcp-app.html vite build --watch",
21+
"serve:http": "bun server.ts",
22+
"serve:stdio": "bun server.ts --stdio",
23+
"start": "npm run start:http",
24+
"start:http": "cross-env NODE_ENV=development npm run build && npm run serve:http",
25+
"start:stdio": "cross-env NODE_ENV=development npm run build && npm run serve:stdio",
26+
"dev": "cross-env NODE_ENV=development concurrently 'npm run watch' 'npm run serve:http'",
27+
"prepublishOnly": "npm run build"
28+
},
29+
"dependencies": {
30+
"@modelcontextprotocol/ext-apps": "^0.3.1",
31+
"@modelcontextprotocol/sdk": "^1.24.0",
32+
"zod": "^4.1.13"
33+
},
34+
"devDependencies": {
35+
"@types/cors": "^2.8.19",
36+
"@types/express": "^5.0.0",
37+
"@types/node": "^22.0.0",
38+
"concurrently": "^9.2.1",
39+
"cors": "^2.8.5",
40+
"cross-env": "^10.1.0",
41+
"express": "^5.1.0",
42+
"typescript": "^5.9.3",
43+
"vite": "^6.0.0",
44+
"vite-plugin-singlefile": "^2.3.0"
45+
}
46+
}
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
/**
2+
* Shared utilities for running MCP servers with Streamable HTTP transport.
3+
*/
4+
5+
import { createMcpExpressApp } from "@modelcontextprotocol/sdk/server/express.js";
6+
import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
7+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
8+
import cors from "cors";
9+
import type { Request, Response } from "express";
10+
11+
export interface ServerOptions {
12+
port: number;
13+
name?: string;
14+
}
15+
16+
/**
17+
* Starts an MCP server with Streamable HTTP transport in stateless mode.
18+
*
19+
* @param createServer - Factory function that creates a new McpServer instance per request.
20+
* @param options - Server configuration options.
21+
*/
22+
export async function startServer(
23+
createServer: () => McpServer,
24+
options: ServerOptions,
25+
): Promise<void> {
26+
const { port, name = "MCP Server" } = options;
27+
28+
const app = createMcpExpressApp({ host: "0.0.0.0" });
29+
app.use(cors());
30+
31+
app.all("/mcp", async (req: Request, res: Response) => {
32+
const server = createServer();
33+
const transport = new StreamableHTTPServerTransport({
34+
sessionIdGenerator: undefined,
35+
});
36+
37+
res.on("close", () => {
38+
transport.close().catch(() => {});
39+
server.close().catch(() => {});
40+
});
41+
42+
try {
43+
await server.connect(transport);
44+
await transport.handleRequest(req, res, req.body);
45+
} catch (error) {
46+
console.error("MCP error:", error);
47+
if (!res.headersSent) {
48+
res.status(500).json({
49+
jsonrpc: "2.0",
50+
error: { code: -32603, message: "Internal server error" },
51+
id: null,
52+
});
53+
}
54+
}
55+
});
56+
57+
const httpServer = app.listen(port, () => {
58+
console.log(`${name} listening on http://localhost:${port}/mcp`);
59+
});
60+
61+
const shutdown = () => {
62+
console.log("\nShutting down...");
63+
httpServer.close(() => process.exit(0));
64+
};
65+
66+
process.on("SIGINT", shutdown);
67+
process.on("SIGTERM", shutdown);
68+
}

0 commit comments

Comments
 (0)