/** * 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); });