| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | 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"; |
| |
|
| | |
| | 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]; |
| | class: string; |
| | type: string; |
| | importance: number; |
| | } |
| |
|
| | |
| | let lastNominatimRequest = 0; |
| | const NOMINATIM_RATE_LIMIT_MS = 1100; |
| |
|
| | |
| | |
| | |
| | async function geocodeWithNominatim(query: string): Promise<NominatimResult[]> { |
| | |
| | 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(); |
| | } |
| |
|
| | |
| | |
| | |
| | |
| | export function createServer(): McpServer { |
| | const server = new McpServer({ |
| | name: "CesiumJS Map Server", |
| | version: "1.0.0", |
| | }); |
| |
|
| | |
| | const cspMeta = { |
| | ui: { |
| | csp: { |
| | |
| | connectDomains: [ |
| | "https://*.openstreetmap.org", |
| | "https://cesium.com", |
| | "https://*.cesium.com", |
| | ], |
| | |
| | resourceDomains: [ |
| | "https://*.openstreetmap.org", |
| | "https://cesium.com", |
| | "https://*.cesium.com", |
| | ], |
| | }, |
| | }, |
| | }; |
| |
|
| | |
| | registerAppResource( |
| | server, |
| | RESOURCE_URI, |
| | RESOURCE_URI, |
| | { mimeType: RESOURCE_MIME_TYPE }, |
| | async (): Promise<ReadResourceResult> => { |
| | const html = await fs.readFile( |
| | path.join(DIST_DIR, "mcp-app.html"), |
| | "utf-8", |
| | ); |
| | return { |
| | contents: [ |
| | |
| | { |
| | uri: RESOURCE_URI, |
| | mimeType: RESOURCE_MIME_TYPE, |
| | text: html, |
| | _meta: cspMeta, |
| | }, |
| | ], |
| | }; |
| | }, |
| | ); |
| |
|
| | |
| | |
| | registerAppTool( |
| | server, |
| | "show-map", |
| | { |
| | title: "Show Map", |
| | description: |
| | "Display an interactive world map zoomed to a specific bounding box. Use the GeoCode tool to find the bounding box of a location.", |
| | inputSchema: { |
| | west: z |
| | .number() |
| | .optional() |
| | .default(-0.5) |
| | .describe("Western longitude (-180 to 180)"), |
| | south: z |
| | .number() |
| | .optional() |
| | .default(51.3) |
| | .describe("Southern latitude (-90 to 90)"), |
| | east: z |
| | .number() |
| | .optional() |
| | .default(0.3) |
| | .describe("Eastern longitude (-180 to 180)"), |
| | north: z |
| | .number() |
| | .optional() |
| | .default(51.7) |
| | .describe("Northern latitude (-90 to 90)"), |
| | label: z |
| | .string() |
| | .optional() |
| | .describe("Optional label to display on the map"), |
| | }, |
| | _meta: { [RESOURCE_URI_META_KEY]: RESOURCE_URI }, |
| | }, |
| | async ({ west, south, east, north, label }): Promise<CallToolResult> => ({ |
| | content: [ |
| | { |
| | type: "text", |
| | text: `Displaying globe at: W:${west.toFixed(4)}, S:${south.toFixed(4)}, E:${east.toFixed(4)}, N:${north.toFixed(4)}${label ? ` (${label})` : ""}`, |
| | }, |
| | ], |
| | }), |
| | ); |
| |
|
| | |
| | server.registerTool( |
| | "geocode", |
| | { |
| | title: "Geocode", |
| | description: |
| | "Search for places using OpenStreetMap. Returns coordinates and bounding boxes for up to 5 matches.", |
| | inputSchema: { |
| | query: z |
| | .string() |
| | .describe( |
| | "Place name or address to search for (e.g., 'Paris', 'Golden Gate Bridge', '1600 Pennsylvania Ave')", |
| | ), |
| | }, |
| | }, |
| | async ({ query }): Promise<CallToolResult> => { |
| | try { |
| | const results = await geocodeWithNominatim(query); |
| |
|
| | if (results.length === 0) { |
| | return { |
| | content: [ |
| | { type: "text", text: `No results found for "${query}"` }, |
| | ], |
| | }; |
| | } |
| |
|
| | const formattedResults = results.map((r) => ({ |
| | displayName: r.display_name, |
| | lat: parseFloat(r.lat), |
| | lon: parseFloat(r.lon), |
| | boundingBox: { |
| | south: parseFloat(r.boundingbox[0]), |
| | north: parseFloat(r.boundingbox[1]), |
| | west: parseFloat(r.boundingbox[2]), |
| | east: parseFloat(r.boundingbox[3]), |
| | }, |
| | type: r.type, |
| | importance: r.importance, |
| | })); |
| |
|
| | const textContent = formattedResults |
| | .map( |
| | (r, i) => |
| | `${i + 1}. ${r.displayName}\n Coordinates: ${r.lat.toFixed(6)}, ${r.lon.toFixed(6)}\n Bounding box: W:${r.boundingBox.west.toFixed(4)}, S:${r.boundingBox.south.toFixed(4)}, E:${r.boundingBox.east.toFixed(4)}, N:${r.boundingBox.north.toFixed(4)}`, |
| | ) |
| | .join("\n\n"); |
| |
|
| | return { |
| | content: [{ type: "text", text: textContent }], |
| | }; |
| | } catch (error) { |
| | return { |
| | content: [ |
| | { |
| | type: "text", |
| | text: `Geocoding error: ${error instanceof Error ? error.message : String(error)}`, |
| | }, |
| | ], |
| | isError: true, |
| | }; |
| | } |
| | }, |
| | ); |
| |
|
| | return server; |
| | } |
| |
|
| | async function main() { |
| | if (process.argv.includes("--stdio")) { |
| | await createServer().connect(new StdioServerTransport()); |
| | } else { |
| | const port = parseInt(process.env.PORT ?? "3001", 10); |
| | await startServer(createServer, { port, name: "CesiumJS Map Server" }); |
| | } |
| | } |
| |
|
| | main().catch((e) => { |
| | console.error(e); |
| | process.exit(1); |
| | }); |
| |
|