diff --git a/mcp/README.md b/mcp/README.md index 3ea476d..b41b1bb 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -13,6 +13,7 @@ CesiumJs servers live in [`cesium-js/`](./cesium-js/README.md). See the [cesium- | πŸŽ₯ [cesium-camera-server](./cesium-js/servers/camera-server/README.md) | `cesium-js/servers/camera-server/` | Camera control: fly-to, orbit, look-at, position queries | | 🌍 [cesium-entity-server](./cesium-js/servers/entity-server/README.md) | `cesium-js/servers/entity-server/` | Entity management: points, billboards, labels, models, polygons, polylines, and more | | 🎬 [cesium-animation-server](./cesium-js/servers/animation-server/README.md) | `cesium-js/servers/animation-server/` | Path-based animations, clock control, camera tracking, globe lighting | +| πŸ—ΊοΈ [cesium-imagery-server](./cesium-js/servers/imagery-server/README.md) | `cesium-js/servers/imagery-server/` | Imagery layer management: add, remove, and list imagery providers | ### 🌐 Geolocation MCP Server @@ -43,6 +44,7 @@ See the individual READMEs for full details: - **[cesium-camera-server README](./cesium-js/servers/camera-server/README.md)** β€” camera tools reference and configuration - **[cesium-entity-server README](./cesium-js/servers/entity-server/README.md)** β€” entity tools reference and configuration - **[cesium-animation-server README](./cesium-js/servers/animation-server/README.md)** β€” animation tools reference and configuration +- **[cesium-imagery-server README](./cesium-js/servers/imagery-server/README.md)** β€” imagery tools reference and configuration - **[cesium-geolocation-server README](./geolocation-server/README.md)** β€” geolocation, POI search, and routing tools - **[mcp-apps README](./mcp-apps/README.md)** β€” MCP Apps with interactive UIs - **[cesium-context7 README](./external/cesium-context7/README.md)** β€” Context7 setup and agent skill usage @@ -60,7 +62,8 @@ mcp/ β”‚ β”‚ β”œβ”€β”€ shared/ # Shared utilities (MCP base, communications) β”‚ β”‚ β”œβ”€β”€ camera-server/ # Camera control MCP server β”‚ β”‚ β”œβ”€β”€ entity-server/ # Entity management MCP server -β”‚ β”‚ └── animation-server/ # Animation and path control MCP server +β”‚ β”‚ β”œβ”€β”€ animation-server/ # Animation and path control MCP server +β”‚ β”‚ └── imagery-server/ # Imagery layer management MCP server β”‚ β”œβ”€β”€ test-applications/ β”‚ β”‚ β”œβ”€β”€ packages/client-core/ # Shared client library β”‚ β”‚ └── web-app/ # Browser application (localhost:8080) diff --git a/mcp/cesium-js/README.md b/mcp/cesium-js/README.md index f175e95..9c955a7 100644 --- a/mcp/cesium-js/README.md +++ b/mcp/cesium-js/README.md @@ -10,6 +10,7 @@ pnpm monorepo containing MCP servers and test applications for controlling [Cesi | [`@cesium-mcp/camera-server`](./servers/camera-server/README.md) | Camera control: fly-to, orbit, look-at, position queries | 3002 | | [`@cesium-mcp/entity-server`](./servers/entity-server/README.md) | Entity management: points, billboards, labels, models, polygons, polylines, and more | 3003 | | [`@cesium-mcp/animation-server`](./servers/animation-server/README.md) | Path-based animations, clock control, camera tracking, globe lighting | 3004 | +| [`@cesium-mcp/imagery-server`](./servers/imagery-server/README.md) | Imagery layer management: add, remove, and list imagery providers | 3005 | | [`@cesium-mcp/client-core`](./test-applications/packages/client-core/README.md) | Shared browser client library (managers, communications) | β€” | | [`@cesium-mcp/cesium-js`](./test-applications/README.md) | Browser web application (CesiumJS viewer) | 8080 | @@ -65,6 +66,16 @@ Animate 3D models along paths and control the scene clock. | `clock_control` | Configure global clock time, speed, and playback state | | `globe_set_lighting` | Enable realistic day/night globe lighting | +### πŸ—ΊοΈ [cesium-imagery-server](./servers/imagery-server/README.md) + +Manage imagery layers on the CesiumJS globe. + +| Tool | Description | +| ---------------- | --------------------------------------------------------- | +| `imagery_add` | Add imagery layer from various provider types | +| `imagery_remove` | Remove imagery layer by index, name, or remove all | +| `imagery_list` | List all imagery layers with visibility and provider info | + ## πŸ—οΈ Structure ``` @@ -73,7 +84,8 @@ cesium-js/ β”‚ β”œβ”€β”€ shared/ # @cesium-mcp/shared β”‚ β”œβ”€β”€ camera-server/ # @cesium-mcp/camera-server β”‚ β”œβ”€β”€ entity-server/ # @cesium-mcp/entity-server -β”‚ └── animation-server/ # @cesium-mcp/animation-server +β”‚ β”œβ”€β”€ animation-server/ # @cesium-mcp/animation-server +β”‚ └── imagery-server/ # @cesium-mcp/imagery-server β”œβ”€β”€ test-applications/ β”‚ β”œβ”€β”€ packages/ β”‚ β”‚ └── client-core/ # @cesium-mcp/client-core @@ -107,6 +119,7 @@ pnpm run build:shared # @cesium-mcp/shared only pnpm run build:camera # @cesium-mcp/camera-server only pnpm run build:entity # @cesium-mcp/entity-server only pnpm run build:animation # @cesium-mcp/animation-server only +pnpm run build:imagery # @cesium-mcp/imagery-server only pnpm run build:cesium-js # @cesium-mcp/cesium-js (web app) only pnpm run clean # Remove all build artifacts ``` @@ -119,6 +132,7 @@ pnpm run clean # Remove all build artifacts pnpm run dev:camera # Camera server on port 3002 pnpm run dev:entity # Entity server on port 3003 pnpm run dev:animation # Animation server on port 3004 +pnpm run dev:imagery # Imagery server on port 3005 ``` ### Web Application @@ -168,6 +182,17 @@ Add the servers to your MCP client (e.g., Claude Desktop, Cline): "ANIMATION_SERVER_PORT": "3004", "STRICT_PORT": "false" } + }, + "cesium-imagery": { + "command": "node", + "args": [ + "{YOUR_WORKSPACE}/mcp/cesium-js/servers/imagery-server/build/index.js" + ], + "env": { + "COMMUNICATION_PROTOCOL": "websocket", + "IMAGERY_SERVER_PORT": "3005", + "STRICT_PORT": "false" + } } } } @@ -186,7 +211,7 @@ Add the servers to your MCP client (e.g., Claude Desktop, Cline): AI Assistant (Claude, Cline, etc.) β”‚ stdio (MCP) β–Ό - MCP Server (camera / entity / animation) + MCP Server (camera / entity / animation / imagery) β”‚ WebSocket or SSE β–Ό CesiumJS Web App (localhost:8080) diff --git a/mcp/cesium-js/package.json b/mcp/cesium-js/package.json index 3a2a1f8..2c33eee 100644 --- a/mcp/cesium-js/package.json +++ b/mcp/cesium-js/package.json @@ -5,15 +5,17 @@ "type": "module", "private": true, "scripts": { - "build": "pnpm run build:shared && pnpm run build:camera && pnpm run build:entity && pnpm run build:animation && pnpm run build:cesium-js", + "build": "pnpm run build:shared && pnpm run build:camera && pnpm run build:entity && pnpm run build:animation && pnpm run build:imagery && pnpm run build:cesium-js", "build:shared": "pnpm --filter @cesium-mcp/shared build", "build:camera": "pnpm --filter @cesium-mcp/camera-server build", "build:entity": "pnpm --filter @cesium-mcp/entity-server build", "build:animation": "pnpm --filter @cesium-mcp/animation-server build", + "build:imagery": "pnpm --filter @cesium-mcp/imagery-server build", "build:cesium-js": "pnpm --filter @cesium-mcp/cesium-js build", "dev:camera": "pnpm --filter @cesium-mcp/camera-server dev", "dev:entity": "pnpm --filter @cesium-mcp/entity-server dev", "dev:animation": "pnpm --filter @cesium-mcp/animation-server dev", + "dev:imagery": "pnpm --filter @cesium-mcp/imagery-server dev", "start:web": "pnpm --filter @cesium-mcp/cesium-js start:web", "clean": "pnpm -r run clean", "lint": "eslint .", @@ -25,7 +27,8 @@ "test:coverage": "vitest run --coverage", "test:camera": "pnpm --filter @cesium-mcp/camera-server test", "test:entity": "pnpm --filter @cesium-mcp/entity-server test", - "test:animation": "pnpm --filter @cesium-mcp/animation-server test" + "test:animation": "pnpm --filter @cesium-mcp/animation-server test", + "test:imagery": "pnpm --filter @cesium-mcp/imagery-server test" }, "keywords": [ "mcp", diff --git a/mcp/cesium-js/pnpm-lock.yaml b/mcp/cesium-js/pnpm-lock.yaml index 3c73edd..1b5f608 100644 --- a/mcp/cesium-js/pnpm-lock.yaml +++ b/mcp/cesium-js/pnpm-lock.yaml @@ -25,7 +25,7 @@ importers: version: 25.3.3 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@25.3.3)(@vitest/ui@4.0.18)(tsx@4.21.0)) + version: 4.0.18(vitest@4.0.18) eslint: specifier: ^9.39.2 version: 9.39.3 @@ -74,7 +74,7 @@ importers: version: 20.19.35 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@20.19.35)(@vitest/ui@4.0.18)(tsx@4.21.0)) + version: 4.0.18(vitest@4.0.18) '@vitest/ui': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18) @@ -111,7 +111,7 @@ importers: version: 25.3.3 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@25.3.3)(@vitest/ui@4.0.18)(tsx@4.21.0)) + version: 4.0.18(vitest@4.0.18) '@vitest/ui': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18) @@ -145,7 +145,41 @@ importers: version: 25.3.3 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@25.3.3)(@vitest/ui@4.0.18)(tsx@4.21.0)) + version: 4.0.18(vitest@4.0.18) + '@vitest/ui': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18) + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ^5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@25.3.3)(@vitest/ui@4.0.18)(tsx@4.21.0) + + servers/imagery-server: + dependencies: + '@cesium-mcp/shared': + specifier: workspace:* + version: link:../shared + '@modelcontextprotocol/sdk': + specifier: ^1.26.0 + version: 1.27.1(zod@4.3.6) + dotenv: + specifier: ^16.4.7 + version: 16.6.1 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + '@types/node': + specifier: ^25.2.2 + version: 25.3.3 + '@vitest/coverage-v8': + specifier: ^4.0.18 + version: 4.0.18(vitest@4.0.18) '@vitest/ui': specifier: ^4.0.18 version: 4.0.18(vitest@4.0.18) @@ -188,7 +222,7 @@ importers: version: 8.18.1 '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@25.3.3)(@vitest/ui@4.0.18)(tsx@4.21.0)) + version: 4.0.18(vitest@4.0.18) rimraf: specifier: ^6.1.2 version: 6.1.3 @@ -208,7 +242,7 @@ importers: version: 1.27.1(zod@4.3.6) '@vitest/coverage-v8': specifier: ^4.0.18 - version: 4.0.18(vitest@4.0.18(@types/node@25.3.3)(@vitest/ui@4.0.18)(tsx@4.21.0)) + version: 4.0.18(vitest@4.0.18) cesium: specifier: ^1.112.0 version: 1.139.0 @@ -603,66 +637,79 @@ packages: resolution: {integrity: sha512-t4ONHboXi/3E0rT6OZl1pKbl2Vgxf9vJfWgmUoCEVQVxhW6Cw/c8I6hbbu7DAvgp82RKiH7TpLwxnJeKv2pbsw==} cpu: [arm] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.59.0': resolution: {integrity: sha512-CikFT7aYPA2ufMD086cVORBYGHffBo4K8MQ4uPS/ZnY54GKj36i196u8U+aDVT2LX4eSMbyHtyOh7D7Zvk2VvA==} cpu: [arm] os: [linux] + libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.59.0': resolution: {integrity: sha512-jYgUGk5aLd1nUb1CtQ8E+t5JhLc9x5WdBKew9ZgAXg7DBk0ZHErLHdXM24rfX+bKrFe+Xp5YuJo54I5HFjGDAA==} cpu: [arm64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.59.0': resolution: {integrity: sha512-peZRVEdnFWZ5Bh2KeumKG9ty7aCXzzEsHShOZEFiCQlDEepP1dpUl/SrUNXNg13UmZl+gzVDPsiCwnV1uI0RUA==} cpu: [arm64] os: [linux] + libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.59.0': resolution: {integrity: sha512-gbUSW/97f7+r4gHy3Jlup8zDG190AuodsWnNiXErp9mT90iCy9NKKU0Xwx5k8VlRAIV2uU9CsMnEFg/xXaOfXg==} cpu: [loong64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.59.0': resolution: {integrity: sha512-yTRONe79E+o0FWFijasoTjtzG9EBedFXJMl888NBEDCDV9I2wGbFFfJQQe63OijbFCUZqxpHz1GzpbtSFikJ4Q==} cpu: [loong64] os: [linux] + libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.59.0': resolution: {integrity: sha512-sw1o3tfyk12k3OEpRddF68a1unZ5VCN7zoTNtSn2KndUE+ea3m3ROOKRCZxEpmT9nsGnogpFP9x6mnLTCaoLkA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.59.0': resolution: {integrity: sha512-+2kLtQ4xT3AiIxkzFVFXfsmlZiG5FXYW7ZyIIvGA7Bdeuh9Z0aN4hVyXS/G1E9bTP/vqszNIN/pUKCk/BTHsKA==} cpu: [ppc64] os: [linux] + libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.59.0': resolution: {integrity: sha512-NDYMpsXYJJaj+I7UdwIuHHNxXZ/b/N2hR15NyH3m2qAtb/hHPA4g4SuuvrdxetTdndfj9b1WOmy73kcPRoERUg==} cpu: [riscv64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.59.0': resolution: {integrity: sha512-nLckB8WOqHIf1bhymk+oHxvM9D3tyPndZH8i8+35p/1YiVoVswPid2yLzgX7ZJP0KQvnkhM4H6QZ5m0LzbyIAg==} cpu: [riscv64] os: [linux] + libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.59.0': resolution: {integrity: sha512-oF87Ie3uAIvORFBpwnCvUzdeYUqi2wY6jRFWJAy1qus/udHFYIkplYRW+wo+GRUP4sKzYdmE1Y3+rY5Gc4ZO+w==} cpu: [s390x] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.59.0': resolution: {integrity: sha512-3AHmtQq/ppNuUspKAlvA8HtLybkDflkMuLK4DPo77DfthRb71V84/c4MlWJXixZz4uruIH4uaa07IqoAkG64fg==} cpu: [x64] os: [linux] + libc: [glibc] '@rollup/rollup-linux-x64-musl@4.59.0': resolution: {integrity: sha512-2UdiwS/9cTAx7qIUZB/fWtToJwvt0Vbo0zmnYt7ED35KPg13Q0ym1g442THLC7VyI6JfYTP4PiSOWyoMdV2/xg==} cpu: [x64] os: [linux] + libc: [musl] '@rollup/rollup-openbsd-x64@4.59.0': resolution: {integrity: sha512-M3bLRAVk6GOwFlPTIxVBSYKUaqfLrn8l0psKinkCFxl4lQvOSz8ZrKDz2gxcBwHFpci0B6rttydI4IpS4IS/jQ==} @@ -2570,7 +2617,7 @@ snapshots: '@typescript-eslint/types': 8.56.1 eslint-visitor-keys: 5.0.1 - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@20.19.35)(@vitest/ui@4.0.18)(tsx@4.21.0))': + '@vitest/coverage-v8@4.0.18(vitest@4.0.18)': dependencies: '@bcoe/v8-coverage': 1.0.2 '@vitest/utils': 4.0.18 @@ -2584,20 +2631,6 @@ snapshots: tinyrainbow: 3.0.3 vitest: 4.0.18(@types/node@20.19.35)(@vitest/ui@4.0.18)(tsx@4.21.0) - '@vitest/coverage-v8@4.0.18(vitest@4.0.18(@types/node@25.3.3)(@vitest/ui@4.0.18)(tsx@4.21.0))': - dependencies: - '@bcoe/v8-coverage': 1.0.2 - '@vitest/utils': 4.0.18 - ast-v8-to-istanbul: 0.3.12 - istanbul-lib-coverage: 3.2.2 - istanbul-lib-report: 3.0.1 - istanbul-reports: 3.2.0 - magicast: 0.5.2 - obug: 2.1.1 - std-env: 3.10.0 - tinyrainbow: 3.0.3 - vitest: 4.0.18(@types/node@25.3.3)(@vitest/ui@4.0.18)(tsx@4.21.0) - '@vitest/expect@4.0.18': dependencies: '@standard-schema/spec': 1.1.0 diff --git a/mcp/cesium-js/servers/imagery-server/.env.example b/mcp/cesium-js/servers/imagery-server/.env.example new file mode 100644 index 0000000..565d963 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/.env.example @@ -0,0 +1,5 @@ +IMAGERY_SERVER_PORT=3005 +MAX_RETRIES=10 +COMMUNICATION_PROTOCOL=websocket +STRICT_PORT=false +MCP_TRANSPORT=stdio diff --git a/mcp/cesium-js/servers/imagery-server/README.md b/mcp/cesium-js/servers/imagery-server/README.md new file mode 100644 index 0000000..8d687ee --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/README.md @@ -0,0 +1,180 @@ +# πŸ—ΊοΈ Cesium Imagery MCP Server + +MCP server for managing imagery layers on CesiumJS 3D globe applications. + +## ✨ Features + +- **Multiple Provider Types**: Support for URL templates, WMS, WMTS, ArcGIS, Bing Maps, TMS, OpenStreetMap, Cesium Ion, and single tile providers +- **Layer Management**: Add, remove, and list imagery layers dynamically +- **Opacity & Visibility**: Control layer transparency and visibility +- **Geographic Extent**: Restrict imagery layers to specific geographic rectangles +- **Batch Operations**: Remove all non-base imagery layers at once + +## πŸ“¦ Installation + +```bash +pnpm install +pnpm run build +``` + +## πŸš€ Running the Server + +```bash +pnpm run dev # Development mode with auto-reload +pnpm start # Production mode +``` + +The server will start on port 3005 with WebSocket transport. + +## πŸ› οΈ Tools + +### 1. `imagery_add` + +**Add a new imagery layer to the globe** + +Supports various imagery provider types for overlaying map tiles, satellite imagery, or custom tile services. + +**Supported Provider Types:** + +- `UrlTemplateImageryProvider` β€” Custom URL template tiles +- `WebMapServiceImageryProvider` β€” OGC WMS services +- `WebMapTileServiceImageryProvider` β€” OGC WMTS services +- `ArcGisMapServerImageryProvider` β€” ArcGIS MapServer +- `BingMapsImageryProvider` β€” Bing Maps tiles +- `TileMapServiceImageryProvider` β€” TMS tile services +- `OpenStreetMapImageryProvider` β€” OpenStreetMap tiles +- `IonImageryProvider` β€” Cesium Ion assets +- `SingleTileImageryProvider` β€” Single image overlay +- `GoogleEarthEnterpriseImageryProvider` β€” Google Earth Enterprise + +**Input:** + +- `type` (required): Type of imagery provider to create +- `url` (required): URL of the imagery service or tile template +- `name` (optional): Display name for the imagery layer +- `layers` (optional): Comma-separated layer names (for WMS/WMTS providers) +- `style` (optional): Style name (for WMS/WMTS providers) +- `format` (optional): Image format, e.g. `image/png` (for WMS/WMTS providers) +- `tileMatrixSetID` (optional): Tile matrix set identifier (for WMTS providers) +- `maximumLevel` (optional): Maximum zoom level (0–30) +- `minimumLevel` (optional): Minimum zoom level (0–30) +- `assetId` (optional): Cesium Ion asset ID (required for `IonImageryProvider`) +- `key` (optional): API key (required for `BingMapsImageryProvider`) +- `alpha` (optional): Layer opacity (0 = transparent, 1 = opaque) +- `show` (optional): Whether the layer is visible (default: true) +- `rectangle` (optional): Geographic extent to restrict the imagery layer (`west`, `south`, `east`, `north` in degrees) + +**Output:** + +- Layer index, name, provider type +- Total layer count and response time + +--- + +### 2. `imagery_remove` + +**Remove an imagery layer from the globe** + +Remove a single layer by index or name, or remove all non-base imagery layers at once. + +**Input:** + +- `index` (optional): Index of the imagery layer to remove +- `name` (optional): Name of the imagery layer to remove +- `removeAll` (optional): Remove all non-base imagery layers + +**Output:** + +- Removed layer index, name, and count +- Response time + +--- + +### 3. `imagery_list` + +**List all imagery layers on the globe** + +Get a summary of all imagery layers including their indices, names, visibility, opacity, and provider types. + +**Input:** + +- `includeDetails` (optional): Include detailed provider information + +**Output:** + +- Array of imagery layers with index, name, visibility, alpha, and provider type +- Total layer count and response time + +--- + +## πŸ”Œ Using with AI Clients + +The imagery server works with any MCP-compatible client: **Cline**, **Github Copilot** (VS Code), **Claude Desktop**, or other MCP clients. + +### Example: Configure with Cline + +Add to your Cline MCP settings: + +```json +{ + "mcpServers": { + "cesium-imagery-server": { + "command": "node", + "args": [ + "{YOUR_WORKSPACE}/cesium-ai-integrations/mcp/cesium-js/servers/imagery-server/build/index.js" + ], + "env": { + "PORT": "3005", + "COMMUNICATION_PROTOCOL": "websocket" + } + } + } +} +``` + +> **Note:** Replace `{YOUR_WORKSPACE}` with the absolute path to your local clone. + +## πŸ§ͺ Example Test Queries + +Try these natural language queries with your AI client: + +### Adding Imagery + +``` +"Add an OpenStreetMap imagery layer" +"Add a WMS layer from this URL with layer name 'elevation'" +"Overlay a satellite imagery from ArcGIS MapServer" +``` + +### Managing Layers + +``` +"List all imagery layers on the globe" +"Remove the imagery layer at index 2" +"Remove all imagery layers" +``` + +### Advanced Usage + +``` +"Add a WMTS layer with 50% opacity restricted to North America" +"Add a custom tile URL template as a semi-transparent overlay" +``` + +## βš™οΈ Configuration + +Environment variables: + +- `PORT` or `IMAGERY_SERVER_PORT`: Server port (default: 3005) +- `COMMUNICATION_PROTOCOL`: `websocket` or `sse` (default: `websocket`) +- `MAX_RETRIES`: Maximum retry attempts for port binding (default: 10) +- `STRICT_PORT`: If `true`, fail if exact port unavailable (default: false) +- `MCP_TRANSPORT`: `stdio` or `streamable-http` (default: `stdio`) + +## 🀝 Contributing + +Interested in contributing? Please read [CONTRIBUTING.md](CONTRIBUTING.md). We also ask that you follow the [Code of Conduct](CODE_OF_CONDUCT.md). + +## License + +Apache 2.0. See [LICENSE](LICENSE). diff --git a/mcp/cesium-js/servers/imagery-server/package.json b/mcp/cesium-js/servers/imagery-server/package.json new file mode 100644 index 0000000..00b0d61 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/package.json @@ -0,0 +1,44 @@ +{ + "name": "@cesium-mcp/imagery-server", + "version": "1.0.0", + "description": "MCP server for Cesium imagery layer management operations", + "type": "module", + "main": "./build/index.js", + "bin": { + "cesium-imagery-mcp": "./build/index.js" + }, + "scripts": { + "build": "tsc", + "clean": "rimraf build", + "dev": "tsx src/index.ts", + "start": "node build/index.js", + "test": "vitest run", + "test:watch": "vitest", + "test:coverage": "vitest run --coverage" + }, + "keywords": [ + "mcp", + "cesium", + "imagery", + "3d", + "geospatial" + ], + "license": "Apache-2.0", + "exports": { + ".": "./build/index.js" + }, + "dependencies": { + "@cesium-mcp/shared": "workspace:*", + "@modelcontextprotocol/sdk": "^1.26.0", + "dotenv": "^16.4.7", + "zod": "^4.3.6" + }, + "devDependencies": { + "@types/node": "^25.2.2", + "@vitest/coverage-v8": "^4.0.18", + "@vitest/ui": "^4.0.18", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "vitest": "^4.0.18" + } +} diff --git a/mcp/cesium-js/servers/imagery-server/src/index.ts b/mcp/cesium-js/servers/imagery-server/src/index.ts new file mode 100644 index 0000000..a866228 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/index.ts @@ -0,0 +1,59 @@ +#!/usr/bin/env node + +import "dotenv/config"; +import { + CesiumMCPServer, + CesiumSSEServer, + CesiumWebSocketServer, +} from "@cesium-mcp/shared"; +import { registerImageryTools } from "./tools/index.js"; + +const PORT = parseInt( + process.env.PORT || process.env.IMAGERY_SERVER_PORT || "3005", +); +const MAX_RETRIES = parseInt(process.env.MAX_RETRIES || "10"); +const PROTOCOL = process.env.COMMUNICATION_PROTOCOL || "websocket"; +const STRICT_PORT = process.env.STRICT_PORT === "true"; + +// Main execution +async function main() { + try { + // Create communication server based on protocol + const communicationServer = + PROTOCOL === "sse" ? new CesiumSSEServer() : new CesiumWebSocketServer(); + + // Create generic MCP server + const server = new CesiumMCPServer( + { + name: "cesium-imagery-mcp-server", + version: "1.0.0", + communicationServerPort: PORT, + communicationServerMaxRetries: MAX_RETRIES, + communicationServerStrictPort: STRICT_PORT, + mcpTransport: (process.env.MCP_TRANSPORT || "stdio") as + | "stdio" + | "streamable-http", + }, + communicationServer, + ); + + console.error( + `Imagery Server starting with ${PROTOCOL.toUpperCase()} on port ${PORT} (strictPort: ${STRICT_PORT})`, + ); + + // Register imagery tools + server.registerTools(registerImageryTools); + + // Start the server + await server.start(); + } catch (error) { + console.error("Failed to start imagery server:", error); + process.exit(1); + } +} + +// Handle errors +main().catch((error) => { + console.error("Fatal error in main():", error); + process.exit(1); +}); diff --git a/mcp/cesium-js/servers/imagery-server/src/schemas/core-schemas.ts b/mcp/cesium-js/servers/imagery-server/src/schemas/core-schemas.ts new file mode 100644 index 0000000..beb5183 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/schemas/core-schemas.ts @@ -0,0 +1,62 @@ +import { z } from "zod"; + +/** + * Core imagery data type schemas + * These represent fundamental imagery provider types used across imagery tools + */ + +/** + * Supported imagery provider types in CesiumJS + */ +export const ImageryProviderTypeSchema = z + .enum([ + "UrlTemplateImageryProvider", + "WebMapServiceImageryProvider", + "WebMapTileServiceImageryProvider", + "ArcGisMapServerImageryProvider", + "BingMapsImageryProvider", + "TileMapServiceImageryProvider", + "OpenStreetMapImageryProvider", + "IonImageryProvider", + "SingleTileImageryProvider", + "GoogleEarthEnterpriseImageryProvider", + ]) + .describe("Type of imagery provider to create"); + +/** + * Geographic rectangle for constraining imagery layer extent + */ +export const ImageryRectangleSchema = z + .object({ + west: z + .number() + .min(-180) + .max(180) + .describe("Western longitude in degrees"), + south: z.number().min(-90).max(90).describe("Southern latitude in degrees"), + east: z + .number() + .min(-180) + .max(180) + .describe("Eastern longitude in degrees"), + north: z.number().min(-90).max(90).describe("Northern latitude in degrees"), + }) + .describe("Geographic rectangle bounding the imagery layer"); + +/** + * Summary of an imagery layer for list operations + */ +export const ImageryLayerSummarySchema = z.object({ + index: z.number().describe("Layer index in the imagery layer collection"), + name: z.string().optional().describe("Layer name if available"), + show: z.boolean().describe("Whether the layer is currently visible"), + alpha: z.number().min(0).max(1).describe("Layer opacity (0-1)"), + providerType: z.string().optional().describe("Type of imagery provider"), + url: z.string().optional().describe("Provider URL if available"), + ready: z.boolean().optional().describe("Whether the provider is ready"), +}); + +// Type exports for TypeScript +export type ImageryProviderType = z.infer; +export type ImageryRectangle = z.infer; +export type ImageryLayerSummary = z.infer; diff --git a/mcp/cesium-js/servers/imagery-server/src/schemas/index.ts b/mcp/cesium-js/servers/imagery-server/src/schemas/index.ts new file mode 100644 index 0000000..de74c89 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/schemas/index.ts @@ -0,0 +1,40 @@ +/** + * Centralized schema exports for the imagery server + * + * This module re-exports all schemas from their respective files: + * - core-schemas.ts: Fundamental imagery data types + * - tool-schemas.ts: Tool-specific input schemas + * - response-schemas.ts: Common response patterns + */ + +// Core imagery schemas +export { + ImageryProviderTypeSchema, + ImageryRectangleSchema, + ImageryLayerSummarySchema, + type ImageryProviderType, + type ImageryRectangle, + type ImageryLayerSummary, +} from "./core-schemas.js"; + +// Tool-specific schemas +export { + ImageryAddInputSchema, + ImageryRemoveInputSchema, + ImageryListInputSchema, + type ImageryAddInput, + type ImageryRemoveInput, + type ImageryListInput, +} from "./tool-schemas.js"; + +// Response schemas +export { + StatsSchema, + ImageryAddResponseSchema, + ImageryRemoveResponseSchema, + ImageryListResponseSchema, + type Stats, + type ImageryAddResponse, + type ImageryRemoveResponse, + type ImageryListResponse, +} from "./response-schemas.js"; diff --git a/mcp/cesium-js/servers/imagery-server/src/schemas/response-schemas.ts b/mcp/cesium-js/servers/imagery-server/src/schemas/response-schemas.ts new file mode 100644 index 0000000..4a2221d --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/schemas/response-schemas.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +import { ImageryLayerSummarySchema } from "./core-schemas.js"; + +/** + * Response schemas for imagery operations + */ + +/** + * Common statistics included in responses + */ +export const StatsSchema = z.object({ + responseTime: z.number().describe("Response time in milliseconds"), + totalLayers: z.number().optional().describe("Total number of imagery layers"), +}); + +/** + * Response schema for imagery add operations + */ +export const ImageryAddResponseSchema = z.object({ + success: z.boolean().describe("Whether the operation succeeded"), + message: z.string().describe("Human-readable status message"), + layerIndex: z + .number() + .optional() + .describe("Index of the added imagery layer"), + layerName: z.string().optional().describe("Name of the added imagery layer"), + providerType: z + .string() + .optional() + .describe("Type of imagery provider created"), + stats: StatsSchema, +}); + +/** + * Response schema for imagery remove operations + */ +export const ImageryRemoveResponseSchema = z.object({ + success: z.boolean().describe("Whether the operation succeeded"), + message: z.string().describe("Human-readable status message"), + removedIndex: z + .number() + .optional() + .describe("Index of the removed imagery layer"), + removedName: z + .string() + .optional() + .describe("Name of the removed imagery layer"), + removedCount: z + .number() + .optional() + .describe("Number of imagery layers removed"), + stats: z.object({ + responseTime: z.number().describe("Response time in milliseconds"), + }), +}); + +/** + * Response schema for imagery list operations + */ +export const ImageryListResponseSchema = z.object({ + success: z.boolean().describe("Whether the operation succeeded"), + message: z.string().describe("Human-readable status message"), + layers: z + .array(ImageryLayerSummarySchema) + .describe("Array of imagery layers"), + totalCount: z.number().describe("Total number of imagery layers"), + stats: StatsSchema, +}); + +// Type exports for TypeScript +export type Stats = z.infer; +export type ImageryAddResponse = z.infer; +export type ImageryRemoveResponse = z.infer; +export type ImageryListResponse = z.infer; diff --git a/mcp/cesium-js/servers/imagery-server/src/schemas/tool-schemas.ts b/mcp/cesium-js/servers/imagery-server/src/schemas/tool-schemas.ts new file mode 100644 index 0000000..b60e162 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/schemas/tool-schemas.ts @@ -0,0 +1,103 @@ +import { z } from "zod"; +import { + ImageryProviderTypeSchema, + ImageryRectangleSchema, +} from "./core-schemas.js"; + +/** + * Tool-specific input schemas for imagery operations + */ + +/** + * Input schema for imagery_add tool + * Adds a new imagery layer to the globe + */ +export const ImageryAddInputSchema = z.object({ + type: ImageryProviderTypeSchema, + url: z + .string() + .optional() + .describe( + "URL of the imagery service or tile template (not required for IonImageryProvider)", + ), + name: z.string().optional().describe("Display name for the imagery layer"), + layers: z + .string() + .optional() + .describe("Comma-separated layer names (for WMS/WMTS providers)"), + style: z.string().optional().describe("Style name (for WMS/WMTS providers)"), + format: z + .string() + .optional() + .describe("Image format, e.g. 'image/png' (for WMS/WMTS providers)"), + tileMatrixSetID: z + .string() + .optional() + .describe("Tile matrix set identifier (for WMTS providers)"), + maximumLevel: z + .number() + .min(0) + .max(30) + .optional() + .describe("Maximum zoom level for the imagery layer"), + minimumLevel: z + .number() + .min(0) + .max(30) + .optional() + .describe("Minimum zoom level for the imagery layer"), + alpha: z + .number() + .min(0) + .max(1) + .optional() + .describe("Layer opacity (0 = transparent, 1 = opaque, default: 1)"), + show: z + .boolean() + .optional() + .describe("Whether the layer is visible (default: true)"), + rectangle: ImageryRectangleSchema.optional().describe( + "Geographic extent to restrict the imagery layer", + ), + assetId: z + .number() + .optional() + .describe("Cesium Ion asset ID (required for IonImageryProvider)"), + key: z + .string() + .optional() + .describe("API key (required for BingMapsImageryProvider)"), +}); + +/** + * Input schema for imagery_remove tool + * Removes an imagery layer from the globe + */ +export const ImageryRemoveInputSchema = z.object({ + index: z + .number() + .min(0) + .optional() + .describe("Index of the imagery layer to remove"), + name: z.string().optional().describe("Name of the imagery layer to remove"), + removeAll: z + .boolean() + .optional() + .describe("Remove all non-base imagery layers"), +}); + +/** + * Input schema for imagery_list tool + * Lists all imagery layers on the globe + */ +export const ImageryListInputSchema = z.object({ + includeDetails: z + .boolean() + .optional() + .describe("Include detailed provider information"), +}); + +// Type exports for TypeScript +export type ImageryAddInput = z.infer; +export type ImageryRemoveInput = z.infer; +export type ImageryListInput = z.infer; diff --git a/mcp/cesium-js/servers/imagery-server/src/tools/imagery-add.ts b/mcp/cesium-js/servers/imagery-server/src/tools/imagery-add.ts new file mode 100644 index 0000000..6288fdb --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/tools/imagery-add.ts @@ -0,0 +1,115 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + ImageryAddInputSchema, + ImageryAddResponseSchema, + type ImageryAddInput, +} from "../schemas/index.js"; +import type { ImageryAddResult } from "../utils/types.js"; +import { + executeWithTiming, + formatErrorMessage, + buildSuccessResponse, + buildErrorResponse, + ResponseEmoji, + type ICommunicationServer, +} from "@cesium-mcp/shared"; + +/** + * Register the imagery_add tool + */ +export function registerImageryAdd( + server: McpServer, + communicationServer: ICommunicationServer, +): void { + server.registerTool( + "imagery_add", + { + title: "Add Imagery Layer", + description: + "Add a new imagery layer to the globe. Supports various provider types " + + "including URL templates, WMS, WMTS, ArcGIS, Bing Maps, TMS, OpenStreetMap, " + + "Cesium Ion, and single tile providers.", + inputSchema: ImageryAddInputSchema.shape, + outputSchema: ImageryAddResponseSchema.shape, + }, + async ({ + type, + url, + name, + layers, + style, + format, + tileMatrixSetID, + maximumLevel, + minimumLevel, + alpha, + show, + rectangle, + assetId, + key, + }: ImageryAddInput) => { + try { + const command = { + type: "imagery_add" as const, + providerType: type, + url, + name, + layers, + style, + format, + tileMatrixSetID, + maximumLevel, + minimumLevel, + alpha, + show, + rectangle, + assetId, + key, + }; + + const { result, responseTime } = + await executeWithTiming( + communicationServer, + command, + ); + + if (result.success) { + const layerName = name || result.layerName || type; + + const output = { + success: true, + message: `Imagery layer '${layerName}' added successfully at index ${result.layerIndex ?? "unknown"}`, + layerIndex: result.layerIndex, + layerName, + providerType: type, + stats: { + responseTime, + totalLayers: result.totalLayers, + }, + }; + + return buildSuccessResponse( + ResponseEmoji.Success, + responseTime, + output, + ); + } + + throw new Error(result.error || "Unknown error from Cesium"); + } catch (error) { + const errorOutput = { + success: false, + message: `Failed to add imagery layer: ${formatErrorMessage(error)}`, + layerIndex: undefined, + layerName: name, + providerType: type, + stats: { + responseTime: 0, + }, + }; + + return buildErrorResponse(0, errorOutput); + } + }, + ); +} diff --git a/mcp/cesium-js/servers/imagery-server/src/tools/imagery-list.ts b/mcp/cesium-js/servers/imagery-server/src/tools/imagery-list.ts new file mode 100644 index 0000000..20e7839 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/tools/imagery-list.ts @@ -0,0 +1,84 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + ImageryListInputSchema, + ImageryListResponseSchema, + type ImageryListInput, + type ImageryLayerSummary, +} from "../schemas/index.js"; +import type { ImageryListResult } from "../utils/types.js"; +import { + executeWithTiming, + formatErrorMessage, + buildSuccessResponse, + buildErrorResponse, + ResponseEmoji, + type ICommunicationServer, +} from "@cesium-mcp/shared"; + +/** + * Register the imagery_list tool + */ +export function registerImageryList( + server: McpServer, + communicationServer: ICommunicationServer, +): void { + server.registerTool( + "imagery_list", + { + title: "List Imagery Layers", + description: + "Get a list of all imagery layers currently on the globe with their " + + "indices, names, visibility, and provider types.", + inputSchema: ImageryListInputSchema.shape, + outputSchema: ImageryListResponseSchema.shape, + }, + async ({ includeDetails = false }: ImageryListInput) => { + try { + const command = { + type: "imagery_list" as const, + includeDetails, + }; + + const { result, responseTime } = + await executeWithTiming( + communicationServer, + command, + ); + + if (result.success) { + const layers: ImageryLayerSummary[] = Array.isArray(result.layers) + ? result.layers + : []; + + const output = { + success: true, + message: `Found ${layers.length} imagery layer${layers.length === 1 ? "" : "s"} on the globe`, + layers, + totalCount: layers.length, + stats: { + totalLayers: layers.length, + responseTime, + }, + }; + + return buildSuccessResponse(ResponseEmoji.List, responseTime, output); + } + + throw new Error(result.error || "Unknown error from Cesium"); + } catch (error) { + const errorOutput = { + success: false, + message: `Failed to list imagery layers: ${formatErrorMessage(error)}`, + layers: [], + totalCount: 0, + stats: { + totalLayers: 0, + responseTime: 0, + }, + }; + + return buildErrorResponse(0, errorOutput); + } + }, + ); +} diff --git a/mcp/cesium-js/servers/imagery-server/src/tools/imagery-remove.ts b/mcp/cesium-js/servers/imagery-server/src/tools/imagery-remove.ts new file mode 100644 index 0000000..b1be464 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/tools/imagery-remove.ts @@ -0,0 +1,99 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + ImageryRemoveInputSchema, + ImageryRemoveResponseSchema, + type ImageryRemoveInput, +} from "../schemas/index.js"; +import type { ImageryRemoveResult } from "../utils/types.js"; +import { + executeWithTiming, + formatErrorMessage, + buildSuccessResponse, + buildErrorResponse, + ResponseEmoji, + type ICommunicationServer, +} from "@cesium-mcp/shared"; + +/** + * Register the imagery_remove tool + */ +export function registerImageryRemove( + server: McpServer, + communicationServer: ICommunicationServer, +): void { + server.registerTool( + "imagery_remove", + { + title: "Remove Imagery Layer", + description: + "Remove an imagery layer from the globe by index or name. " + + "Can remove a single layer or all non-base imagery layers.", + inputSchema: ImageryRemoveInputSchema.shape, + outputSchema: ImageryRemoveResponseSchema.shape, + }, + async ({ index, name, removeAll = false }: ImageryRemoveInput) => { + try { + if (index === undefined && !name && !removeAll) { + throw new Error("Either index, name, or removeAll must be provided"); + } + + const command = { + type: "imagery_remove" as const, + index, + name, + removeAll, + }; + + const { result, responseTime } = + await executeWithTiming( + communicationServer, + command, + ); + + if (result.success) { + const removedCount = removeAll + ? (result.removedCount ?? 0) + : (result.removedCount ?? 1); + const identifier = + name || (index !== undefined ? `index ${index}` : "all"); + + const output = { + success: true, + message: removeAll + ? `Removed ${removedCount} imagery layer${removedCount === 1 ? "" : "s"}` + : `Imagery layer '${identifier}' removed successfully`, + removedIndex: result.removedIndex ?? index, + removedName: result.removedName ?? name, + removedCount, + stats: { + responseTime, + }, + }; + + return buildSuccessResponse( + ResponseEmoji.Remove, + responseTime, + output, + ); + } + + throw new Error(result.error || "Unknown error from Cesium"); + } catch (error) { + const identifier = + name || (index !== undefined ? `index ${index}` : "unknown"); + const errorOutput = { + success: false, + message: `Failed to remove imagery layer '${identifier}': ${formatErrorMessage(error)}`, + removedIndex: index, + removedName: name, + removedCount: 0, + stats: { + responseTime: 0, + }, + }; + + return buildErrorResponse(0, errorOutput); + } + }, + ); +} diff --git a/mcp/cesium-js/servers/imagery-server/src/tools/index.ts b/mcp/cesium-js/servers/imagery-server/src/tools/index.ts new file mode 100644 index 0000000..ba650da --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/tools/index.ts @@ -0,0 +1,26 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { ICommunicationServer } from "@cesium-mcp/shared"; +import { registerImageryAdd } from "./imagery-add.js"; +import { registerImageryRemove } from "./imagery-remove.js"; +import { registerImageryList } from "./imagery-list.js"; + +/** + * Register all imagery tools with the MCP server + * @param server - The MCP server instance + * @param communicationServer - The communication server for browser interaction + */ +export function registerImageryTools( + server: McpServer, + communicationServer: ICommunicationServer | undefined, +): void { + if (!communicationServer) { + throw new Error( + "Imagery tools require a communication server for browser visualization", + ); + } + + // Register all imagery tools + registerImageryAdd(server, communicationServer); + registerImageryRemove(server, communicationServer); + registerImageryList(server, communicationServer); +} diff --git a/mcp/cesium-js/servers/imagery-server/src/utils/index.ts b/mcp/cesium-js/servers/imagery-server/src/utils/index.ts new file mode 100644 index 0000000..a0c4ebf --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./types.js"; diff --git a/mcp/cesium-js/servers/imagery-server/src/utils/types.ts b/mcp/cesium-js/servers/imagery-server/src/utils/types.ts new file mode 100644 index 0000000..a25a2d0 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/src/utils/types.ts @@ -0,0 +1,49 @@ +import type { ImageryLayerSummary } from "../schemas/core-schemas.js"; + +/** + * Type definitions for imagery server communication + */ + +/** + * Base result structure + */ +export interface BaseResult { + success?: boolean; + error?: string; +} + +/** + * Result from adding an imagery layer + */ +export interface ImageryAddResult extends BaseResult { + layerIndex?: number; + layerName?: string; + totalLayers?: number; + [key: string]: unknown; +} + +/** + * Result from removing an imagery layer + */ +export interface ImageryRemoveResult extends BaseResult { + removedIndex?: number; + removedName?: string; + removedCount?: number; + [key: string]: unknown; +} + +/** + * Result from listing imagery layers + */ +export interface ImageryListResult extends BaseResult { + layers?: ImageryLayerSummary[]; + [key: string]: unknown; +} + +/** + * Union type for all imagery results + */ +export type ImageryResult = + | ImageryAddResult + | ImageryRemoveResult + | ImageryListResult; diff --git a/mcp/cesium-js/servers/imagery-server/test/schemas/schemas.test.ts b/mcp/cesium-js/servers/imagery-server/test/schemas/schemas.test.ts new file mode 100644 index 0000000..50a4b58 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/test/schemas/schemas.test.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from "vitest"; +import { + ImageryAddInputSchema, + ImageryRemoveInputSchema, + ImageryListInputSchema, + ImageryProviderTypeSchema, + ImageryRectangleSchema, +} from "../../src/schemas/index"; + +describe("Imagery Schemas", () => { + describe("ImageryProviderTypeSchema", () => { + it("should accept valid provider types", () => { + const validTypes = [ + "UrlTemplateImageryProvider", + "WebMapServiceImageryProvider", + "WebMapTileServiceImageryProvider", + "ArcGisMapServerImageryProvider", + "BingMapsImageryProvider", + "TileMapServiceImageryProvider", + "OpenStreetMapImageryProvider", + "IonImageryProvider", + "SingleTileImageryProvider", + "GoogleEarthEnterpriseImageryProvider", + ]; + + for (const type of validTypes) { + expect(() => ImageryProviderTypeSchema.parse(type)).not.toThrow(); + } + }); + + it("should reject invalid provider types", () => { + expect(() => + ImageryProviderTypeSchema.parse("InvalidProvider"), + ).toThrow(); + }); + }); + + describe("ImageryRectangleSchema", () => { + it("should accept valid rectangle", () => { + const result = ImageryRectangleSchema.parse({ + west: -180, + south: -90, + east: 180, + north: 90, + }); + expect(result.west).toBe(-180); + expect(result.north).toBe(90); + }); + + it("should reject out-of-range longitude", () => { + expect(() => + ImageryRectangleSchema.parse({ + west: -181, + south: 0, + east: 0, + north: 0, + }), + ).toThrow(); + }); + + it("should reject out-of-range latitude", () => { + expect(() => + ImageryRectangleSchema.parse({ + west: 0, + south: -91, + east: 0, + north: 0, + }), + ).toThrow(); + }); + }); + + describe("ImageryAddInputSchema", () => { + it("should accept minimal valid input", () => { + const input = { + type: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + }; + const result = ImageryAddInputSchema.parse(input); + expect(result.type).toBe("UrlTemplateImageryProvider"); + expect(result.url).toBe("https://example.com/tiles/{z}/{x}/{y}.png"); + }); + + it("should accept full WMS input", () => { + const input = { + type: "WebMapServiceImageryProvider", + url: "https://example.com/wms", + name: "WMS Layer", + layers: "terrain", + style: "default", + format: "image/png", + alpha: 0.8, + show: true, + minimumLevel: 0, + maximumLevel: 18, + rectangle: { west: -10, south: 40, east: 10, north: 55 }, + }; + const result = ImageryAddInputSchema.parse(input); + expect(result.layers).toBe("terrain"); + expect(result.alpha).toBe(0.8); + }); + + it("should accept WMTS input with tileMatrixSetID", () => { + const input = { + type: "WebMapTileServiceImageryProvider", + url: "https://example.com/wmts", + tileMatrixSetID: "GoogleMapsCompatible", + }; + const result = ImageryAddInputSchema.parse(input); + expect(result.tileMatrixSetID).toBe("GoogleMapsCompatible"); + }); + + it("should reject alpha out of range", () => { + expect(() => + ImageryAddInputSchema.parse({ + type: "UrlTemplateImageryProvider", + url: "https://example.com", + alpha: 1.5, + }), + ).toThrow(); + }); + + it("should reject negative alpha", () => { + expect(() => + ImageryAddInputSchema.parse({ + type: "UrlTemplateImageryProvider", + url: "https://example.com", + alpha: -0.1, + }), + ).toThrow(); + }); + + it("should reject maximumLevel out of range", () => { + expect(() => + ImageryAddInputSchema.parse({ + type: "UrlTemplateImageryProvider", + url: "https://example.com", + maximumLevel: 31, + }), + ).toThrow(); + }); + + it("should accept missing url (optional for IonImageryProvider)", () => { + const result = ImageryAddInputSchema.parse({ + type: "UrlTemplateImageryProvider", + }); + expect(result.url).toBeUndefined(); + }); + + it("should reject missing type", () => { + expect(() => + ImageryAddInputSchema.parse({ + url: "https://example.com", + }), + ).toThrow(); + }); + }); + + describe("ImageryRemoveInputSchema", () => { + it("should accept removal by index", () => { + const result = ImageryRemoveInputSchema.parse({ index: 1 }); + expect(result.index).toBe(1); + }); + + it("should accept removal by name", () => { + const result = ImageryRemoveInputSchema.parse({ name: "Satellite" }); + expect(result.name).toBe("Satellite"); + }); + + it("should accept removeAll", () => { + const result = ImageryRemoveInputSchema.parse({ removeAll: true }); + expect(result.removeAll).toBe(true); + }); + + it("should accept empty object (validation is in tool handler)", () => { + const result = ImageryRemoveInputSchema.parse({}); + expect(result.index).toBeUndefined(); + expect(result.name).toBeUndefined(); + }); + + it("should reject negative index", () => { + expect(() => ImageryRemoveInputSchema.parse({ index: -1 })).toThrow(); + }); + }); + + describe("ImageryListInputSchema", () => { + it("should accept empty object", () => { + const result = ImageryListInputSchema.parse({}); + expect(result.includeDetails).toBeUndefined(); + }); + + it("should accept includeDetails", () => { + const result = ImageryListInputSchema.parse({ includeDetails: true }); + expect(result.includeDetails).toBe(true); + }); + }); +}); diff --git a/mcp/cesium-js/servers/imagery-server/test/tools/imagery-add.test.ts b/mcp/cesium-js/servers/imagery-server/test/tools/imagery-add.test.ts new file mode 100644 index 0000000..498d2d4 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/test/tools/imagery-add.test.ts @@ -0,0 +1,336 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ICommunicationServer } from "@cesium-mcp/shared"; +import type { ImageryAddResponse } from "../../src/schemas/index"; +import { registerImageryAdd } from "../../src/tools/imagery-add"; + +describe("registerImageryAdd", () => { + let mockServer: { registerTool: ReturnType }; + let mockCommunicationServer: { executeCommand: ReturnType }; + let registeredHandler: (args: unknown) => Promise<{ + structuredContent: ImageryAddResponse; + isError: boolean; + }>; + + beforeEach(() => { + mockServer = { + registerTool: vi.fn((_name, _config, handler) => { + registeredHandler = handler; + }), + }; + mockCommunicationServer = { + executeCommand: vi.fn(), + }; + + registerImageryAdd( + mockServer as unknown as McpServer, + mockCommunicationServer as unknown as ICommunicationServer, + ); + }); + + describe("Happy paths", () => { + it('should register tool with name "imagery_add"', () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "imagery_add", + expect.objectContaining({ + title: "Add Imagery Layer", + description: expect.stringContaining("imagery layer"), + }), + expect.any(Function), + ); + }); + + it("should add imagery layer with URL template provider", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layerIndex: 1, + totalLayers: 2, + }); + + const response = await registeredHandler({ + type: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + name: "Test Layer", + }); + + expect(response.structuredContent.success).toBe(true); + expect(response.structuredContent.layerName).toBe("Test Layer"); + expect(response.structuredContent.layerIndex).toBe(1); + expect(response.structuredContent.providerType).toBe( + "UrlTemplateImageryProvider", + ); + expect(response.isError).toBe(false); + + const command = mockCommunicationServer.executeCommand.mock.calls[0][0]; + expect(command).toMatchObject({ + type: "imagery_add", + providerType: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + name: "Test Layer", + }); + }); + + it("should add WMS imagery layer with layers and style", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layerIndex: 2, + totalLayers: 3, + }); + + const response = await registeredHandler({ + type: "WebMapServiceImageryProvider", + url: "https://example.com/wms", + layers: "terrain,roads", + style: "default", + format: "image/png", + }); + + expect(response.structuredContent.success).toBe(true); + expect(response.structuredContent.layerIndex).toBe(2); + + const command = mockCommunicationServer.executeCommand.mock.calls[0][0]; + expect(command).toMatchObject({ + type: "imagery_add", + providerType: "WebMapServiceImageryProvider", + layers: "terrain,roads", + style: "default", + format: "image/png", + }); + }); + + it("should add imagery layer with alpha and visibility", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layerIndex: 1, + }); + + const response = await registeredHandler({ + type: "OpenStreetMapImageryProvider", + url: "https://tile.openstreetmap.org/", + alpha: 0.5, + show: false, + }); + + expect(response.structuredContent.success).toBe(true); + + const command = mockCommunicationServer.executeCommand.mock.calls[0][0]; + expect(command).toMatchObject({ + alpha: 0.5, + show: false, + }); + }); + + it("should add imagery layer with geographic rectangle", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layerIndex: 1, + }); + + const rectangle = { west: -180, south: -90, east: 180, north: 90 }; + + await registeredHandler({ + type: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + rectangle, + }); + + const command = mockCommunicationServer.executeCommand.mock.calls[0][0]; + expect(command).toMatchObject({ rectangle }); + }); + + it("should add WMTS imagery layer with tileMatrixSetID", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layerIndex: 1, + }); + + await registeredHandler({ + type: "WebMapTileServiceImageryProvider", + url: "https://example.com/wmts", + layers: "satellite", + tileMatrixSetID: "GoogleMapsCompatible", + }); + + const command = mockCommunicationServer.executeCommand.mock.calls[0][0]; + expect(command).toMatchObject({ + providerType: "WebMapTileServiceImageryProvider", + tileMatrixSetID: "GoogleMapsCompatible", + }); + }); + + it("should add imagery layer with zoom level constraints", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layerIndex: 1, + }); + + await registeredHandler({ + type: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + minimumLevel: 3, + maximumLevel: 18, + }); + + const command = mockCommunicationServer.executeCommand.mock.calls[0][0]; + expect(command).toMatchObject({ + minimumLevel: 3, + maximumLevel: 18, + }); + }); + + it("should use provider type as layer name when name not provided", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layerIndex: 0, + }); + + const response = await registeredHandler({ + type: "IonImageryProvider", + url: "https://example.com", + }); + + expect(response.structuredContent.layerName).toBe("IonImageryProvider"); + }); + + it("should use result layerName when name not provided but result has one", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layerIndex: 0, + layerName: "Auto-generated Name", + }); + + const response = await registeredHandler({ + type: "IonImageryProvider", + url: "https://example.com", + }); + + expect(response.structuredContent.layerName).toBe("Auto-generated Name"); + }); + + it("should include totalLayers in stats when provided", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layerIndex: 3, + totalLayers: 4, + }); + + const response = await registeredHandler({ + type: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + }); + + expect(response.structuredContent.stats.totalLayers).toBe(4); + }); + + it("should include responseTime in stats", async () => { + mockCommunicationServer.executeCommand.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ success: true, layerIndex: 0 }), 10); + }), + ); + + const response = await registeredHandler({ + type: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + }); + + expect(response.structuredContent.stats.responseTime).toBeGreaterThan(0); + }); + }); + + describe("Unhappy paths", () => { + it("should handle communication server error", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Connection failed"), + ); + + const response = await registeredHandler({ + type: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + }); + + expect(response.structuredContent.success).toBe(false); + expect(response.structuredContent.message).toContain("Connection failed"); + expect(response.isError).toBe(true); + }); + + it("should handle result with success=false", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: false, + error: "Invalid provider URL", + }); + + const response = await registeredHandler({ + type: "UrlTemplateImageryProvider", + url: "invalid-url", + }); + + expect(response.structuredContent.success).toBe(false); + expect(response.structuredContent.message).toContain( + "Invalid provider URL", + ); + expect(response.isError).toBe(true); + }); + + it("should handle result with success=false and no error message", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: false, + }); + + const response = await registeredHandler({ + type: "UrlTemplateImageryProvider", + url: "https://example.com", + }); + + expect(response.structuredContent.message).toContain( + "Unknown error from Cesium", + ); + expect(response.isError).toBe(true); + }); + + it("should handle timeout error", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Timeout"), + ); + + const response = await registeredHandler({ + type: "UrlTemplateImageryProvider", + url: "https://example.com", + }); + + expect(response.structuredContent.success).toBe(false); + expect(response.structuredContent.message).toContain("Timeout"); + }); + + it("should include providerType in error response", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Test error"), + ); + + const response = await registeredHandler({ + type: "ArcGisMapServerImageryProvider", + url: "https://example.com", + name: "Failed Layer", + }); + + expect(response.structuredContent.providerType).toBe( + "ArcGisMapServerImageryProvider", + ); + expect(response.structuredContent.layerName).toBe("Failed Layer"); + }); + + it("should set responseTime to 0 in error response", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Error"), + ); + + const response = await registeredHandler({ + type: "UrlTemplateImageryProvider", + url: "https://example.com", + }); + + expect(response.structuredContent.stats.responseTime).toBe(0); + }); + }); +}); diff --git a/mcp/cesium-js/servers/imagery-server/test/tools/imagery-list.test.ts b/mcp/cesium-js/servers/imagery-server/test/tools/imagery-list.test.ts new file mode 100644 index 0000000..2d3c70b --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/test/tools/imagery-list.test.ts @@ -0,0 +1,286 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ICommunicationServer } from "@cesium-mcp/shared"; +import type { ImageryListResponse } from "../../src/schemas/index"; +import { registerImageryList } from "../../src/tools/imagery-list"; + +describe("registerImageryList", () => { + let mockServer: { registerTool: ReturnType }; + let mockCommunicationServer: { executeCommand: ReturnType }; + let registeredHandler: (args: unknown) => Promise<{ + content: Array<{ type: string; text: string }>; + structuredContent: ImageryListResponse; + }>; + + beforeEach(() => { + mockServer = { + registerTool: vi.fn((_name, _config, handler) => { + registeredHandler = handler; + }), + }; + mockCommunicationServer = { + executeCommand: vi.fn(), + }; + + registerImageryList( + mockServer as unknown as McpServer, + mockCommunicationServer as unknown as ICommunicationServer, + ); + }); + + describe("Happy paths", () => { + it('should register tool with name "imagery_list"', () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "imagery_list", + expect.objectContaining({ + title: "List Imagery Layers", + }), + expect.any(Function), + ); + }); + + it("should list imagery layers with empty result", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layers: [], + }); + + const response = await registeredHandler({}); + + expect(response.structuredContent.success).toBe(true); + expect(response.structuredContent.layers).toEqual([]); + expect(response.structuredContent.totalCount).toBe(0); + expect(response.structuredContent.message).toContain("0"); + }); + + it("should list imagery layers with multiple results", async () => { + const layers = [ + { + index: 0, + name: "Base", + show: true, + alpha: 1, + providerType: "IonImageryProvider", + }, + { + index: 1, + name: "Satellite", + show: true, + alpha: 0.8, + providerType: "UrlTemplateImageryProvider", + url: "https://example.com", + }, + { + index: 2, + name: "Overlay", + show: false, + alpha: 0.5, + providerType: "WebMapServiceImageryProvider", + }, + ]; + + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layers, + }); + + const response = await registeredHandler({ includeDetails: true }); + + expect(response.structuredContent.success).toBe(true); + expect(response.structuredContent.layers).toEqual(layers); + expect(response.structuredContent.totalCount).toBe(3); + expect(response.structuredContent.message).toContain("3"); + + const command = mockCommunicationServer.executeCommand.mock.calls[0][0]; + expect(command).toMatchObject({ + type: "imagery_list", + includeDetails: true, + }); + }); + + it("should include layer details in structuredContent", async () => { + const layers = [ + { + index: 0, + name: "Base Layer", + show: true, + alpha: 1, + providerType: "IonImageryProvider", + }, + ]; + + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layers, + }); + + const response = await registeredHandler({ includeDetails: true }); + + expect(response.structuredContent.layers[0].name).toBe("Base Layer"); + expect(response.structuredContent.layers[0].show).toBe(true); + expect(response.structuredContent.layers[0].providerType).toBe( + "IonImageryProvider", + ); + }); + + it("should include show status in structuredContent for hidden layers", async () => { + const layers = [ + { index: 0, name: "Hidden Layer", show: false, alpha: 0.5 }, + ]; + + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layers, + }); + + const response = await registeredHandler({}); + + expect(response.structuredContent.layers[0].show).toBe(false); + }); + + it("should handle layers without name in structuredContent", async () => { + const layers = [{ index: 0, show: true, alpha: 1 }]; + + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layers, + }); + + const response = await registeredHandler({}); + + expect(response.structuredContent.layers[0].index).toBe(0); + expect(response.content[0].text).toContain("1 imagery layer"); + }); + + it("should default includeDetails to false", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layers: [], + }); + + await registeredHandler({}); + + const command = mockCommunicationServer.executeCommand.mock.calls[0][0]; + expect(command).toMatchObject({ includeDetails: false }); + }); + + it("should not show provider type when includeDetails is false", async () => { + const layers = [ + { + index: 0, + name: "Test", + show: true, + alpha: 1, + providerType: "IonImageryProvider", + }, + ]; + + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layers, + }); + + const response = await registeredHandler({}); + + expect(response.content[0].text).not.toContain("IonImageryProvider"); + }); + + it("should handle singular layer message", async () => { + const layers = [{ index: 0, name: "Only", show: true, alpha: 1 }]; + + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + layers, + }); + + const response = await registeredHandler({}); + + expect(response.structuredContent.message).toContain("1 imagery layer"); + expect(response.structuredContent.message).not.toContain("layers"); + }); + + it("should include responseTime in stats", async () => { + mockCommunicationServer.executeCommand.mockImplementation( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ success: true, layers: [] }), 10); + }), + ); + + const response = await registeredHandler({}); + + expect(response.structuredContent.stats.responseTime).toBeGreaterThan(0); + }); + + it("should handle missing layers array in result", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + }); + + const response = await registeredHandler({}); + + expect(response.structuredContent.layers).toEqual([]); + expect(response.structuredContent.totalCount).toBe(0); + }); + }); + + describe("Unhappy paths", () => { + it("should handle communication server error", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Connection failed"), + ); + + const response = await registeredHandler({}); + + expect(response.structuredContent.success).toBe(false); + expect(response.structuredContent.message).toContain("Connection failed"); + }); + + it("should handle result with success=false", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: false, + error: "Scene not initialized", + }); + + const response = await registeredHandler({}); + + expect(response.structuredContent.success).toBe(false); + expect(response.structuredContent.message).toContain( + "Scene not initialized", + ); + }); + + it("should handle result with success=false and no error message", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: false, + }); + + const response = await registeredHandler({}); + + expect(response.structuredContent.message).toContain( + "Unknown error from Cesium", + ); + }); + + it("should return empty layers array in error response", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Error"), + ); + + const response = await registeredHandler({}); + + expect(response.structuredContent.layers).toEqual([]); + expect(response.structuredContent.totalCount).toBe(0); + }); + + it("should set responseTime to 0 in error response", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Error"), + ); + + const response = await registeredHandler({}); + + expect(response.structuredContent.stats.responseTime).toBe(0); + }); + }); +}); diff --git a/mcp/cesium-js/servers/imagery-server/test/tools/imagery-remove.test.ts b/mcp/cesium-js/servers/imagery-server/test/tools/imagery-remove.test.ts new file mode 100644 index 0000000..ed5a680 --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/test/tools/imagery-remove.test.ts @@ -0,0 +1,235 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { ICommunicationServer } from "@cesium-mcp/shared"; +import type { ImageryRemoveResponse } from "../../src/schemas/index"; +import { registerImageryRemove } from "../../src/tools/imagery-remove"; + +describe("registerImageryRemove", () => { + let mockServer: { registerTool: ReturnType }; + let mockCommunicationServer: { executeCommand: ReturnType }; + let registeredHandler: (args: unknown) => Promise<{ + structuredContent: ImageryRemoveResponse; + isError: boolean; + }>; + + beforeEach(() => { + mockServer = { + registerTool: vi.fn((_name, _config, handler) => { + registeredHandler = handler; + }), + }; + mockCommunicationServer = { + executeCommand: vi.fn(), + }; + + registerImageryRemove( + mockServer as unknown as McpServer, + mockCommunicationServer as unknown as ICommunicationServer, + ); + }); + + describe("Happy paths", () => { + it('should register tool with name "imagery_remove"', () => { + expect(mockServer.registerTool).toHaveBeenCalledWith( + "imagery_remove", + expect.objectContaining({ + title: "Remove Imagery Layer", + }), + expect.any(Function), + ); + }); + + it("should remove imagery layer by index", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + removedIndex: 2, + removedCount: 1, + }); + + const response = await registeredHandler({ index: 2 }); + + expect(response.structuredContent.success).toBe(true); + expect(response.structuredContent.message).toContain("index 2"); + expect(response.structuredContent.removedIndex).toBe(2); + expect(response.structuredContent.removedCount).toBe(1); + expect(response.isError).toBe(false); + + const command = mockCommunicationServer.executeCommand.mock.calls[0][0]; + expect(command).toMatchObject({ + type: "imagery_remove", + index: 2, + }); + }); + + it("should remove imagery layer by name", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + removedName: "Satellite", + removedCount: 1, + }); + + const response = await registeredHandler({ name: "Satellite" }); + + expect(response.structuredContent.success).toBe(true); + expect(response.structuredContent.message).toContain("Satellite"); + expect(response.structuredContent.removedName).toBe("Satellite"); + }); + + it("should remove all non-base imagery layers", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + removedCount: 3, + }); + + const response = await registeredHandler({ removeAll: true }); + + expect(response.structuredContent.success).toBe(true); + expect(response.structuredContent.message).toContain("3"); + expect(response.structuredContent.message).toContain("layers"); + expect(response.structuredContent.removedCount).toBe(3); + }); + + it("should handle removing single layer with removeAll", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + removedCount: 1, + }); + + const response = await registeredHandler({ removeAll: true }); + + expect(response.structuredContent.message).toContain("1"); + expect(response.structuredContent.message).toContain("layer"); + // Should not contain "layers" (plural) for count 1 + expect(response.structuredContent.message).not.toMatch(/\blayers\b/); + }); + + it("should report 0 removed when removeAll on empty collection", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + removedCount: 0, + }); + + const response = await registeredHandler({ removeAll: true }); + + expect(response.structuredContent.success).toBe(true); + expect(response.structuredContent.removedCount).toBe(0); + expect(response.structuredContent.message).toContain("0"); + }); + + it("should use result removedIndex when available", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + removedIndex: 5, + removedCount: 1, + }); + + const response = await registeredHandler({ index: 5 }); + + expect(response.structuredContent.removedIndex).toBe(5); + }); + + it("should default removeAll to false", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: true, + removedCount: 1, + }); + + await registeredHandler({ index: 0 }); + + const command = mockCommunicationServer.executeCommand.mock.calls[0][0]; + expect(command).toMatchObject({ removeAll: false }); + }); + }); + + describe("Unhappy paths", () => { + it("should throw error when neither index, name, nor removeAll is provided", async () => { + const response = await registeredHandler({}); + + expect(response.structuredContent.success).toBe(false); + expect(response.structuredContent.message).toContain( + "index, name, or removeAll", + ); + expect(response.isError).toBe(true); + }); + + it("should handle communication server error", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Connection failed"), + ); + + const response = await registeredHandler({ index: 1 }); + + expect(response.structuredContent.success).toBe(false); + expect(response.structuredContent.message).toContain("Connection failed"); + expect(response.isError).toBe(true); + }); + + it("should handle result with success=false", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: false, + error: "Layer not found", + }); + + const response = await registeredHandler({ index: 99 }); + + expect(response.structuredContent.success).toBe(false); + expect(response.structuredContent.message).toContain("Layer not found"); + expect(response.isError).toBe(true); + }); + + it("should handle result with success=false and no error message", async () => { + mockCommunicationServer.executeCommand.mockResolvedValue({ + success: false, + }); + + const response = await registeredHandler({ name: "Missing" }); + + expect(response.structuredContent.message).toContain( + "Unknown error from Cesium", + ); + expect(response.isError).toBe(true); + }); + + it("should include identifier in error response for index", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Error"), + ); + + const response = await registeredHandler({ index: 3 }); + + expect(response.structuredContent.message).toContain("index 3"); + expect(response.structuredContent.removedIndex).toBe(3); + }); + + it("should include identifier in error response for name", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Error"), + ); + + const response = await registeredHandler({ name: "TestLayer" }); + + expect(response.structuredContent.message).toContain("TestLayer"); + expect(response.structuredContent.removedName).toBe("TestLayer"); + }); + + it("should set responseTime to 0 in error response", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Error"), + ); + + const response = await registeredHandler({ index: 0 }); + + expect(response.structuredContent.stats.responseTime).toBe(0); + }); + + it("should set removedCount to 0 in error response", async () => { + mockCommunicationServer.executeCommand.mockRejectedValue( + new Error("Error"), + ); + + const response = await registeredHandler({ index: 0 }); + + expect(response.structuredContent.removedCount).toBe(0); + }); + }); +}); diff --git a/mcp/cesium-js/servers/imagery-server/tsconfig.json b/mcp/cesium-js/servers/imagery-server/tsconfig.json new file mode 100644 index 0000000..2a0c6dd --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2020"], + "outDir": "./build", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "baseUrl": ".", + "paths": { + "@cesium-mcp/shared": ["../shared/src"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "build"] +} diff --git a/mcp/cesium-js/servers/imagery-server/vitest.config.ts b/mcp/cesium-js/servers/imagery-server/vitest.config.ts new file mode 100644 index 0000000..c3442db --- /dev/null +++ b/mcp/cesium-js/servers/imagery-server/vitest.config.ts @@ -0,0 +1,27 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +export default defineConfig({ + test: { + globals: true, + environment: "node", + include: ["test/**/*.test.ts"], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["src/**/*.ts"], + exclude: [ + "**/*.test.ts", + "**/index.ts", + "**/*.d.ts", + "**/build/**", + "**/test/**", + ], + }, + }, + resolve: { + alias: { + "@cesium-mcp/shared": path.resolve(__dirname, "../shared/src"), + }, + }, +}); diff --git a/mcp/cesium-js/test-applications/packages/client-core/src/cesium-app.ts b/mcp/cesium-js/test-applications/packages/client-core/src/cesium-app.ts index e1e1cd0..828906c 100644 --- a/mcp/cesium-js/test-applications/packages/client-core/src/cesium-app.ts +++ b/mcp/cesium-js/test-applications/packages/client-core/src/cesium-app.ts @@ -9,6 +9,7 @@ import type { CesiumViewer } from "./types/cesium-types.js"; import CesiumCameraController from "./managers/camera-manager.js"; import CesiumEntityManager from "./managers/entity-manager.js"; import CesiumAnimationManager from "./managers/animation-manager.js"; +import CesiumImageryManager from "./managers/imagery-manager.js"; import { BaseCommunicationManager } from "./communications/base-communication.js"; import SSECommunicationManager from "./communications/sse-communication.js"; import WebSocketCommunicationManager from "./communications/websocket-communication.js"; @@ -112,6 +113,7 @@ export class CesiumApp { new CesiumCameraController(this.viewer), new CesiumEntityManager(this.viewer), new CesiumAnimationManager(this.viewer), + new CesiumImageryManager(this.viewer), ]; } diff --git a/mcp/cesium-js/test-applications/packages/client-core/src/managers/imagery-manager.ts b/mcp/cesium-js/test-applications/packages/client-core/src/managers/imagery-manager.ts new file mode 100644 index 0000000..a114bb0 --- /dev/null +++ b/mcp/cesium-js/test-applications/packages/client-core/src/managers/imagery-manager.ts @@ -0,0 +1,375 @@ +/** + * Cesium Imagery Manager Module + * Handles imagery layer operations: add, remove, and list imagery providers + */ + +import type { + MCPCommand, + CommandHandler, + ManagerInterface, +} from "../types/mcp.js"; +import type { + ImageryAddResult, + ImageryRemoveResult, + ImageryLayerInfo, + ImageryListResult, +} from "../types/imagery-types.js"; +import type { CesiumViewer } from "../types/cesium-types.js"; +import { + ImageryProvider, + ImageryLayer, + UrlTemplateImageryProvider, + WebMapServiceImageryProvider, + WebMapTileServiceImageryProvider, + OpenStreetMapImageryProvider, + ArcGisMapServerImageryProvider, + BingMapsImageryProvider, + TileMapServiceImageryProvider, + IonImageryProvider, + SingleTileImageryProvider, + GoogleEarthEnterpriseImageryProvider, + GoogleEarthEnterpriseMetadata, + Rectangle, +} from "cesium"; + +// Extend ImageryLayer with custom metadata +interface ImageryLayerWithMeta extends ImageryLayer { + _mcpName?: string; +} + +class CesiumImageryManager implements ManagerInterface { + public viewer: CesiumViewer; + + constructor(viewer: CesiumViewer) { + this.viewer = viewer; + } + + public setUp(): void { + // No initialization needed + } + + public shutdown(): void { + // No cleanup needed + } + + /** + * Create an imagery provider based on provider type and parameters. + * Some providers (ArcGIS, Bing, TMS, Ion, SingleTile) require async fromUrl factory. + */ + private async createImageryProvider( + cmd: MCPCommand, + ): Promise { + const providerType = cmd.providerType as string; + const url = cmd.url as string; + const layers = cmd.layers as string | undefined; + const style = cmd.style as string | undefined; + const format = cmd.format as string | undefined; + const tileMatrixSetID = cmd.tileMatrixSetID as string | undefined; + const minimumLevel = cmd.minimumLevel as number | undefined; + const maximumLevel = cmd.maximumLevel as number | undefined; + const rectangle = cmd.rectangle as + | { west: number; south: number; east: number; north: number } + | undefined; + + const rect = rectangle + ? Rectangle.fromDegrees( + rectangle.west, + rectangle.south, + rectangle.east, + rectangle.north, + ) + : undefined; + + switch (providerType) { + case "UrlTemplateImageryProvider": + return new UrlTemplateImageryProvider({ + url, + minimumLevel, + maximumLevel, + rectangle: rect, + }); + + case "WebMapServiceImageryProvider": + return new WebMapServiceImageryProvider({ + url, + layers: layers || "", + parameters: { + transparent: true, + format: format || "image/png", + styles: style || "", + }, + rectangle: rect, + }); + + case "WebMapTileServiceImageryProvider": + return new WebMapTileServiceImageryProvider({ + url, + layer: layers || "", + style: style || "default", + format: format || "image/png", + tileMatrixSetID: tileMatrixSetID || "default", + rectangle: rect, + }); + + case "ArcGisMapServerImageryProvider": + return await ArcGisMapServerImageryProvider.fromUrl(url, { + rectangle: rect, + }); + + case "BingMapsImageryProvider": + return await BingMapsImageryProvider.fromUrl(url, { + key: (cmd.key as string) || "", + }); + + case "TileMapServiceImageryProvider": + return await TileMapServiceImageryProvider.fromUrl(url, { + minimumLevel, + maximumLevel, + rectangle: rect, + }); + + case "OpenStreetMapImageryProvider": + return new OpenStreetMapImageryProvider({ + url: url || "https://tile.openstreetmap.org/", + }); + + case "IonImageryProvider": + return await IonImageryProvider.fromAssetId(cmd.assetId as number); + + case "SingleTileImageryProvider": + return await SingleTileImageryProvider.fromUrl(url, { + rectangle: rect || Rectangle.MAX_VALUE, + }); + + case "GoogleEarthEnterpriseImageryProvider": { + const metadata = await GoogleEarthEnterpriseMetadata.fromUrl(url); + return GoogleEarthEnterpriseImageryProvider.fromMetadata(metadata, {}); + } + + default: + return null; + } + } + + /** + * Detect the provider type name from a Cesium ImageryProvider instance + */ + private getProviderTypeName(provider: ImageryProvider): string { + if (provider instanceof WebMapServiceImageryProvider) { + return "WebMapServiceImageryProvider"; + } + if (provider instanceof WebMapTileServiceImageryProvider) { + return "WebMapTileServiceImageryProvider"; + } + if (provider instanceof ArcGisMapServerImageryProvider) { + return "ArcGisMapServerImageryProvider"; + } + if (provider instanceof BingMapsImageryProvider) { + return "BingMapsImageryProvider"; + } + if (provider instanceof OpenStreetMapImageryProvider) { + return "OpenStreetMapImageryProvider"; + } + if (provider instanceof TileMapServiceImageryProvider) { + return "TileMapServiceImageryProvider"; + } + if (provider instanceof IonImageryProvider) { + return "IonImageryProvider"; + } + if (provider instanceof SingleTileImageryProvider) { + return "SingleTileImageryProvider"; + } + if (provider instanceof GoogleEarthEnterpriseImageryProvider) { + return "GoogleEarthEnterpriseImageryProvider"; + } + if (provider instanceof UrlTemplateImageryProvider) { + return "UrlTemplateImageryProvider"; + } + return "Unknown"; + } + + /** + * Add an imagery layer + */ + private async addImagery(cmd: MCPCommand): Promise { + try { + const provider = await this.createImageryProvider(cmd); + if (!provider) { + return { + success: false, + error: `Unsupported provider type: ${cmd.providerType}`, + }; + } + + const layer = this.viewer.imageryLayers.addImageryProvider( + provider, + ) as ImageryLayerWithMeta; + const name = (cmd.name as string) || (cmd.providerType as string); + + // Apply optional settings + if (cmd.alpha !== undefined) { + layer.alpha = cmd.alpha as number; + } + if (cmd.show !== undefined) { + layer.show = cmd.show as boolean; + } + + // Store name for later reference + layer._mcpName = name; + + const layerIndex = this.viewer.imageryLayers.indexOf(layer); + + return { + success: true, + message: `Imagery layer '${name}' added successfully`, + layerIndex, + layerName: name, + providerType: cmd.providerType as string, + totalLayers: this.viewer.imageryLayers.length, + }; + } catch (error: unknown) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * Remove imagery layer(s) + */ + private removeImagery(cmd: MCPCommand): ImageryRemoveResult { + try { + const index = cmd.index as number | undefined; + const name = cmd.name as string | undefined; + const removeAll = cmd.removeAll as boolean | undefined; + + if (removeAll) { + const count = this.viewer.imageryLayers.length; + this.viewer.imageryLayers.removeAll(); + return { + success: true, + message: `Removed all ${count} imagery layers`, + removedCount: count, + totalLayers: 0, + }; + } + + if (index !== undefined) { + const layer = this.viewer.imageryLayers.get( + index, + ) as ImageryLayerWithMeta; + if (!layer) { + return { + success: false, + error: `No imagery layer at index ${index}`, + }; + } + const layerName = layer._mcpName || `Layer ${index}`; + this.viewer.imageryLayers.remove(layer); + return { + success: true, + message: `Imagery layer '${layerName}' at index ${index} removed`, + removedIndex: index, + removedName: layerName, + totalLayers: this.viewer.imageryLayers.length, + }; + } + + if (name) { + for (let i = 0; i < this.viewer.imageryLayers.length; i++) { + const layer = this.viewer.imageryLayers.get( + i, + ) as ImageryLayerWithMeta; + if (layer._mcpName === name) { + this.viewer.imageryLayers.remove(layer); + return { + success: true, + message: `Imagery layer '${name}' removed`, + removedIndex: i, + removedName: name, + totalLayers: this.viewer.imageryLayers.length, + }; + } + } + return { + success: false, + error: `No imagery layer found with name '${name}'`, + }; + } + + return { + success: false, + error: + "Either index, name, or removeAll must be provided for imagery removal", + }; + } catch (error: unknown) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } + } + + /** + * List all imagery layers + */ + private listImagery(): ImageryListResult { + try { + const layers: ImageryLayerInfo[] = []; + for (let i = 0; i < this.viewer.imageryLayers.length; i++) { + const layer = this.viewer.imageryLayers.get(i) as ImageryLayerWithMeta; + const name = layer._mcpName || `Layer ${i}`; + + const info: ImageryLayerInfo = { + index: i, + name, + show: layer.show, + alpha: layer.alpha, + ready: layer.ready, + }; + + if (layer.imageryProvider) { + info.providerType = this.getProviderTypeName(layer.imageryProvider); + const provider = layer.imageryProvider as ImageryProvider & { + url?: string; + }; + if (provider.url) { + info.url = provider.url; + } + } + + layers.push(info); + } + + return { + success: true, + layers, + totalCount: layers.length, + message: `Found ${layers.length} imagery layer${layers.length === 1 ? "" : "s"}`, + }; + } catch (error: unknown) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + layers: [], + }; + } + } + + public getCommandHandlers(): Map { + const handlers = new Map(); + + handlers.set("imagery_add", async (cmd: MCPCommand) => + this.addImagery(cmd), + ); + handlers.set("imagery_remove", (cmd: MCPCommand) => + this.removeImagery(cmd), + ); + handlers.set("imagery_list", () => this.listImagery()); + + return handlers; + } +} + +export default CesiumImageryManager; diff --git a/mcp/cesium-js/test-applications/packages/client-core/src/types/imagery-types.ts b/mcp/cesium-js/test-applications/packages/client-core/src/types/imagery-types.ts new file mode 100644 index 0000000..2da7d47 --- /dev/null +++ b/mcp/cesium-js/test-applications/packages/client-core/src/types/imagery-types.ts @@ -0,0 +1,30 @@ +import type { MCPCommandResult } from "./mcp.js"; + +export interface ImageryAddResult extends MCPCommandResult { + layerIndex?: number; + layerName?: string; + providerType?: string; + totalLayers?: number; +} + +export interface ImageryRemoveResult extends MCPCommandResult { + removedIndex?: number; + removedName?: string; + removedCount?: number; + totalLayers?: number; +} + +export interface ImageryLayerInfo { + index: number; + name: string; + show: boolean; + alpha: number; + providerType?: string; + url?: string; + ready?: boolean; +} + +export interface ImageryListResult extends MCPCommandResult { + layers?: ImageryLayerInfo[]; + totalCount?: number; +} diff --git a/mcp/cesium-js/test-applications/packages/client-core/test/unit/imagery-manager.unit.test.ts b/mcp/cesium-js/test-applications/packages/client-core/test/unit/imagery-manager.unit.test.ts new file mode 100644 index 0000000..56db513 --- /dev/null +++ b/mcp/cesium-js/test-applications/packages/client-core/test/unit/imagery-manager.unit.test.ts @@ -0,0 +1,617 @@ +/** + * Unit Tests for Imagery Manager MCP Communication + * Tests request/response handling between imagery manager and MCP server + */ + +import { describe, it, expect, vi, beforeEach } from "vitest"; +import CesiumImageryManager from "../../src/managers/imagery-manager"; +import type { CesiumViewer } from "../../src/types/cesium-types"; +import type { MCPCommand } from "../../src/types/mcp"; +import type { + ImageryAddResult, + ImageryRemoveResult, + ImageryListResult, +} from "../../src/types/imagery-types"; + +// Mock Cesium module +vi.mock("cesium", () => { + class MockImageryProvider { + url?: string; + } + + class MockUrlTemplateImageryProvider extends MockImageryProvider { + constructor(options: { url: string }) { + super(); + this.url = options.url; + } + } + + class MockWebMapServiceImageryProvider extends MockImageryProvider { + constructor(options: { url: string }) { + super(); + this.url = options.url; + } + } + + class MockWebMapTileServiceImageryProvider extends MockImageryProvider { + constructor(options: { url: string }) { + super(); + this.url = options.url; + } + } + + class MockOpenStreetMapImageryProvider extends MockImageryProvider { + constructor(options?: { url?: string }) { + super(); + this.url = options?.url || "https://tile.openstreetmap.org/"; + } + } + + class MockArcGisMapServerImageryProvider extends MockImageryProvider { + static async fromUrl(url: string) { + const provider = new MockArcGisMapServerImageryProvider(); + provider.url = url; + return provider; + } + } + + class MockBingMapsImageryProvider extends MockImageryProvider { + static async fromUrl(url: string) { + const provider = new MockBingMapsImageryProvider(); + provider.url = url; + return provider; + } + } + + class MockTileMapServiceImageryProvider extends MockImageryProvider { + static async fromUrl(url: string) { + const provider = new MockTileMapServiceImageryProvider(); + provider.url = url; + return provider; + } + } + + class MockIonImageryProvider extends MockImageryProvider { + static async fromAssetId(assetId: number) { + const provider = new MockIonImageryProvider(); + provider.url = `ion://asset/${assetId}`; + return provider; + } + } + + class MockSingleTileImageryProvider extends MockImageryProvider { + static async fromUrl(url: string) { + const provider = new MockSingleTileImageryProvider(); + provider.url = url; + return provider; + } + } + + class MockGoogleEarthEnterpriseImageryProvider extends MockImageryProvider { + static fromMetadata(metadata: unknown) { + const provider = new MockGoogleEarthEnterpriseImageryProvider(); + provider.url = (metadata as { url: string }).url; + return provider; + } + } + + class MockGoogleEarthEnterpriseMetadata { + url: string; + constructor() { + this.url = ""; + } + static async fromUrl(url: string) { + const metadata = new MockGoogleEarthEnterpriseMetadata(); + metadata.url = url; + return metadata; + } + } + + return { + ImageryProvider: MockImageryProvider, + ImageryLayer: class {}, + UrlTemplateImageryProvider: MockUrlTemplateImageryProvider, + WebMapServiceImageryProvider: MockWebMapServiceImageryProvider, + WebMapTileServiceImageryProvider: MockWebMapTileServiceImageryProvider, + OpenStreetMapImageryProvider: MockOpenStreetMapImageryProvider, + ArcGisMapServerImageryProvider: MockArcGisMapServerImageryProvider, + BingMapsImageryProvider: MockBingMapsImageryProvider, + TileMapServiceImageryProvider: MockTileMapServiceImageryProvider, + IonImageryProvider: MockIonImageryProvider, + SingleTileImageryProvider: MockSingleTileImageryProvider, + GoogleEarthEnterpriseImageryProvider: + MockGoogleEarthEnterpriseImageryProvider, + GoogleEarthEnterpriseMetadata: MockGoogleEarthEnterpriseMetadata, + Rectangle: { + fromDegrees: vi.fn( + (west: number, south: number, east: number, north: number) => ({ + west, + south, + east, + north, + }), + ), + MAX_VALUE: { west: -180, south: -90, east: 180, north: 90 }, + }, + }; +}); + +// Helper to create a mock imagery layer +function createMockLayer( + options: { + show?: boolean; + alpha?: number; + name?: string; + imageryProvider?: { url?: string }; + ready?: boolean; + } = {}, +) { + return { + show: options.show ?? true, + alpha: options.alpha ?? 1.0, + _mcpName: options.name, + imageryProvider: options.imageryProvider ?? null, + ready: options.ready ?? true, + }; +} + +describe("Imagery Manager MCP Communication Tests", () => { + let imageryManager: CesiumImageryManager; + let mockViewer: CesiumViewer; + let commandHandlers: Map unknown>; + let mockLayers: ReturnType[]; + + beforeEach(() => { + mockLayers = []; + + mockViewer = { + imageryLayers: { + length: 0, + addImageryProvider: vi.fn((provider: unknown) => { + const layer = createMockLayer({ + imageryProvider: provider as { url?: string }, + }); + mockLayers.push(layer); + (mockViewer.imageryLayers as { length: number }).length = + mockLayers.length; + return layer; + }), + get: vi.fn((index: number) => mockLayers[index] ?? null), + remove: vi.fn((layer: unknown) => { + const idx = mockLayers.indexOf( + layer as ReturnType, + ); + if (idx >= 0) { + mockLayers.splice(idx, 1); + (mockViewer.imageryLayers as { length: number }).length = + mockLayers.length; + return true; + } + return false; + }), + removeAll: vi.fn(() => { + mockLayers.length = 0; + (mockViewer.imageryLayers as { length: number }).length = 0; + }), + indexOf: vi.fn((layer: unknown) => + mockLayers.indexOf(layer as ReturnType), + ), + }, + } as unknown as CesiumViewer; + + imageryManager = new CesiumImageryManager(mockViewer); + commandHandlers = imageryManager.getCommandHandlers(); + }); + + describe("imagery_add", () => { + it("should add a UrlTemplateImageryProvider layer", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + name: "Test Layer", + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(response.layerName).toBe("Test Layer"); + expect(response.providerType).toBe("UrlTemplateImageryProvider"); + expect(response.layerIndex).toBeDefined(); + expect(response.totalLayers).toBe(1); + }); + + it("should add an OpenStreetMapImageryProvider layer", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "OpenStreetMapImageryProvider", + url: "https://tile.openstreetmap.org/", + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(response.totalLayers).toBe(1); + }); + + it("should add a WebMapServiceImageryProvider layer", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "WebMapServiceImageryProvider", + url: "https://example.com/wms", + layers: "layer1,layer2", + style: "default", + format: "image/png", + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(response.providerType).toBe("WebMapServiceImageryProvider"); + }); + + it("should add a WebMapTileServiceImageryProvider layer", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "WebMapTileServiceImageryProvider", + url: "https://example.com/wmts", + layers: "layer1", + tileMatrixSetID: "default028mm", + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(response.providerType).toBe("WebMapTileServiceImageryProvider"); + }); + + it("should add an ArcGisMapServerImageryProvider layer", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "ArcGisMapServerImageryProvider", + url: "https://services.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer", + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(response.providerType).toBe("ArcGisMapServerImageryProvider"); + }); + + it("should add a BingMapsImageryProvider layer with key", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "BingMapsImageryProvider", + url: "https://dev.virtualearth.net", + key: "test-bing-key", + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(response.providerType).toBe("BingMapsImageryProvider"); + }); + + it("should add an IonImageryProvider layer with assetId", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "IonImageryProvider", + url: "", + assetId: 3954, + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(response.providerType).toBe("IonImageryProvider"); + }); + + it("should add a SingleTileImageryProvider layer", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "SingleTileImageryProvider", + url: "https://example.com/image.png", + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(response.providerType).toBe("SingleTileImageryProvider"); + }); + + it("should add a GoogleEarthEnterpriseImageryProvider layer", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "GoogleEarthEnterpriseImageryProvider", + url: "https://earth.localdomain", + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(response.providerType).toBe( + "GoogleEarthEnterpriseImageryProvider", + ); + }); + + it("should apply alpha and show settings", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + alpha: 0.5, + show: false, + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(mockLayers[0].alpha).toBe(0.5); + expect(mockLayers[0].show).toBe(false); + }); + + it("should use providerType as name when name is not provided", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + expect(response.layerName).toBe("UrlTemplateImageryProvider"); + }); + + it("should return error for unsupported provider type", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "UnsupportedProvider", + url: "https://example.com", + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(false); + expect(response.error).toContain("Unsupported provider type"); + }); + + it("should handle rectangle parameter", async () => { + const command: MCPCommand = { + type: "imagery_add", + providerType: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + rectangle: { west: -120, south: 30, east: -100, north: 50 }, + }; + + const handler = commandHandlers.get("imagery_add")!; + const response = (await handler(command)) as ImageryAddResult; + + expect(response.success).toBe(true); + }); + }); + + describe("imagery_remove", () => { + beforeEach(async () => { + // Add two layers for removal tests + const handler = commandHandlers.get("imagery_add")!; + await handler({ + type: "imagery_add", + providerType: "UrlTemplateImageryProvider", + url: "https://example.com/1/{z}/{x}/{y}.png", + name: "Layer A", + } as MCPCommand); + await handler({ + type: "imagery_add", + providerType: "UrlTemplateImageryProvider", + url: "https://example.com/2/{z}/{x}/{y}.png", + name: "Layer B", + } as MCPCommand); + }); + + it("should remove layer by index", () => { + const command: MCPCommand = { + type: "imagery_remove", + index: 0, + }; + + const handler = commandHandlers.get("imagery_remove")!; + const response = handler(command) as ImageryRemoveResult; + + expect(response.success).toBe(true); + expect(response.removedIndex).toBe(0); + expect(response.removedName).toBe("Layer A"); + expect(response.totalLayers).toBe(1); + }); + + it("should remove layer by name", () => { + const command: MCPCommand = { + type: "imagery_remove", + name: "Layer B", + }; + + const handler = commandHandlers.get("imagery_remove")!; + const response = handler(command) as ImageryRemoveResult; + + expect(response.success).toBe(true); + expect(response.removedName).toBe("Layer B"); + expect(response.totalLayers).toBe(1); + }); + + it("should remove all layers", () => { + const command: MCPCommand = { + type: "imagery_remove", + removeAll: true, + }; + + const handler = commandHandlers.get("imagery_remove")!; + const response = handler(command) as ImageryRemoveResult; + + expect(response.success).toBe(true); + expect(response.removedCount).toBe(2); + expect(response.totalLayers).toBe(0); + }); + + it("should handle removeAll on empty collection", () => { + // Remove all first to empty the collection + const handler = commandHandlers.get("imagery_remove")!; + handler({ + type: "imagery_remove", + removeAll: true, + } as MCPCommand); + + // Now removeAll on empty + const response = handler({ + type: "imagery_remove", + removeAll: true, + } as MCPCommand) as ImageryRemoveResult; + + expect(response.success).toBe(true); + expect(response.removedCount).toBe(0); + }); + + it("should return error for invalid index", () => { + const command: MCPCommand = { + type: "imagery_remove", + index: 99, + }; + + const handler = commandHandlers.get("imagery_remove")!; + const response = handler(command) as ImageryRemoveResult; + + expect(response.success).toBe(false); + expect(response.error).toContain("No imagery layer at index"); + }); + + it("should return error for non-existent name", () => { + const command: MCPCommand = { + type: "imagery_remove", + name: "NonExistent", + }; + + const handler = commandHandlers.get("imagery_remove")!; + const response = handler(command) as ImageryRemoveResult; + + expect(response.success).toBe(false); + expect(response.error).toContain("No imagery layer found with name"); + }); + + it("should return error when no removal criteria provided", () => { + const command: MCPCommand = { + type: "imagery_remove", + }; + + const handler = commandHandlers.get("imagery_remove")!; + const response = handler(command) as ImageryRemoveResult; + + expect(response.success).toBe(false); + expect(response.error).toContain( + "Either index, name, or removeAll must be provided", + ); + }); + }); + + describe("imagery_list", () => { + it("should list empty layers", () => { + const handler = commandHandlers.get("imagery_list")!; + const response = handler({ + type: "imagery_list", + } as MCPCommand) as ImageryListResult; + + expect(response.success).toBe(true); + expect(response.layers).toEqual([]); + expect(response.totalCount).toBe(0); + }); + + it("should list layers after adding", async () => { + const addHandler = commandHandlers.get("imagery_add")!; + await addHandler({ + type: "imagery_add", + providerType: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + name: "My Layer", + } as MCPCommand); + + const listHandler = commandHandlers.get("imagery_list")!; + const response = listHandler({ + type: "imagery_list", + } as MCPCommand) as ImageryListResult; + + expect(response.success).toBe(true); + expect(response.totalCount).toBe(1); + expect(response.layers).toHaveLength(1); + expect(response.layers![0].name).toBe("My Layer"); + expect(response.layers![0].index).toBe(0); + expect(response.layers![0].show).toBe(true); + expect(response.layers![0].alpha).toBe(1.0); + }); + + it("should include url and ready in layer info", async () => { + const addHandler = commandHandlers.get("imagery_add")!; + await addHandler({ + type: "imagery_add", + providerType: "UrlTemplateImageryProvider", + url: "https://example.com/tiles/{z}/{x}/{y}.png", + name: "Test", + } as MCPCommand); + + const listHandler = commandHandlers.get("imagery_list")!; + const response = listHandler({ + type: "imagery_list", + } as MCPCommand) as ImageryListResult; + + expect(response.success).toBe(true); + expect(response.layers![0].ready).toBeDefined(); + expect(response.layers![0].url).toBe( + "https://example.com/tiles/{z}/{x}/{y}.png", + ); + }); + + it("should list multiple layers with correct indices", async () => { + const addHandler = commandHandlers.get("imagery_add")!; + await addHandler({ + type: "imagery_add", + providerType: "UrlTemplateImageryProvider", + url: "https://a.com", + name: "First", + } as MCPCommand); + await addHandler({ + type: "imagery_add", + providerType: "OpenStreetMapImageryProvider", + url: "https://b.com", + name: "Second", + } as MCPCommand); + + const listHandler = commandHandlers.get("imagery_list")!; + const response = listHandler({ + type: "imagery_list", + } as MCPCommand) as ImageryListResult; + + expect(response.success).toBe(true); + expect(response.totalCount).toBe(2); + expect(response.layers![0].name).toBe("First"); + expect(response.layers![1].name).toBe("Second"); + }); + }); + + describe("command handler registration", () => { + it("should register all three command handlers", () => { + expect(commandHandlers.has("imagery_add")).toBe(true); + expect(commandHandlers.has("imagery_remove")).toBe(true); + expect(commandHandlers.has("imagery_list")).toBe(true); + expect(commandHandlers.size).toBe(3); + }); + }); +}); diff --git a/mcp/cesium-js/test-applications/web-app/.env.example b/mcp/cesium-js/test-applications/web-app/.env.example index 728d589..307696b 100644 --- a/mcp/cesium-js/test-applications/web-app/.env.example +++ b/mcp/cesium-js/test-applications/web-app/.env.example @@ -11,3 +11,4 @@ MCP_PROTOCOL=websocket MCP_CAMERA_PORT=3002 MCP_ENTITY_PORT=3003 MCP_ANIMATION_PORT=3004 +MCP_IMAGERY_PORT=3005 diff --git a/mcp/cesium-js/test-applications/web-app/esbuild.config.cjs b/mcp/cesium-js/test-applications/web-app/esbuild.config.cjs index 6c52a20..8f7791a 100644 --- a/mcp/cesium-js/test-applications/web-app/esbuild.config.cjs +++ b/mcp/cesium-js/test-applications/web-app/esbuild.config.cjs @@ -23,6 +23,9 @@ const define = { "process.env.MCP_ANIMATION_PORT": JSON.stringify( process.env.MCP_ANIMATION_PORT || "3004", ), + "process.env.MCP_IMAGERY_PORT": JSON.stringify( + process.env.MCP_IMAGERY_PORT || "3005", + ), }; esbuild diff --git a/mcp/cesium-js/test-applications/web-app/src/app.ts b/mcp/cesium-js/test-applications/web-app/src/app.ts index 63f2d76..8987301 100644 --- a/mcp/cesium-js/test-applications/web-app/src/app.ts +++ b/mcp/cesium-js/test-applications/web-app/src/app.ts @@ -35,6 +35,10 @@ const config: CesiumAppConfig = { name: "Animation Server", port: parseInt(process.env.MCP_ANIMATION_PORT || "3004"), }, + { + name: "Imagery Server", + port: parseInt(process.env.MCP_IMAGERY_PORT || "3005"), + }, ], };