From 3544d0ded53a4d2273af717aa5856a41b79e817f Mon Sep 17 00:00:00 2001 From: AlexanderNZ Date: Thu, 29 Jan 2026 16:45:18 +1300 Subject: [PATCH 1/2] refactor: remove teams support and harden tests Eliminate team-related tools and client code, simplify model object handling, and update integration tests to auto-detect write scope with clearer docs on test configuration. --- .cursor/rules/important-files.mdc | 2 +- .env.example | 12 + .gitignore | 1 + README.md | 233 +++++- bin/icepanel-mcp-server.js | 2 +- package.json | 10 +- pnpm-lock.yaml | 977 +++++++++++++++++++++- src/constants.ts | 4 + src/icepanel.ts | 311 ------- src/index.ts | 51 ++ src/main.ts | 304 ------- src/schemas/index.ts | 78 ++ src/{format.ts => services/formatters.ts} | 167 ++-- src/services/icepanel-client.ts | 627 ++++++++++++++ src/tools/connections.ts | 138 +++ src/tools/domains.ts | 119 +++ src/tools/index.ts | 16 + src/tools/landscapes.ts | 113 +++ src/tools/model-objects.ts | 391 +++++++++ src/tools/tags.ts | 114 +++ src/tools/technologies.ts | 97 +++ src/tools/utils.ts | 54 ++ src/transports/http-server.ts | 125 +++ src/types.ts | 165 +++- tests/helpers/mcp.ts | 142 ++++ tests/read-tools.int.test.ts | 126 +++ tests/setup.ts | 7 + tests/write-tools.int.test.ts | 240 ++++++ vitest.config.ts | 9 + 29 files changed, 3860 insertions(+), 775 deletions(-) create mode 100644 src/constants.ts delete mode 100644 src/icepanel.ts create mode 100644 src/index.ts delete mode 100644 src/main.ts create mode 100644 src/schemas/index.ts rename src/{format.ts => services/formatters.ts} (54%) create mode 100644 src/services/icepanel-client.ts create mode 100644 src/tools/connections.ts create mode 100644 src/tools/domains.ts create mode 100644 src/tools/index.ts create mode 100644 src/tools/landscapes.ts create mode 100644 src/tools/model-objects.ts create mode 100644 src/tools/tags.ts create mode 100644 src/tools/technologies.ts create mode 100644 src/tools/utils.ts create mode 100644 src/transports/http-server.ts create mode 100644 tests/helpers/mcp.ts create mode 100644 tests/read-tools.int.test.ts create mode 100644 tests/setup.ts create mode 100644 tests/write-tools.int.test.ts create mode 100644 vitest.config.ts diff --git a/.cursor/rules/important-files.mdc b/.cursor/rules/important-files.mdc index ae8c819..e12e9b7 100644 --- a/.cursor/rules/important-files.mdc +++ b/.cursor/rules/important-files.mdc @@ -10,7 +10,7 @@ These files should be included in every chat: - `.cursor/rules/important-files.mdc`: This file - `package.json`: Project configuration - `tsconfig.json`: TypeScript configuration -- `src/main.ts`: Main entry point +- `src/index.ts`: Main entry point - `.gitignore`: Git ignore file If new files are added to the project that are important for understanding the codebase, please add them to this list. diff --git a/.env.example b/.env.example index de69b23..00a0139 100644 --- a/.env.example +++ b/.env.example @@ -6,3 +6,15 @@ ORGANIZATION_ID=your_org_id_here # IcePanel API Base URL (optional) # ICEPANEL_API_BASE_URL=https://api.icepanel.dev/v1 + +# Allow http base URLs for local testing (optional, default: false) +# ICEPANEL_API_ALLOW_INSECURE=true + +# API request timeout in ms (optional, default: 30000) +# ICEPANEL_API_TIMEOUT_MS=30000 + +# Max retries for GET/HEAD requests (optional, default: 2) +# ICEPANEL_API_MAX_RETRIES=2 + +# Base backoff delay in ms (optional, default: 300) +# ICEPANEL_API_RETRY_BASE_DELAY_MS=300 diff --git a/.gitignore b/.gitignore index 9ee99a7..dc22aa6 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,7 @@ dist/ # Dependencies node_modules/ +.pnpm-store/ # Environment variables .env diff --git a/README.md b/README.md index 4380585..f6b8eac 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,149 @@ IcePanel MCP Server is currently in beta. We appreciate your feedback and patien Please use MCP Servers with caution; only install tools you trust. -## 🚀 Getting Started +## Overview + +IcePanel MCP Server exposes IcePanel architecture data (C4 model objects, connections, technologies, tags, domains) to MCP clients so assistants can read and update your architecture inventory. + +## 🚀 Quick Start + +1. Get your IcePanel Organization ID from the IcePanel app. +2. Generate an API key (read permissions recommended unless you plan to write). +3. Configure your MCP client: + +```json +{ + "mcpServers": { + "@icepanel/icepanel": { + "command": "npx", + "args": ["-y", "@icepanel/mcp-server@latest", "API_KEY=\"your-api-key\"", "ORGANIZATION_ID=\"your-org-id\""] + } + } +} +``` + +## How to Configure Your MCP Client + +### stdio (default) + +Use `command` + `args` to launch the server locally (shown above). + +### Streamable HTTP + +For MCP clients that support HTTP transport: + +```json +{ + "mcpServers": { + "@icepanel/icepanel": { + "url": "http://localhost:9846/mcp" + } + } +} +``` + +## Reference: Tool Capabilities (v0.3.0) + +All tools follow the `icepanel_*` naming convention and return structured output in `structuredContent`. Read tools support: + +- `response_format`: `markdown` (default) or `json` +- Pagination (`limit`, `offset`) where applicable +- Pagination metadata: `total`, `count`, `has_more`, `next_offset` + +### Read Tools + +- `icepanel_list_landscapes` +- `icepanel_get_landscape` +- `icepanel_list_model_objects` +- `icepanel_get_model_object` +- `icepanel_get_model_object_connections` +- `icepanel_list_technologies` + +### Write Tools + +- `icepanel_create_model_object` +- `icepanel_update_model_object` +- `icepanel_delete_model_object` +- `icepanel_create_connection` +- `icepanel_update_connection` +- `icepanel_delete_connection` +- `icepanel_create_tag` +- `icepanel_update_tag` +- `icepanel_delete_tag` +- `icepanel_create_domain` +- `icepanel_update_domain` +- `icepanel_delete_domain` + +## Reference: Environment Variables + +- `API_KEY`: IcePanel API key (required) +- `ORGANIZATION_ID`: IcePanel organization ID (required) +- `ICEPANEL_API_BASE_URL`: Override API base URL (optional) +- `ICEPANEL_API_ALLOW_INSECURE`: Allow http base URLs for testing (optional, default: false) +- `ICEPANEL_API_TIMEOUT_MS`: API request timeout in ms (optional, default: 30000) +- `ICEPANEL_API_MAX_RETRIES`: Max retries for GET/HEAD requests (optional, default: 2) +- `ICEPANEL_API_RETRY_BASE_DELAY_MS`: Base backoff delay in ms (optional, default: 300) +- `MCP_TRANSPORT`: `stdio` (default) or `http` +- `MCP_PORT`: HTTP port for Streamable HTTP transport (default: 3000) + +## How to Run Integration Tests + +Use this guide to run the live integration tests against your IcePanel org. + +### Prerequisites + +- A valid IcePanel API key +- A landscape in your org (for example, `Alex's landscape`) + +### Steps + +1. Export your test credentials: + +```bash +export ICEPANEL_MCP_API_KEY="your-api-key" \ +ICEPANEL_MCP_ORGANIZATION_ID="your-org-id" +``` + +2. Point the tests at a specific landscape (by name or ID): + +```bash +export ICEPANEL_MCP_TEST_LANDSCAPE_NAME="your-landscape-name" +# or +export ICEPANEL_MCP_TEST_LANDSCAPE_ID="your-landscape-id" +``` + +3. For tag write tests, provide a tag group id: + +```bash +export ICEPANEL_MCP_TAG_GROUP_ID="your-tag-group-id" +``` + +4. Run the suite: + +```bash +pnpm test +``` + +### Notes + +- Read tests run when `ICEPANEL_MCP_API_KEY` is set. +- Write tests run automatically when the API key has write scope. +- Tag write tests require `ICEPANEL_MCP_TAG_GROUP_ID`. + +## Reference: Test Environment Variables + +- `ICEPANEL_MCP_API_KEY`: API key for integration tests (read or write) +- `ICEPANEL_MCP_ORGANIZATION_ID`: Organization ID for tests +- `ICEPANEL_MCP_TEST_LANDSCAPE_NAME`: Landscape name to target +- `ICEPANEL_MCP_TEST_LANDSCAPE_ID`: Landscape ID to target (overrides name) +- `ICEPANEL_MCP_TAG_GROUP_ID`: Tag group id used for tag write tests + +## Reference: CLI Flags + +- `--transport `: Transport type (overrides `MCP_TRANSPORT`) +- `--port `: HTTP port for HTTP transport (overrides `MCP_PORT`) + +## How to Run with Docker ### Prerequisites @@ -16,35 +158,22 @@ Please use MCP Servers with caution; only install tools you trust. - Cursor - Windsurf -### Installation +### Build the Docker Image -1. **Get your organization's ID** - - Visit [IcePanel](https://app.icepanel.io/) - - Head to your Organization's Settings: - - Click on your landscape in the top left to open the dropdown - - Beside your org name, click the gear icon - - Keep your "Organization Identifier" handy! - - -2. **Generate API Key** - - Visit [IcePanel](https://app.icepanel.io/) - - Head to your Organization's Settings: - - Click on your landscape in the top left to open the dropdown - - Beside your org name, click the gear icon - - Click on the 🔑 API keys link in the sidebar - - Generate a new API key - - Read permissions recommended - -3. **Install** - - Add the configuration to your MCP Client's MCP config file. (See below) +```bash +docker build -t icepanel-mcp-server . +``` -#### Environment Variables +### Run with Docker -- `API_KEY`: Your IcePanel API key (required) -- `ORGANIZATION_ID`: Your IcePanel organization ID (required) -- `ICEPANEL_API_BASE_URL`: (Optional) Override the API base URL for different environments +```bash +docker run -i --rm \ + -e API_KEY="your-api-key" \ + -e ORGANIZATION_ID="your-org-id" \ + icepanel-mcp-server +``` -#### Configure your MCP Client +### Configure MCP Client for Docker (stdio) Add this to your MCP Clients' MCP config file: @@ -52,13 +181,61 @@ Add this to your MCP Clients' MCP config file: { "mcpServers": { "@icepanel/icepanel": { - "command": "npx", - "args": ["-y", "@icepanel/mcp-server@latest", "API_KEY=\"your-api-key\"", "ORGANIZATION_ID=\"your-org-id\""] + "command": "docker", + "args": [ + "run", "-i", "--rm", + "-e", "API_KEY=your-api-key", + "-e", "ORGANIZATION_ID=your-org-id", + "icepanel-mcp-server" + ] } } } ``` +### Run with Streamable HTTP Transport + +For standalone HTTP server mode, use the `--transport http` flag: + +```bash +docker run -d -p 9846:9846 \ + -e API_KEY="your-api-key" \ + -e ORGANIZATION_ID="your-org-id" \ + icepanel-mcp-server --transport http --port 9846 +``` + +The server exposes: +- `GET/POST/DELETE /mcp` - Main MCP endpoint (Streamable HTTP) +- `GET /health` - Health check endpoint + +## Reference: Transport Options + +This server supports two transport mechanisms: + +### stdio (default) +- Standard input/output transport +- Used when MCP client spawns the server process directly +- Best for: Local development, npx usage, per-user deployments + +### Streamable HTTP +- Single endpoint HTTP transport (`/mcp`) +- Supports both request/response and streaming modes +- Best for: Docker deployments, shared servers, enterprise environments +- Replaces the deprecated SSE transport (MCP spec 2025-03-26) + +## v0.3.0 Breaking Changes + +Tool names have been updated to follow MCP best practices and use snake_case with an `icepanel_` prefix. Update any clients or prompts that refer to the old tool names: + +- `getLandscapes` → `icepanel_list_landscapes` +- `getLandscape` → `icepanel_get_landscape` +- `getModelObjects` → `icepanel_list_model_objects` +- `getModelObject` → `icepanel_get_model_object` +- `getModelObjectRelationships` → `icepanel_get_model_object_connections` +- `getTechnologyCatalog` → `icepanel_list_technologies` + +Read tools now accept `response_format` (`markdown` or `json`) plus `limit`/`offset` pagination parameters where applicable. + ## ✉️ Support - Reach out to [Support](mailto:support@icepanel.io) if you experience any issues. diff --git a/bin/icepanel-mcp-server.js b/bin/icepanel-mcp-server.js index 1386bc2..0739df1 100755 --- a/bin/icepanel-mcp-server.js +++ b/bin/icepanel-mcp-server.js @@ -18,7 +18,7 @@ process.argv.slice(2).forEach(arg => { } }); -import('../dist/main.js').catch(err => { +import('../dist/index.js').catch(err => { console.error('Failed to start IcePanel MCP Server:', err); process.exit(1); }); diff --git a/package.json b/package.json index ceaa19c..677261d 100644 --- a/package.json +++ b/package.json @@ -1,15 +1,16 @@ { "name": "@icepanel/mcp-server", - "version": "0.2.0", + "version": "0.3.0", "description": "IcePanel MCP Server for integrating with MCP clients", "type": "module", - "main": "dist/main.js", + "main": "dist/index.js", "bin": { "icepanel-mcp-server": "./bin/icepanel-mcp-server.js" }, "scripts": { "build": "tsc", - "dev": "tsx watch --env-file=.env src/main.ts", + "dev": "tsx watch --env-file=.env src/index.ts", + "test": "vitest run", "prepublishOnly": "npm run build", "publish": "npm publish --access public" }, @@ -32,7 +33,8 @@ "devDependencies": { "@types/node": "^22.0.0", "tsx": "^4.7.0", - "typescript": "^5.8.0" + "typescript": "^5.8.0", + "vitest": "^4.0.18" }, "engines": { "node": ">=18" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f5fc184..bd5c040 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -10,14 +10,26 @@ importers: dependencies: '@modelcontextprotocol/sdk': specifier: 1.24.0 - version: 1.24.0(zod@3.24.2) + version: 1.24.0(zod@3.25.76) + cors: + specifier: ^2.8.5 + version: 2.8.5 + express: + specifier: ^5.0.1 + version: 5.2.1 fuse.js: specifier: ^7.1.0 version: 7.1.0 zod: - specifier: ^3.22.4 - version: 3.24.2 + specifier: ^3.25.0 + version: 3.25.76 devDependencies: + '@types/cors': + specifier: ^2.8.17 + version: 2.8.19 + '@types/express': + specifier: ^5.0.0 + version: 5.0.6 '@types/node': specifier: ^22.0.0 version: 22.14.0 @@ -27,6 +39,9 @@ importers: typescript: specifier: ^5.8.0 version: 5.8.3 + vitest: + specifier: ^4.0.18 + version: 4.0.18(@types/node@22.14.0)(tsx@4.19.3) packages: @@ -36,150 +51,309 @@ packages: cpu: [ppc64] os: [aix] + '@esbuild/aix-ppc64@0.27.2': + resolution: {integrity: sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + '@esbuild/android-arm64@0.25.2': resolution: {integrity: sha512-5ZAX5xOmTligeBaeNEPnPaeEuah53Id2tX4c2CVP3JaROTH+j4fnfHCkr1PjXMd78hMst+TlkfKcW/DlTq0i4w==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm64@0.27.2': + resolution: {integrity: sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm@0.25.2': resolution: {integrity: sha512-NQhH7jFstVY5x8CKbcfa166GoV0EFkaPkCKBQkdPJFvo5u+nGXLEH/ooniLb3QI8Fk58YAx7nsPLozUWfCBOJA==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-arm@0.27.2': + resolution: {integrity: sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + '@esbuild/android-x64@0.25.2': resolution: {integrity: sha512-Ffcx+nnma8Sge4jzddPHCZVRvIfQ0kMsUsCMcJRHkGJ1cDmhe4SsrYIjLUKn1xpHZybmOqCWwB0zQvsjdEHtkg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/android-x64@0.27.2': + resolution: {integrity: sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + '@esbuild/darwin-arm64@0.25.2': resolution: {integrity: sha512-MpM6LUVTXAzOvN4KbjzU/q5smzryuoNjlriAIx+06RpecwCkL9JpenNzpKd2YMzLJFOdPqBpuub6eVRP5IgiSA==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-arm64@0.27.2': + resolution: {integrity: sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-x64@0.25.2': resolution: {integrity: sha512-5eRPrTX7wFyuWe8FqEFPG2cU0+butQQVNcT4sVipqjLYQjjh8a8+vUTfgBKM88ObB85ahsnTwF7PSIt6PG+QkA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/darwin-x64@0.27.2': + resolution: {integrity: sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + '@esbuild/freebsd-arm64@0.25.2': resolution: {integrity: sha512-mLwm4vXKiQ2UTSX4+ImyiPdiHjiZhIaE9QvC7sw0tZ6HoNMjYAqQpGyui5VRIi5sGd+uWq940gdCbY3VLvsO1w==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-arm64@0.27.2': + resolution: {integrity: sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.2': resolution: {integrity: sha512-6qyyn6TjayJSwGpm8J9QYYGQcRgc90nmfdUb0O7pp1s4lTY+9D0H9O02v5JqGApUyiHOtkz6+1hZNvNtEhbwRQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/freebsd-x64@0.27.2': + resolution: {integrity: sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + '@esbuild/linux-arm64@0.25.2': resolution: {integrity: sha512-gq/sjLsOyMT19I8obBISvhoYiZIAaGF8JpeXu1u8yPv8BE5HlWYobmlsfijFIZ9hIVGYkbdFhEqC0NvM4kNO0g==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm64@0.27.2': + resolution: {integrity: sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm@0.25.2': resolution: {integrity: sha512-UHBRgJcmjJv5oeQF8EpTRZs/1knq6loLxTsjc3nxO9eXAPDLcWW55flrMVc97qFPbmZP31ta1AZVUKQzKTzb0g==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-arm@0.27.2': + resolution: {integrity: sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + '@esbuild/linux-ia32@0.25.2': resolution: {integrity: sha512-bBYCv9obgW2cBP+2ZWfjYTU+f5cxRoGGQ5SeDbYdFCAZpYWrfjjfYwvUpP8MlKbP0nwZ5gyOU/0aUzZ5HWPuvQ==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-ia32@0.27.2': + resolution: {integrity: sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-loong64@0.25.2': resolution: {integrity: sha512-SHNGiKtvnU2dBlM5D8CXRFdd+6etgZ9dXfaPCeJtz+37PIUlixvlIhI23L5khKXs3DIzAn9V8v+qb1TRKrgT5w==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-loong64@0.27.2': + resolution: {integrity: sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-mips64el@0.25.2': resolution: {integrity: sha512-hDDRlzE6rPeoj+5fsADqdUZl1OzqDYow4TB4Y/3PlKBD0ph1e6uPHzIQcv2Z65u2K0kpeByIyAjCmjn1hJgG0Q==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-mips64el@0.27.2': + resolution: {integrity: sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-ppc64@0.25.2': resolution: {integrity: sha512-tsHu2RRSWzipmUi9UBDEzc0nLc4HtpZEI5Ba+Omms5456x5WaNuiG3u7xh5AO6sipnJ9r4cRWQB2tUjPyIkc6g==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-ppc64@0.27.2': + resolution: {integrity: sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-riscv64@0.25.2': resolution: {integrity: sha512-k4LtpgV7NJQOml/10uPU0s4SAXGnowi5qBSjaLWMojNCUICNu7TshqHLAEbkBdAszL5TabfvQ48kK84hyFzjnw==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-riscv64@0.27.2': + resolution: {integrity: sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-s390x@0.25.2': resolution: {integrity: sha512-GRa4IshOdvKY7M/rDpRR3gkiTNp34M0eLTaC1a08gNrh4u488aPhuZOCpkF6+2wl3zAN7L7XIpOFBhnaE3/Q8Q==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-s390x@0.27.2': + resolution: {integrity: sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-x64@0.25.2': resolution: {integrity: sha512-QInHERlqpTTZ4FRB0fROQWXcYRD64lAoiegezDunLpalZMjcUcld3YzZmVJ2H/Cp0wJRZ8Xtjtj0cEHhYc/uUg==} engines: {node: '>=18'} cpu: [x64] os: [linux] + '@esbuild/linux-x64@0.27.2': + resolution: {integrity: sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + '@esbuild/netbsd-arm64@0.25.2': resolution: {integrity: sha512-talAIBoY5M8vHc6EeI2WW9d/CkiO9MQJ0IOWX8hrLhxGbro/vBXJvaQXefW2cP0z0nQVTdQ/eNyGFV1GSKrxfw==} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-arm64@0.27.2': + resolution: {integrity: sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.2': resolution: {integrity: sha512-voZT9Z+tpOxrvfKFyfDYPc4DO4rk06qamv1a/fkuzHpiVBMOhpjK+vBmWM8J1eiB3OLSMFYNaOaBNLXGChf5tg==} engines: {node: '>=18'} cpu: [x64] os: [netbsd] + '@esbuild/netbsd-x64@0.27.2': + resolution: {integrity: sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + '@esbuild/openbsd-arm64@0.25.2': resolution: {integrity: sha512-dcXYOC6NXOqcykeDlwId9kB6OkPUxOEqU+rkrYVqJbK2hagWOMrsTGsMr8+rW02M+d5Op5NNlgMmjzecaRf7Tg==} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-arm64@0.27.2': + resolution: {integrity: sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.2': resolution: {integrity: sha512-t/TkWwahkH0Tsgoq1Ju7QfgGhArkGLkF1uYz8nQS/PPFlXbP5YgRpqQR3ARRiC2iXoLTWFxc6DJMSK10dVXluw==} engines: {node: '>=18'} cpu: [x64] os: [openbsd] + '@esbuild/openbsd-x64@0.27.2': + resolution: {integrity: sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/openharmony-arm64@0.27.2': + resolution: {integrity: sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openharmony] + '@esbuild/sunos-x64@0.25.2': resolution: {integrity: sha512-cfZH1co2+imVdWCjd+D1gf9NjkchVhhdpgb1q5y6Hcv9TP6Zi9ZG/beI3ig8TvwT9lH9dlxLq5MQBBgwuj4xvA==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/sunos-x64@0.27.2': + resolution: {integrity: sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + '@esbuild/win32-arm64@0.25.2': resolution: {integrity: sha512-7Loyjh+D/Nx/sOTzV8vfbB3GJuHdOQyrOryFdZvPHLf42Tk9ivBU5Aedi7iyX+x6rbn2Mh68T4qq1SDqJBQO5Q==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-arm64@0.27.2': + resolution: {integrity: sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-ia32@0.25.2': resolution: {integrity: sha512-WRJgsz9un0nqZJ4MfhabxaD9Ft8KioqU3JMinOTvobbX6MOSUigSBlogP8QB3uxpJDsFS6yN+3FDBdqE5lg9kg==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-ia32@0.27.2': + resolution: {integrity: sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-x64@0.25.2': resolution: {integrity: sha512-kM3HKb16VIXZyIeVrM1ygYmZBKybX8N4p754bw390wGO3Tf2j4L2/WYL+4suWujpgf6GBYs3jv7TyUivdd05JA==} engines: {node: '>=18'} cpu: [x64] os: [win32] + '@esbuild/win32-x64@0.27.2': + resolution: {integrity: sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + '@modelcontextprotocol/sdk@1.24.0': resolution: {integrity: sha512-D8h5KXY2vHFW8zTuxn2vuZGN0HGrQ5No6LkHwlEA9trVgNdPL3TF1dSqKA7Dny6BbBYKSW/rOBDXdC8KJAjUCg==} engines: {node: '>=18'} @@ -190,9 +364,205 @@ packages: '@cfworker/json-schema': optional: true + '@rollup/rollup-android-arm-eabi@4.57.0': + resolution: {integrity: sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.57.0': + resolution: {integrity: sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.57.0': + resolution: {integrity: sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.57.0': + resolution: {integrity: sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.57.0': + resolution: {integrity: sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.57.0': + resolution: {integrity: sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.57.0': + resolution: {integrity: sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm-musleabihf@4.57.0': + resolution: {integrity: sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA==} + cpu: [arm] + os: [linux] + + '@rollup/rollup-linux-arm64-gnu@4.57.0': + resolution: {integrity: sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-arm64-musl@4.57.0': + resolution: {integrity: sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw==} + cpu: [arm64] + os: [linux] + + '@rollup/rollup-linux-loong64-gnu@4.57.0': + resolution: {integrity: sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-loong64-musl@4.57.0': + resolution: {integrity: sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ==} + cpu: [loong64] + os: [linux] + + '@rollup/rollup-linux-ppc64-gnu@4.57.0': + resolution: {integrity: sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-ppc64-musl@4.57.0': + resolution: {integrity: sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA==} + cpu: [ppc64] + os: [linux] + + '@rollup/rollup-linux-riscv64-gnu@4.57.0': + resolution: {integrity: sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-riscv64-musl@4.57.0': + resolution: {integrity: sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ==} + cpu: [riscv64] + os: [linux] + + '@rollup/rollup-linux-s390x-gnu@4.57.0': + resolution: {integrity: sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg==} + cpu: [s390x] + os: [linux] + + '@rollup/rollup-linux-x64-gnu@4.57.0': + resolution: {integrity: sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-linux-x64-musl@4.57.0': + resolution: {integrity: sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw==} + cpu: [x64] + os: [linux] + + '@rollup/rollup-openbsd-x64@4.57.0': + resolution: {integrity: sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.57.0': + resolution: {integrity: sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.57.0': + resolution: {integrity: sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.57.0': + resolution: {integrity: sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.57.0': + resolution: {integrity: sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.57.0': + resolution: {integrity: sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ==} + cpu: [x64] + os: [win32] + + '@standard-schema/spec@1.1.0': + resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + + '@types/body-parser@1.19.6': + resolution: {integrity: sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==} + + '@types/chai@5.2.3': + resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} + + '@types/connect@3.4.38': + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + + '@types/cors@2.8.19': + resolution: {integrity: sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==} + + '@types/deep-eql@4.0.2': + resolution: {integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==} + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@types/express-serve-static-core@5.1.1': + resolution: {integrity: sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==} + + '@types/express@5.0.6': + resolution: {integrity: sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==} + + '@types/http-errors@2.0.5': + resolution: {integrity: sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==} + '@types/node@22.14.0': resolution: {integrity: sha512-Kmpl+z84ILoG+3T/zQFyAJsU6EPTmOCj8/2+83fSN6djd6I4o7uOuGIH6vq3PrjY5BGitSbFuMN18j3iknubbA==} + '@types/qs@6.14.0': + resolution: {integrity: sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==} + + '@types/range-parser@1.2.7': + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + + '@types/send@1.2.1': + resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==} + + '@types/serve-static@2.2.0': + resolution: {integrity: sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==} + + '@vitest/expect@4.0.18': + resolution: {integrity: sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==} + + '@vitest/mocker@4.0.18': + resolution: {integrity: sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==} + peerDependencies: + msw: ^2.4.9 + vite: ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + '@vitest/pretty-format@4.0.18': + resolution: {integrity: sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==} + + '@vitest/runner@4.0.18': + resolution: {integrity: sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==} + + '@vitest/snapshot@4.0.18': + resolution: {integrity: sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==} + + '@vitest/spy@4.0.18': + resolution: {integrity: sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==} + + '@vitest/utils@4.0.18': + resolution: {integrity: sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -208,6 +578,10 @@ packages: ajv@8.17.1: resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + body-parser@2.2.1: resolution: {integrity: sha512-nfDwkulwiZYQIGwxdy0RUmowMhKcFVcYXUU7m4QlKYim1rUtg83xm2yjZ40QjDuc291AJjjeSc9b++AWHSgSHw==} engines: {node: '>=18'} @@ -224,6 +598,10 @@ packages: resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} engines: {node: '>= 0.4'} + chai@6.2.2: + resolution: {integrity: sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==} + engines: {node: '>=18'} + content-disposition@1.0.1: resolution: {integrity: sha512-oIXISMynqSqm241k6kcQ5UwttDILMK4BiurCfGEREw6+X9jkkpEe5T9FZaApyLGGOnFuyMWZpdolTXMtvEJ08Q==} engines: {node: '>=18'} @@ -280,6 +658,9 @@ packages: resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} engines: {node: '>= 0.4'} + es-module-lexer@1.7.0: + resolution: {integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==} + es-object-atoms@1.1.1: resolution: {integrity: sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==} engines: {node: '>= 0.4'} @@ -289,9 +670,17 @@ packages: engines: {node: '>=18'} hasBin: true + esbuild@0.27.2: + resolution: {integrity: sha512-HyNQImnsOC7X9PMNaCIeAm4ISCQXs5a5YasTXVliKv4uuBo1dKrG0A+uQS8M5eXjVMnLg3WgXaKvprHlFJQffw==} + engines: {node: '>=18'} + hasBin: true + escape-html@1.0.3: resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + etag@1.8.1: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} @@ -304,6 +693,10 @@ packages: resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} engines: {node: '>=18.0.0'} + expect-type@1.3.0: + resolution: {integrity: sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==} + engines: {node: '>=12.0.0'} + express-rate-limit@7.5.1: resolution: {integrity: sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw==} engines: {node: '>= 16'} @@ -320,6 +713,15 @@ packages: fast-uri@3.1.0: resolution: {integrity: sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==} + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + finalhandler@2.1.1: resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} engines: {node: '>= 18.0.0'} @@ -394,6 +796,9 @@ packages: json-schema-traverse@1.0.0: resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -417,6 +822,11 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -429,6 +839,9 @@ packages: resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} engines: {node: '>= 0.4'} + obug@2.1.1: + resolution: {integrity: sha512-uTqF9MuPraAQ+IsnPf366RG4cP9RtUi7MLO1N3KEc+wb0a6yKpeL0lmk2IB1jY5KHPAlTc6T/JRdC/YqxHNwkQ==} + on-finished@2.4.1: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} @@ -447,10 +860,24 @@ packages: path-to-regexp@8.3.0: resolution: {integrity: sha512-7jdwVIRtsP8MYpdXSwOS0YdD0Du+qOoF/AEPIt88PcCFrZCzx41oxku1jD88hZBwbNUIEfpqvuhjFaMAqMTWnA==} + pathe@2.0.3: + resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.3: + resolution: {integrity: sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==} + engines: {node: '>=12'} + pkce-challenge@5.0.1: resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} engines: {node: '>=16.20.0'} + postcss@8.5.6: + resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} + engines: {node: ^10 || ^12 || >=14} + proxy-addr@2.0.7: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} @@ -474,6 +901,11 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + rollup@4.57.0: + resolution: {integrity: sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + router@2.2.0: resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} engines: {node: '>= 18'} @@ -516,10 +948,38 @@ packages: resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} engines: {node: '>= 0.4'} + siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + statuses@2.0.2: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + + tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + + tinyexec@1.0.2: + resolution: {integrity: sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg==} + engines: {node: '>=18'} + + tinyglobby@0.2.15: + resolution: {integrity: sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==} + engines: {node: '>=12.0.0'} + + tinyrainbow@3.0.3: + resolution: {integrity: sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==} + engines: {node: '>=14.0.0'} + toidentifier@1.0.1: resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} engines: {node: '>=0.6'} @@ -549,11 +1009,90 @@ packages: resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} engines: {node: '>= 0.8'} + vite@7.3.1: + resolution: {integrity: sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + jiti: '>=1.21.0' + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@4.0.18: + resolution: {integrity: sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==} + engines: {node: ^20.0.0 || ^22.0.0 || >=24.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@opentelemetry/api': ^1.9.0 + '@types/node': ^20.0.0 || ^22.0.0 || >=24.0.0 + '@vitest/browser-playwright': 4.0.18 + '@vitest/browser-preview': 4.0.18 + '@vitest/browser-webdriverio': 4.0.18 + '@vitest/ui': 4.0.18 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@opentelemetry/api': + optional: true + '@types/node': + optional: true + '@vitest/browser-playwright': + optional: true + '@vitest/browser-preview': + optional: true + '@vitest/browser-webdriverio': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} hasBin: true + why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -562,87 +1101,167 @@ packages: peerDependencies: zod: ^3.25 || ^4 - zod@3.24.2: - resolution: {integrity: sha512-lY7CDW43ECgW9u1TcT3IoXHflywfVqDYze4waEz812jR/bZ8FHDsl7pFQoSZTz5N+2NqRXs8GBwnAwo3ZNxqhQ==} + zod@3.25.76: + resolution: {integrity: sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==} snapshots: '@esbuild/aix-ppc64@0.25.2': optional: true + '@esbuild/aix-ppc64@0.27.2': + optional: true + '@esbuild/android-arm64@0.25.2': optional: true + '@esbuild/android-arm64@0.27.2': + optional: true + '@esbuild/android-arm@0.25.2': optional: true + '@esbuild/android-arm@0.27.2': + optional: true + '@esbuild/android-x64@0.25.2': optional: true + '@esbuild/android-x64@0.27.2': + optional: true + '@esbuild/darwin-arm64@0.25.2': optional: true + '@esbuild/darwin-arm64@0.27.2': + optional: true + '@esbuild/darwin-x64@0.25.2': optional: true + '@esbuild/darwin-x64@0.27.2': + optional: true + '@esbuild/freebsd-arm64@0.25.2': optional: true + '@esbuild/freebsd-arm64@0.27.2': + optional: true + '@esbuild/freebsd-x64@0.25.2': optional: true + '@esbuild/freebsd-x64@0.27.2': + optional: true + '@esbuild/linux-arm64@0.25.2': optional: true + '@esbuild/linux-arm64@0.27.2': + optional: true + '@esbuild/linux-arm@0.25.2': optional: true + '@esbuild/linux-arm@0.27.2': + optional: true + '@esbuild/linux-ia32@0.25.2': optional: true + '@esbuild/linux-ia32@0.27.2': + optional: true + '@esbuild/linux-loong64@0.25.2': optional: true + '@esbuild/linux-loong64@0.27.2': + optional: true + '@esbuild/linux-mips64el@0.25.2': optional: true + '@esbuild/linux-mips64el@0.27.2': + optional: true + '@esbuild/linux-ppc64@0.25.2': optional: true + '@esbuild/linux-ppc64@0.27.2': + optional: true + '@esbuild/linux-riscv64@0.25.2': optional: true + '@esbuild/linux-riscv64@0.27.2': + optional: true + '@esbuild/linux-s390x@0.25.2': optional: true + '@esbuild/linux-s390x@0.27.2': + optional: true + '@esbuild/linux-x64@0.25.2': optional: true + '@esbuild/linux-x64@0.27.2': + optional: true + '@esbuild/netbsd-arm64@0.25.2': optional: true + '@esbuild/netbsd-arm64@0.27.2': + optional: true + '@esbuild/netbsd-x64@0.25.2': optional: true + '@esbuild/netbsd-x64@0.27.2': + optional: true + '@esbuild/openbsd-arm64@0.25.2': optional: true + '@esbuild/openbsd-arm64@0.27.2': + optional: true + '@esbuild/openbsd-x64@0.25.2': optional: true + '@esbuild/openbsd-x64@0.27.2': + optional: true + + '@esbuild/openharmony-arm64@0.27.2': + optional: true + '@esbuild/sunos-x64@0.25.2': optional: true + '@esbuild/sunos-x64@0.27.2': + optional: true + '@esbuild/win32-arm64@0.25.2': optional: true + '@esbuild/win32-arm64@0.27.2': + optional: true + '@esbuild/win32-ia32@0.25.2': optional: true + '@esbuild/win32-ia32@0.27.2': + optional: true + '@esbuild/win32-x64@0.25.2': optional: true - '@modelcontextprotocol/sdk@1.24.0(zod@3.24.2)': + '@esbuild/win32-x64@0.27.2': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@modelcontextprotocol/sdk@1.24.0(zod@3.25.76)': dependencies: ajv: 8.17.1 ajv-formats: 3.0.1(ajv@8.17.1) @@ -656,15 +1275,181 @@ snapshots: jose: 6.1.3 pkce-challenge: 5.0.1 raw-body: 3.0.2 - zod: 3.24.2 - zod-to-json-schema: 3.25.1(zod@3.24.2) + zod: 3.25.76 + zod-to-json-schema: 3.25.1(zod@3.25.76) transitivePeerDependencies: - supports-color + '@rollup/rollup-android-arm-eabi@4.57.0': + optional: true + + '@rollup/rollup-android-arm64@4.57.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.57.0': + optional: true + + '@rollup/rollup-darwin-x64@4.57.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.57.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.57.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.57.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.57.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.57.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.57.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.57.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.57.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.57.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.57.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.57.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.57.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.57.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.57.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.57.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.57.0': + optional: true + + '@standard-schema/spec@1.1.0': {} + + '@types/body-parser@1.19.6': + dependencies: + '@types/connect': 3.4.38 + '@types/node': 22.14.0 + + '@types/chai@5.2.3': + dependencies: + '@types/deep-eql': 4.0.2 + assertion-error: 2.0.1 + + '@types/connect@3.4.38': + dependencies: + '@types/node': 22.14.0 + + '@types/cors@2.8.19': + dependencies: + '@types/node': 22.14.0 + + '@types/deep-eql@4.0.2': {} + + '@types/estree@1.0.8': {} + + '@types/express-serve-static-core@5.1.1': + dependencies: + '@types/node': 22.14.0 + '@types/qs': 6.14.0 + '@types/range-parser': 1.2.7 + '@types/send': 1.2.1 + + '@types/express@5.0.6': + dependencies: + '@types/body-parser': 1.19.6 + '@types/express-serve-static-core': 5.1.1 + '@types/serve-static': 2.2.0 + + '@types/http-errors@2.0.5': {} + '@types/node@22.14.0': dependencies: undici-types: 6.21.0 + '@types/qs@6.14.0': {} + + '@types/range-parser@1.2.7': {} + + '@types/send@1.2.1': + dependencies: + '@types/node': 22.14.0 + + '@types/serve-static@2.2.0': + dependencies: + '@types/http-errors': 2.0.5 + '@types/node': 22.14.0 + + '@vitest/expect@4.0.18': + dependencies: + '@standard-schema/spec': 1.1.0 + '@types/chai': 5.2.3 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + chai: 6.2.2 + tinyrainbow: 3.0.3 + + '@vitest/mocker@4.0.18(vite@7.3.1(@types/node@22.14.0)(tsx@4.19.3))': + dependencies: + '@vitest/spy': 4.0.18 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.1(@types/node@22.14.0)(tsx@4.19.3) + + '@vitest/pretty-format@4.0.18': + dependencies: + tinyrainbow: 3.0.3 + + '@vitest/runner@4.0.18': + dependencies: + '@vitest/utils': 4.0.18 + pathe: 2.0.3 + + '@vitest/snapshot@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + magic-string: 0.30.21 + pathe: 2.0.3 + + '@vitest/spy@4.0.18': {} + + '@vitest/utils@4.0.18': + dependencies: + '@vitest/pretty-format': 4.0.18 + tinyrainbow: 3.0.3 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -681,6 +1466,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + assertion-error@2.0.1: {} + body-parser@2.2.1: dependencies: bytes: 3.1.2 @@ -707,6 +1494,8 @@ snapshots: call-bind-apply-helpers: 1.0.2 get-intrinsic: 1.3.0 + chai@6.2.2: {} + content-disposition@1.0.1: {} content-type@1.0.5: {} @@ -746,6 +1535,8 @@ snapshots: es-errors@1.3.0: {} + es-module-lexer@1.7.0: {} + es-object-atoms@1.1.1: dependencies: es-errors: 1.3.0 @@ -778,8 +1569,41 @@ snapshots: '@esbuild/win32-ia32': 0.25.2 '@esbuild/win32-x64': 0.25.2 + esbuild@0.27.2: + optionalDependencies: + '@esbuild/aix-ppc64': 0.27.2 + '@esbuild/android-arm': 0.27.2 + '@esbuild/android-arm64': 0.27.2 + '@esbuild/android-x64': 0.27.2 + '@esbuild/darwin-arm64': 0.27.2 + '@esbuild/darwin-x64': 0.27.2 + '@esbuild/freebsd-arm64': 0.27.2 + '@esbuild/freebsd-x64': 0.27.2 + '@esbuild/linux-arm': 0.27.2 + '@esbuild/linux-arm64': 0.27.2 + '@esbuild/linux-ia32': 0.27.2 + '@esbuild/linux-loong64': 0.27.2 + '@esbuild/linux-mips64el': 0.27.2 + '@esbuild/linux-ppc64': 0.27.2 + '@esbuild/linux-riscv64': 0.27.2 + '@esbuild/linux-s390x': 0.27.2 + '@esbuild/linux-x64': 0.27.2 + '@esbuild/netbsd-arm64': 0.27.2 + '@esbuild/netbsd-x64': 0.27.2 + '@esbuild/openbsd-arm64': 0.27.2 + '@esbuild/openbsd-x64': 0.27.2 + '@esbuild/openharmony-arm64': 0.27.2 + '@esbuild/sunos-x64': 0.27.2 + '@esbuild/win32-arm64': 0.27.2 + '@esbuild/win32-ia32': 0.27.2 + '@esbuild/win32-x64': 0.27.2 + escape-html@1.0.3: {} + estree-walker@3.0.3: + dependencies: + '@types/estree': 1.0.8 + etag@1.8.1: {} eventsource-parser@3.0.6: {} @@ -788,6 +1612,8 @@ snapshots: dependencies: eventsource-parser: 3.0.6 + expect-type@1.3.0: {} + express-rate-limit@7.5.1(express@5.2.1): dependencies: express: 5.2.1 @@ -829,6 +1655,10 @@ snapshots: fast-uri@3.1.0: {} + fdir@6.5.0(picomatch@4.0.3): + optionalDependencies: + picomatch: 4.0.3 + finalhandler@2.1.1: dependencies: debug: 4.4.3 @@ -905,6 +1735,10 @@ snapshots: json-schema-traverse@1.0.0: {} + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + math-intrinsics@1.1.0: {} media-typer@1.1.0: {} @@ -919,12 +1753,16 @@ snapshots: ms@2.1.3: {} + nanoid@3.3.11: {} + negotiator@1.0.0: {} object-assign@4.1.1: {} object-inspect@1.13.4: {} + obug@2.1.1: {} + on-finished@2.4.1: dependencies: ee-first: 1.1.1 @@ -939,8 +1777,20 @@ snapshots: path-to-regexp@8.3.0: {} + pathe@2.0.3: {} + + picocolors@1.1.1: {} + + picomatch@4.0.3: {} + pkce-challenge@5.0.1: {} + postcss@8.5.6: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + proxy-addr@2.0.7: dependencies: forwarded: 0.2.0 @@ -963,6 +1813,37 @@ snapshots: resolve-pkg-maps@1.0.0: {} + rollup@4.57.0: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.57.0 + '@rollup/rollup-android-arm64': 4.57.0 + '@rollup/rollup-darwin-arm64': 4.57.0 + '@rollup/rollup-darwin-x64': 4.57.0 + '@rollup/rollup-freebsd-arm64': 4.57.0 + '@rollup/rollup-freebsd-x64': 4.57.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.57.0 + '@rollup/rollup-linux-arm-musleabihf': 4.57.0 + '@rollup/rollup-linux-arm64-gnu': 4.57.0 + '@rollup/rollup-linux-arm64-musl': 4.57.0 + '@rollup/rollup-linux-loong64-gnu': 4.57.0 + '@rollup/rollup-linux-loong64-musl': 4.57.0 + '@rollup/rollup-linux-ppc64-gnu': 4.57.0 + '@rollup/rollup-linux-ppc64-musl': 4.57.0 + '@rollup/rollup-linux-riscv64-gnu': 4.57.0 + '@rollup/rollup-linux-riscv64-musl': 4.57.0 + '@rollup/rollup-linux-s390x-gnu': 4.57.0 + '@rollup/rollup-linux-x64-gnu': 4.57.0 + '@rollup/rollup-linux-x64-musl': 4.57.0 + '@rollup/rollup-openbsd-x64': 4.57.0 + '@rollup/rollup-openharmony-arm64': 4.57.0 + '@rollup/rollup-win32-arm64-msvc': 4.57.0 + '@rollup/rollup-win32-ia32-msvc': 4.57.0 + '@rollup/rollup-win32-x64-gnu': 4.57.0 + '@rollup/rollup-win32-x64-msvc': 4.57.0 + fsevents: 2.3.3 + router@2.2.0: dependencies: debug: 4.4.3 @@ -1036,8 +1917,27 @@ snapshots: side-channel-map: 1.0.1 side-channel-weakmap: 1.0.2 + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + statuses@2.0.2: {} + std-env@3.10.0: {} + + tinybench@2.9.0: {} + + tinyexec@1.0.2: {} + + tinyglobby@0.2.15: + dependencies: + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + + tinyrainbow@3.0.3: {} + toidentifier@1.0.1: {} tsx@4.19.3: @@ -1061,14 +1961,69 @@ snapshots: vary@1.1.2: {} + vite@7.3.1(@types/node@22.14.0)(tsx@4.19.3): + dependencies: + esbuild: 0.27.2 + fdir: 6.5.0(picomatch@4.0.3) + picomatch: 4.0.3 + postcss: 8.5.6 + rollup: 4.57.0 + tinyglobby: 0.2.15 + optionalDependencies: + '@types/node': 22.14.0 + fsevents: 2.3.3 + tsx: 4.19.3 + + vitest@4.0.18(@types/node@22.14.0)(tsx@4.19.3): + dependencies: + '@vitest/expect': 4.0.18 + '@vitest/mocker': 4.0.18(vite@7.3.1(@types/node@22.14.0)(tsx@4.19.3)) + '@vitest/pretty-format': 4.0.18 + '@vitest/runner': 4.0.18 + '@vitest/snapshot': 4.0.18 + '@vitest/spy': 4.0.18 + '@vitest/utils': 4.0.18 + es-module-lexer: 1.7.0 + expect-type: 1.3.0 + magic-string: 0.30.21 + obug: 2.1.1 + pathe: 2.0.3 + picomatch: 4.0.3 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 1.0.2 + tinyglobby: 0.2.15 + tinyrainbow: 3.0.3 + vite: 7.3.1(@types/node@22.14.0)(tsx@4.19.3) + why-is-node-running: 2.3.0 + optionalDependencies: + '@types/node': 22.14.0 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - terser + - tsx + - yaml + which@2.0.2: dependencies: isexe: 2.0.0 + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + wrappy@1.0.2: {} - zod-to-json-schema@3.25.1(zod@3.24.2): + zod-to-json-schema@3.25.1(zod@3.25.76): dependencies: - zod: 3.24.2 + zod: 3.25.76 - zod@3.24.2: {} + zod@3.25.76: {} diff --git a/src/constants.ts b/src/constants.ts new file mode 100644 index 0000000..e9d2fb2 --- /dev/null +++ b/src/constants.ts @@ -0,0 +1,4 @@ +export const CHARACTER_LIMIT = 25000; +export const DEFAULT_PAGE_SIZE = 50; +export const MAX_PAGE_SIZE = 100; +export const MODEL_OBJECT_DESCRIPTION_MAX_LENGTH = 150; diff --git a/src/icepanel.ts b/src/icepanel.ts deleted file mode 100644 index e87268e..0000000 --- a/src/icepanel.ts +++ /dev/null @@ -1,311 +0,0 @@ -/** - * IcePanel API client - */ - -import type { ModelObjectsResponse, ModelObjectResponse, CatalogTechnologyResponse, TeamsResponse, ModelConnectionsResponse } from "./types.js"; - -// Base URL for the IcePanel API -// Use environment variable if set, otherwise default to production URL -const API_BASE_URL = process.env.ICEPANEL_API_BASE_URL || "https://api.icepanel.io/v1"; - -// Get the API key from environment variables -const API_KEY = process.env.API_KEY; - -// Note: We don't check for API_KEY here as main.ts handles this - -/** - * Make an authenticated request to the IcePanel API - */ -async function apiRequest(path: string, options: RequestInit = {}) { - const url = `${API_BASE_URL}${path}`; - - const headers = { - "Accept": "application/json", - "Content-Type": "application/json", - "Authorization": `ApiKey ${API_KEY}`, - ...options.headers, - }; - - const response = await fetch(url, { - ...options, - headers, - }); - - if (!response.ok) { - throw new Error(`IcePanel API error: ${response.status} ${response.statusText}`); - } - - return response.json(); -} - -/** - * Get all landscapes - */ -export async function getLandscapes(organizationId: string) { - return apiRequest(`/organizations/${organizationId}/landscapes`); -} - -/** - * Get a specific landscape - */ -export async function getLandscape(organizationId: string, landscapeId: string) { - return apiRequest(`/organizations/${organizationId}/landscapes/${landscapeId}`); -} - -/** - * Get a specific version - */ -export async function getVersion(landscapeId: string, versionId: string = "latest") { - return apiRequest(`/landscapes/${landscapeId}/versions/${versionId}`); -} - -/** - * Get catalog technologies - * - * Retrieves a list of technologies from the IcePanel catalog - * - * @param options - Filter options for the catalog technologies - * @param options.filter.provider - Filter by provider (aws, azure, gcp, etc.) - * @param options.filter.type - Filter by technology type (data-storage, deployment, etc.) - * @param options.filter.restrictions - Filter by restrictions (actor, app, component, etc.) - * @param options.filter.status - Filter by status (approved, pending-review, rejected) - * @returns Promise with catalog technologies response - */ -export async function getCatalogTechnologies( - options: { - filter?: { - provider?: string | string[] | null, - type?: string | string[] | null, - restrictions?: string | string[], - status?: string | string[] - } - } = {} -) { - const params = new URLSearchParams(); - - if (options.filter) { - const filter = options.filter; - - // Convert filter object to query parameters - Object.entries(filter).forEach(([key, value]) => { - if (value !== undefined) { - if (Array.isArray(value)) { - // Handle array values - value.forEach(item => { - params.append(`filter[${key}][]`, item); - }); - } else if (value === null) { - // Handle null values - params.append(`filter[${key}]`, 'null'); - } else { - // Handle simple values - params.append(`filter[${key}]`, String(value)); - } - } - }); - } - - const queryString = params.toString(); - const url = `/catalog/technologies${queryString ? `?${queryString}` : ''}`; - - return apiRequest(url) as Promise; -} - -/** - * Get organization technologies - * - * Retrieves a list of technologies from an organization - * - * @param organizationId - The ID of the organization - * @param options - Filter options for the organization technologies - * @param options.filter.provider - Filter by provider (aws, azure, gcp, etc.) - * @param options.filter.type - Filter by technology type (data-storage, deployment, etc.) - * @param options.filter.restrictions - Filter by restrictions (actor, app, component, etc.) - * @param options.filter.status - Filter by status (approved, pending-review, rejected) - * @returns Promise with catalog technologies response - */ -export async function getOrganizationTechnologies( - organizationId: string, - options: { - filter?: { - provider?: string | string[] | null, - type?: string | string[] | null, - restrictions?: string | string[], - status?: string | string[] - } - } = {} -) { - const params = new URLSearchParams(); - - if (options.filter) { - const filter = options.filter; - - // Convert filter object to query parameters - Object.entries(filter).forEach(([key, value]) => { - if (value !== undefined) { - if (Array.isArray(value)) { - // Handle array values - value.forEach(item => { - params.append(`filter[${key}][]`, item); - }); - } else if (value === null) { - // Handle null values - params.append(`filter[${key}]`, 'null'); - } else { - // Handle simple values - params.append(`filter[${key}]`, String(value)); - } - } - }); - } - - const queryString = params.toString(); - const url = `/organizations/${organizationId}/technologies${queryString ? `?${queryString}` : ''}`; - - return apiRequest(url) as Promise; -} - -/** - * Get teams for an organization - * - * Retrieves a list of teams from an organization - * - * @param organizationId - The ID of the organization - * @returns Promise with teams response - */ -export async function getTeams(organizationId: string) { - return apiRequest(`/organizations/${organizationId}/teams`) as Promise; -} - -/** - * Get all model objects for a landscape version - */ -export async function getModelObjects( - landscapeId: string, - versionId: string = "latest", - options: { filter?: { - domainId?: string | string[], - external?: boolean, - handleId?: string | string[], - labels?: Record, - name?: string, - parentId?: string | null, - status?: string | string[], - type?: string | string[] - }} = {} -): Promise { - const params = new URLSearchParams(); - - if (options.filter) { - const filter = options.filter; - - // Convert filter object to query parameters - Object.entries(filter).forEach(([key, value]) => { - if (value !== undefined) { - if (key === 'labels' && typeof value === 'object') { - // Handle labels object - Object.entries(value as Record).forEach(([labelKey, labelValue]) => { - params.append(`filter[labels][${labelKey}]`, labelValue); - }); - } else if (Array.isArray(value)) { - // Handle array values - value.forEach(item => { - params.append(`filter[${key}][]`, item); - }); - } else if (value === null) { - // Handle null values - params.append(`filter[${key}]`, 'null'); - } else { - // Handle simple values - params.append(`filter[${key}]`, String(value)); - } - } - }); - } - - const queryString = params.toString(); - const url = `/landscapes/${landscapeId}/versions/${versionId}/model/objects${queryString ? `?${queryString}` : ''}`; - - return apiRequest(url) as Promise; -} - -/** - * Get a specific model object - */ -export async function getModelObject(landscapeId: string, modelObjectId: string, versionId: string = "latest") { - return apiRequest(`/landscapes/${landscapeId}/versions/${versionId}/model/objects/${modelObjectId}`) as Promise; -} - -/** - * Get all model connections - * - * Retrieves a list of connections between model objects - * - * @param landscapeId - The ID of the landscape - * @param versionId - The ID of the version (defaults to "latest") - * @param options - Filter options for the model connections - * @param options.filter.direction - Filter by connection direction (outgoing, bidirectional) - * @param options.filter.handleId - Filter by handle ID - * @param options.filter.labels - Filter by labels - * @param options.filter.name - Filter by name - * @param options.filter.originId - Filter by origin ID - * @param options.filter.status - Filter by status (deprecated, future, live, removed) - * @param options.filter.targetId - Filter by target ID - * @returns Promise with model connections response - */ -export async function getModelConnections( - landscapeId: string, - versionId: string = "latest", - options: { - filter?: { - direction?: 'outgoing' | 'bidirectional' | null, - handleId?: string | string[], - labels?: Record, - name?: string, - originId?: string | string[], - status?: ('deprecated' | 'future' | 'live' | 'removed') | ('deprecated' | 'future' | 'live' | 'removed')[], - targetId?: string | string[] - } - } = {} -): Promise { - const params = new URLSearchParams(); - - if (options.filter) { - const filter = options.filter; - - // Convert filter object to query parameters - Object.entries(filter).forEach(([key, value]) => { - if (value !== undefined) { - if (key === 'labels' && typeof value === 'object') { - // Handle labels object - Object.entries(value as Record).forEach(([labelKey, labelValue]) => { - params.append(`filter[labels][${labelKey}]`, labelValue); - }); - } else if (Array.isArray(value)) { - // Handle array values - value.forEach(item => { - params.append(`filter[${key}][]`, item); - }); - } else if (value === null) { - // Handle null values - params.append(`filter[${key}]`, 'null'); - } else { - // Handle simple values - params.append(`filter[${key}]`, String(value)); - } - } - }); - } - - const queryString = params.toString(); - const url = `/landscapes/${landscapeId}/versions/${versionId}/model/connections${queryString ? `?${queryString}` : ''}`; - - return apiRequest(url) as Promise; -} - -/** - * Get a specific connection - */ -export async function getConnection(landscapeId: string, versionId: string, connectionId: string) { - return apiRequest(`/landscapes/${landscapeId}/versions/${versionId}/model/connections/${connectionId}`); -} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..3ad7cf8 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,51 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { registerAllTools } from "./tools/index.js"; +import { startHttpServer } from "./transports/http-server.js"; + +// Get API key and organization ID from environment variables +const API_KEY = process.env.API_KEY; +const ORGANIZATION_ID = process.env.ORGANIZATION_ID; + +if (!API_KEY) { + console.error("API_KEY environment variable is not set"); + process.exit(1); +} + +if (!ORGANIZATION_ID) { + console.error("ORGANIZATION_ID environment variable is not set"); + process.exit(1); +} + +// Create an MCP server +const server = new McpServer({ + name: "icepanel-mcp-server", + version: "0.3.0", +}); + +registerAllTools(server, ORGANIZATION_ID); + +// Get transport configuration from CLI (set by bin/icepanel-mcp-server.js) +const transportType = process.env._MCP_TRANSPORT || "stdio"; +const portRaw = process.env._MCP_PORT || "3000"; +const port = Number.parseInt(portRaw, 10); + +if (!Number.isInteger(port) || port < 1 || port > 65535) { + console.error(`Invalid port: ${portRaw}. Must be a number between 1 and 65535.`); + process.exit(1); +} + +// Start the server with the appropriate transport +try { + if (transportType === "http") { + // Start HTTP server with Streamable HTTP transport + await startHttpServer(server, port); + } else { + // Default: Start with stdio transport + const transport = new StdioServerTransport(); + await server.connect(transport); + } +} catch (error: any) { + console.error("Failed to start IcePanel MCP Server:", error?.message || error); + process.exit(1); +} diff --git a/src/main.ts b/src/main.ts deleted file mode 100644 index 7051367..0000000 --- a/src/main.ts +++ /dev/null @@ -1,304 +0,0 @@ -import { - McpServer, -} from "@modelcontextprotocol/sdk/server/mcp.js"; -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; -import { z } from "zod"; -import * as icepanel from "./icepanel.js"; -import { formatCatalogTechnology, formatConnections, formatModelObjectItem, formatModelObjectListItem, formatTeam } from "./format.js"; -import Fuse from 'fuse.js'; - -// Get API key and organization ID from environment variables -const API_KEY = process.env.API_KEY; -const ORGANIZATION_ID = process.env.ORGANIZATION_ID; - -if (!API_KEY) { - console.error("API_KEY environment variable is not set"); - process.exit(1); -} - -if (!ORGANIZATION_ID) { - console.error("ORGANIZATION_ID environment variable is not set"); - process.exit(1); -} - -// Create an MCP server -const server = new McpServer({ - name: "IcePanel MCP Server", - version: "0.1.1", -}); - -// Get all landscapes -server.tool( - "getLandscapes", - "Get all your landscapes from IcePanel", - {}, - async () => { - try { - const landscapes = await icepanel.getLandscapes(ORGANIZATION_ID!); - return { - content: [{ type: "text", text: JSON.stringify(landscapes, null, 2) }], - }; - } catch (error: any) { - return { - content: [{ type: "text", text: `Error: ${error.message}` }], - }; - } - } -); - -// Get a specific landscape -server.tool( - "getLandscape", - "Get a specific landscape from IcePanel", - { - landscapeId: z.string(), - }, - async ({ landscapeId }) => { - try { - const landscape = await icepanel.getLandscape(ORGANIZATION_ID!, landscapeId); - return { - content: [{ type: "text", text: JSON.stringify(landscape, null, 2) }], - }; - } catch (error: any) { - return { - content: [{ type: "text", text: `Error: ${error.message}` }], - }; - } - } -); - -// Get model objects for a landscape version -server.tool( - "getModelObjects", -` -Get all the model objects in an IcePanel landscape. -IcePanel is a C4 diagramming tool. C4 is a framework for visualizing the architecture of software systems. -To get the C1 level objects - query for 'system' type. -To get the C2 level objects - query for 'app' and 'store' component types. -To get the C3 level objects - query for the 'component' type. - -The 'group' and 'actor' types can be used in any of the levels, and should generally by included in user queries. -- 'group' - is a type agnostic group which groups objects together -- 'actor' - is a actor in the system, typically a kind of user. Ex. 'our customer', 'admin user', etc. - -Use this tool to filter / query against many model objects at once. It provides high level details such as; name, ID, type, status, and external. - -Prefer filtering by Technology ID and Team ID when the query is asking things like: -- "What services does the Automations Team own?" -- "We need to upgrade our .NET applications - what is affected by this?" -`, - { - landscapeId: z.string().length(20), - domainId: z.union([z.string().length(20), z.array(z.string().length(20))]).optional(), - external: z.boolean().optional().default(false), - name: z.string().optional(), - parentId: z.string().nullable().optional(), - status: z.union([ - z.enum(["deprecated", "future", "live", "removed"]), - z.array(z.enum(["deprecated", "future", "live", "removed"])) - ]).optional(), - type: z.union([ - z.enum(["actor", "app", "component", "group", "root", "store", "system"]), - z.array(z.enum(["actor", "app", "component", "group", "root", "store", "system"])) - ]).optional(), - technologyId: z.union([z.string().length(20), z.array(z.string().length(20))]).optional().describe("The technology UUID - useful to find all objects using a specific technology or technologies"), - teamId: z.union([z.string().length(20), z.array(z.string().length(20))]).optional().describe("The team UUID - useful to find all objects owned by a specific team or teams"), - search: z.string().optional().describe("Search by name") - }, - async ({ landscapeId, ...filters }) => { - try { - const result = await icepanel.getModelObjects(landscapeId, "latest", { filter: filters }); - let modelObjects = result.modelObjects; - if (filters.search) { - const fuseInstance = new Fuse(modelObjects, { - keys: ['name', 'description'], - threshold: 0.3 - }) - modelObjects = fuseInstance.search(filters.search).map(result => result.item); - } - const content: any[] = modelObjects.map((o) => ({ - type: "text", - text: formatModelObjectListItem(landscapeId, o) - })) - return { - content, - }; - } catch (error: any) { - return { - content: [{ type: "text", text: `Error: ${error.message}` }], - }; - } - } -); - -server.tool( - 'getModelObject', - ` - Get detailed information about a model object in IcePanel. - IcePanel is a C4 diagramming tool. C4 is a framework for visualizing the architecture of software systems. - Use this tool to get detailed information about a model object, such as it's description, type, hierarchical information (i.e. parent and children objects), any teams associated with it, as well as the technologies it uses. - `, - { - landscapeId: z.string().length(20), - modelObjectId: z.string().length(20), - includeHierarchicalInfo: z.boolean().default(false).describe('Include hierarchical information like parent and child objects. (Only use this when necessary as it is an expensive operation.)') - }, - async ({ landscapeId, modelObjectId, includeHierarchicalInfo }) => { - try { - const result = await icepanel.getModelObject(landscapeId, modelObjectId); - const teamResult = await icepanel.getTeams(ORGANIZATION_ID!); - const modelObject = result.modelObject - let parentObject; - let childObjects; - - if (includeHierarchicalInfo) { - const listResult = await icepanel.getModelObjects(landscapeId) - const modelObjectList = listResult.modelObjects; - parentObject = (modelObject.parentId && modelObject.parentId !== 'root') ? modelObjectList.find(o => o.id === modelObject.parentId) : undefined; - childObjects = modelObject.childIds.length > 0 ? modelObjectList.filter(o => modelObject.childIds.includes(o.id)): undefined; - } - const content: any = { - type: 'text', - text: formatModelObjectItem(landscapeId, result.modelObject, teamResult.teams, parentObject, childObjects), - } - return { - content: [content], - }; - } catch (error: any) { - return { - content: [{ type: "text", text: `Error: ${error.message}` }], - }; - } - } -) - -server.tool( - 'getModelObjectRelationships', - ` - Get information about the relationships a model object has in IcePanel. - IcePanel is a C4 diagramming tool. C4 is a framework for visualizing the architecture of software systems. - - Use this tool when you want to know about what objects are related to the current object. It provides a succinct list of related items. - `, - { - landscapeId: z.string().length(20), - modelObjectId: z.string().length(20), - }, - async({ landscapeId, modelObjectId }) => { - try { - const modelObjectResult = await icepanel.getModelObject(landscapeId, modelObjectId) - const modelObjectsResult = await icepanel.getModelObjects(landscapeId) - const outgoingConnectionsResult = await icepanel.getModelConnections(landscapeId, "latest", { - filter: { - originId: modelObjectId - } - }) - const incomingConnectionsResult = await icepanel.getModelConnections(landscapeId, "latest", { - filter: { - targetId: modelObjectId, - } - }) - const formattedText = formatConnections( - modelObjectResult.modelObject, - incomingConnectionsResult.modelConnections, - outgoingConnectionsResult.modelConnections, - modelObjectsResult.modelObjects, - ) - - return { - content: [{ - type: 'text', - text: formattedText - }] - } - } catch (error: any) { - return { - content: [{ type: "text", text: `Error: ${error.message}` }], - }; - } - } -) - -server.tool( - 'getTechnologyCatalog', - ` - Get the technology catalog in IcePanel. - IcePanel is a C4 diagramming tool. C4 is a framework for visualizing the architecture of software systems. - Use this tool to get the technology catalog, which is a list of all the technologies available in the system. - `, - { - provider: z.union([z.enum(["aws", "azure", "gcp", "microsoft", "salesforce", "atlassian", "apache", "supabase"]), z.array(z.enum(["aws", "azure", "gcp", "microsoft", "salesforce", "atlassian", "apache", "supabase"]))]).nullable().optional(), - type: z.union([z.enum(["data-storage", "deployment", "framework-library", "gateway", "other", "language", "message-broker", "network", "protocol", "runtime", "service-tool"]), z.array(z.enum(["data-storage", "deployment", "framework-library", "gateway", "other", "language", "message-broker", "network", "protocol", "runtime", "service-tool"]))]).nullable().optional(), - restrictions: z.union([z.enum(["actor", "app", "component", "connection", "group", "store", "system"]), z.array(z.enum(["actor", "app", "component", "connection", "group", "store", "system"]))]).optional(), - search: z.string().describe('Search by name and description') - }, - async ({ provider, type, restrictions, search }) => { - try { - const result = await icepanel.getCatalogTechnologies({ filter: { provider, type, restrictions, status: "approved" } }); - const organizationResult = await icepanel.getOrganizationTechnologies(ORGANIZATION_ID!, { filter: { provider, type, restrictions } }); - let combinedTechnologies = result.catalogTechnologies.concat(organizationResult.catalogTechnologies); - - if (search) { - const fuse = new Fuse(combinedTechnologies, { - keys: ['name', 'description'], - threshold: 0.3, - }); - combinedTechnologies = fuse.search(search).map(result => result.item); - } - - const content: any = combinedTechnologies.map(t => ({ - type: 'text', - text: formatCatalogTechnology(t) - })); - return { - content, - }; - } catch (error: any) { - return { - content: [{ type: "text", text: `Error: ${error.message}` }], - }; - } - } -) - -server.tool( - 'getTeams', - ` - Get the teams in IcePanel. - IcePanel is a C4 diagramming tool. C4 is a framework for visualizing the architecture of software systems. - Use this tool to get the teams in IcePanel, teams are assigned as owners to different Model Objects within IcePanel. - `, - { - search: z.string().optional().describe('Search by name') - }, - async ({ search }) => { - try { - const teamResult = await icepanel.getTeams(ORGANIZATION_ID!) - let teams = teamResult.teams - if (search) { - const fuse = new Fuse(teams, { - keys: ['name'], - threshold: 0.3, - }); - teams = fuse.search(search).map(result => result.item); - } - - const teamContent: any[] = teams.map(team => ({ - type: 'text', - text: formatTeam(team) - })) - return { - content: teamContent - } - } catch (error: any) { - return { - content: [{ type: 'text', text: `Error: ${error.message}`}] - } - } - } - -) - -// Start receiving messages on stdin and sending messages on stdout -const transport = new StdioServerTransport(); -await server.connect(transport); diff --git a/src/schemas/index.ts b/src/schemas/index.ts new file mode 100644 index 0000000..90ee99c --- /dev/null +++ b/src/schemas/index.ts @@ -0,0 +1,78 @@ +import { z } from "zod"; + +export const ResponseFormatSchema = z.enum(["markdown", "json"]).default("markdown"); + +export const IcePanelIdSchema = z.string().length(20); + +export const PaginationSchema = z.object({ + limit: z.number().int().min(1).max(100).default(50), + offset: z.number().int().min(0).default(0), +}).strict(); + +export const StatusSchema = z.enum(["deprecated", "future", "live", "removed"]); + +export const ModelObjectTypeSchema = z.enum([ + "actor", + "app", + "component", + "group", + "root", + "store", + "system", +]); + +export const ConnectionDirectionSchema = z.enum(["outgoing", "bidirectional"]).nullable(); + +export const ColorNameSchema = z.enum([ + "blue", + "green", + "yellow", + "orange", + "red", + "beaver", + "dark-blue", + "purple", + "pink", + "white", + "grey", + "black", +]); + +export const ColorSchema = z.union([ColorNameSchema, z.string().regex(/^#[0-9A-Fa-f]{6}$/)]); + +export const CatalogProviderSchema = z.enum([ + "aws", + "azure", + "gcp", + "microsoft", + "salesforce", + "atlassian", + "apache", + "supabase", +]); + +export const CatalogTechnologyTypeSchema = z.enum([ + "data-storage", + "deployment", + "framework-library", + "gateway", + "other", + "language", + "message-broker", + "network", + "protocol", + "runtime", + "service-tool", +]); + +export const CatalogRestrictionSchema = z.enum([ + "actor", + "app", + "component", + "connection", + "group", + "store", + "system", +]); + +export type ResponseFormat = z.infer; diff --git a/src/format.ts b/src/services/formatters.ts similarity index 54% rename from src/format.ts rename to src/services/formatters.ts index c308549..d281e34 100644 --- a/src/format.ts +++ b/src/services/formatters.ts @@ -1,4 +1,5 @@ -import type { CatalogTechnology, ModelConnection, ModelObject, Team } from "./types.js"; +import type { CatalogTechnology, ModelConnection, ModelObject } from "../types.js"; +import { MODEL_OBJECT_DESCRIPTION_MAX_LENGTH } from "../constants.js"; /** * Converts text and URL into a markdown link. @@ -7,18 +8,17 @@ import type { CatalogTechnology, ModelConnection, ModelObject, Team } from "./ty * @returns A string formatted as a markdown link. */ export function toMarkdownLink(text: string, url: string): string { - return `[${text}](${url})`; + return `[${text}](${url})`; } export const BASE_PATH = process.env.ICEPANEL_APP_BASE_URL || "https://app.icepanel.io"; - export const modelObjectUrl = (landscapeId: string, modelObjectHandle: string): string => { - return `${BASE_PATH}/landscapes/${landscapeId}/versions/latest/model/objects?object_tab=details&object=${modelObjectHandle}` -} + return `${BASE_PATH}/landscapes/${landscapeId}/versions/latest/model/objects?object_tab=details&object=${modelObjectHandle}`; +}; export const formatModelObjectListItem = (landscapeId: string, modelObject: ModelObject): string => { - let formatString = ''; + let formatString = ""; if (modelObject.name) { formatString += `# ${modelObject.name}\n`; @@ -28,19 +28,17 @@ export const formatModelObjectListItem = (landscapeId: string, modelObject: Mode formatString += `- ID: ${modelObject.id}\n`; } - if (modelObject.name) { - formatString += `- Name: ${modelObject.name}\n`; - } - if (modelObject.description) { - formatString += `- Description: \n` - const maxLength = 150; - let truncatedDescription = modelObject.description; - if (truncatedDescription.length > maxLength) { - const lastSpaceIndex = truncatedDescription.lastIndexOf(' ', maxLength); - truncatedDescription = truncatedDescription.slice(0, lastSpaceIndex > 0 ? lastSpaceIndex : maxLength); - } - formatString += `${truncatedDescription}...\n` + formatString += "- Description: \n"; + const maxLength = MODEL_OBJECT_DESCRIPTION_MAX_LENGTH; + let truncatedDescription = modelObject.description; + let isTruncated = false; + if (truncatedDescription.length > maxLength) { + const lastSpaceIndex = truncatedDescription.lastIndexOf(" ", maxLength); + truncatedDescription = truncatedDescription.slice(0, lastSpaceIndex > 0 ? lastSpaceIndex : maxLength); + isTruncated = true; + } + formatString += `${truncatedDescription}${isTruncated ? "..." : ""}\n`; } if (modelObject.type) { @@ -56,10 +54,10 @@ export const formatModelObjectListItem = (landscapeId: string, modelObject: Mode } return formatString; -} +}; export const formatModelObjectRelatedItem = (modelObject: ModelObject): string => { - let formatString = ''; + let formatString = ""; if (modelObject.name) { formatString += `##### ${modelObject.name}\n`; @@ -69,10 +67,6 @@ export const formatModelObjectRelatedItem = (modelObject: ModelObject): string = formatString += `- ID: ${modelObject.id}\n`; } - if (modelObject.name) { - formatString += `- Name: ${modelObject.name}\n`; - } - if (modelObject.type) { formatString += `- Type: ${modelObject.type}\n`; } @@ -82,10 +76,15 @@ export const formatModelObjectRelatedItem = (modelObject: ModelObject): string = } return formatString; -} +}; -export const formatModelObjectItem = (landscapeId: string, modelObject: ModelObject, teams: Team[], parentObject?: ModelObject, childObjects?: ModelObject[]): string => { - let formatString = ''; +export const formatModelObjectItem = ( + landscapeId: string, + modelObject: ModelObject, + parentObject?: ModelObject, + childObjects?: ModelObject[] +): string => { + let formatString = ""; if (modelObject.name) { formatString += `# ${modelObject.name}\n`; @@ -95,10 +94,6 @@ export const formatModelObjectItem = (landscapeId: string, modelObject: ModelObj formatString += `- ID: ${modelObject.id}\n`; } - if (modelObject.name) { - formatString += `- Name: ${modelObject.name}\n`; - } - formatString += `- View in IcePanel: ${modelObjectUrl(landscapeId, modelObject.handleId)}\n`; if (modelObject.description) { @@ -118,41 +113,37 @@ export const formatModelObjectItem = (landscapeId: string, modelObject: ModelObj } if (modelObject.links && Object.keys(modelObject.links).length > 0) { - formatString += `- Links:\n`; + formatString += "- Links:\n"; for (const [_, link] of Object.entries(modelObject.links)) { formatString += ` - [${link.customName || link.name || link.id}](${link.url})\n`; } } if (modelObject.technologies && Object.values(modelObject.technologies).length > 0) { - formatString += `- Technologies: ${Object.values(modelObject.technologies).map(t => t.name).join(", ")}\n`; - } - - if (modelObject.teamIds && modelObject.teamIds.length > 0) { - formatString += `- Teams: ${modelObject.teamIds.map(teamId => teams.find(t => t.id === teamId)?.name).filter(it => !!it).join(', ')}\n`; + formatString += `- Technologies: ${Object.values(modelObject.technologies) + .map((t) => t.name) + .join(", ")}\n`; } if (parentObject) { - formatString += `### Parent Object\n\n` - formatString += formatModelObjectRelatedItem(parentObject) + '\n\n' + formatString += "### Parent Object\n\n"; + formatString += `${formatModelObjectRelatedItem(parentObject)}\n\n`; } if (childObjects) { - formatString += `### Child Objects\n\n` - formatString += childObjects.map(o => formatModelObjectRelatedItem(o)).join('\n\n') + formatString += "### Child Objects\n\n"; + formatString += childObjects.map((o) => formatModelObjectRelatedItem(o)).join("\n\n"); } return formatString; -} +}; export const formatCatalogTechnology = (technology: CatalogTechnology) => { - let formatString = ''; + let formatString = ""; formatString += `# ${technology.name}\n\n`; - formatString += `- Name: ${technology.name}\n`; formatString += `- ID: ${technology.id}\n`; - if (technology.nameShort) { formatString += `- Short Name: ${technology.nameShort}\n`; } @@ -162,11 +153,11 @@ export const formatCatalogTechnology = (technology: CatalogTechnology) => { } if (technology.docsUrl) { - formatString += `- Documentation: ${toMarkdownLink('Docs', technology.docsUrl)}\n`; + formatString += `- Documentation: ${toMarkdownLink("Docs", technology.docsUrl)}\n`; } if (technology.websiteUrl) { - formatString += `- Website: ${toMarkdownLink('Website', technology.websiteUrl)}\n`; + formatString += `- Website: ${toMarkdownLink("Website", technology.websiteUrl)}\n`; } if (technology.status) { @@ -190,70 +181,58 @@ export const formatCatalogTechnology = (technology: CatalogTechnology) => { } return formatString; -} - - -export const formatTeam = (team: Team): string => { - let formatString = ''; - - if (team.name) { - formatString += `# ${team.name}\n`; - } - - if (team.id) { - formatString += `- ID: ${team.id}\n`; - } - - if (team.name) { - formatString += `- Name: ${team.name}\n`; - } - - if (team.userIds && team.userIds.length > 0) { - formatString += `- Team size: ${team.userIds.length}\n`; - } - - return formatString; -} - -export const formatConnections = (modelObject: ModelObject, incomingConnections: ModelConnection[], outgoingConnections: ModelConnection[], modelObjects: ModelObject[]) => { - let formatString = ''; - formatString += `# ${modelObject.name} - Connections\n\n`; +}; + +export const formatConnections = ( + modelObject: ModelObject, + incomingConnections: ModelConnection[], + outgoingConnections: ModelConnection[], + modelObjects: ModelObject[] +) => { + const formatModelLabel = (model: ModelObject): string => { + const name = model.name || model.handleId || model.id || "Unknown model object"; + const type = model.type ? ` (${model.type})` : ""; + return `${name}${type}`; + }; + + let formatString = ""; + formatString += `# ${formatModelLabel(modelObject)} - Connections\n\n`; if (!incomingConnections.length && !outgoingConnections.length) { - formatString += `No connections found.\n` + formatString += "No connections found.\n"; } const referencedModels: ModelObject[] = []; if (incomingConnections.length) { - formatString += `### Incoming connections\n` - const connectionString = incomingConnections.map(c => { - const connectedModel = modelObjects.find(o => o.id === c.originId) + formatString += "### Incoming connections\n"; + const connectionString = incomingConnections.map((c) => { + const connectedModel = modelObjects.find((o) => o.id === c.originId); if (!connectedModel) { - return '' + return ""; } referencedModels.push(connectedModel); - return `${connectedModel.name} (${connectedModel.type}) -[${c.name}]-> ${modelObject.name} (${modelObject.type})` - }) - formatString += connectionString.join('\n') + return `${formatModelLabel(connectedModel)} -[${c.name}]-> ${formatModelLabel(modelObject)}`; + }); + formatString += connectionString.join("\n"); } if (outgoingConnections.length) { - formatString += `### Outgoing connections\n` - const connectionString = outgoingConnections.map(c => { - const connectedModel = modelObjects.find(o => o.id === c.targetId) + formatString += "### Outgoing connections\n"; + const connectionString = outgoingConnections.map((c) => { + const connectedModel = modelObjects.find((o) => o.id === c.targetId); if (!connectedModel) { - return '' + return ""; } referencedModels.push(connectedModel); - return `${modelObject.name} (${modelObject.type}) -[${c.name}]-> ${connectedModel.name} (${connectedModel.type})` - }) - formatString += connectionString.join('\n') + return `${formatModelLabel(modelObject)} -[${c.name}]-> ${formatModelLabel(connectedModel)}`; + }); + formatString += connectionString.join("\n"); } if (referencedModels.length) { - formatString += `### Referenced Model Objects` - formatString += referencedModels.map(o => formatModelObjectRelatedItem(o)).join('\n') + formatString += "### Referenced Model Objects"; + formatString += referencedModels.map((o) => formatModelObjectRelatedItem(o)).join("\n"); } - return formatString -} + return formatString; +}; diff --git a/src/services/icepanel-client.ts b/src/services/icepanel-client.ts new file mode 100644 index 0000000..199743a --- /dev/null +++ b/src/services/icepanel-client.ts @@ -0,0 +1,627 @@ +/** + * IcePanel API client + */ + +import type { + ModelObjectsResponse, + ModelObjectResponse, + CatalogTechnologyResponse, + ModelConnectionsResponse, + ModelConnectionResponse, + CreateModelObjectRequest, + UpdateModelObjectRequest, + CreateConnectionRequest, + UpdateConnectionRequest, + CreateTagRequest, + UpdateTagRequest, + TagResponse, + CreateDomainRequest, + UpdateDomainRequest, + DomainResponse, +} from "../types.js"; + +const DEFAULT_API_BASE_URL = "https://api.icepanel.io/v1"; +const DEFAULT_API_TIMEOUT_MS = 30000; +const DEFAULT_API_MAX_RETRIES = 2; +const DEFAULT_API_RETRY_BASE_DELAY_MS = 300; +const MAX_API_RETRIES = 5; +const MAX_API_RETRY_BASE_DELAY_MS = 5000; + +function parseEnvInt(name: string, fallback: number, min: number, max: number): number { + const raw = process.env[name]; + if (!raw) { + return fallback; + } + const value = Number.parseInt(raw, 10); + if (!Number.isFinite(value)) { + return fallback; + } + return Math.min(Math.max(value, min), max); +} + +function isTruthyEnv(value: string | undefined): boolean { + return value === "1" || value === "true" || value === "TRUE" || value === "yes" || value === "YES"; +} + +function getValidatedApiBaseUrl(): string { + const raw = process.env.ICEPANEL_API_BASE_URL || DEFAULT_API_BASE_URL; + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + throw new Error("ICEPANEL_API_BASE_URL must be a valid URL"); + } + + const isInsecureAllowed = isTruthyEnv(process.env.ICEPANEL_API_ALLOW_INSECURE); + if (parsed.protocol === "http:" && !isInsecureAllowed) { + throw new Error("ICEPANEL_API_BASE_URL must use https unless ICEPANEL_API_ALLOW_INSECURE is true"); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error("ICEPANEL_API_BASE_URL must use http or https"); + } + + return parsed.toString().replace(/\/$/, ""); +} + +// Base URL for the IcePanel API +// Use environment variable if set, otherwise default to production URL +const API_BASE_URL = getValidatedApiBaseUrl(); + +function getApiKey(): string { + const apiKey = process.env.API_KEY; + if (!apiKey) { + throw new Error("API_KEY environment variable is not set"); + } + return apiKey; +} + +/** + * Custom error class for IcePanel API errors with status code + */ +export class IcePanelApiError extends Error { + constructor( + public status: number, + public statusText: string, + public body?: any + ) { + super(`IcePanel API error: ${status} ${statusText}`); + this.name = "IcePanelApiError"; + } +} + +const API_TIMEOUT_MS = parseEnvInt("ICEPANEL_API_TIMEOUT_MS", DEFAULT_API_TIMEOUT_MS, 1000, 120000); +const API_MAX_RETRIES = parseEnvInt("ICEPANEL_API_MAX_RETRIES", DEFAULT_API_MAX_RETRIES, 0, MAX_API_RETRIES); +const API_RETRY_BASE_DELAY_MS = parseEnvInt( + "ICEPANEL_API_RETRY_BASE_DELAY_MS", + DEFAULT_API_RETRY_BASE_DELAY_MS, + 50, + MAX_API_RETRY_BASE_DELAY_MS +); + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableStatus(status: number): boolean { + return status === 429 || status >= 500; +} + +function isRetryableError(error: unknown, externalAbort: boolean): boolean { + if (externalAbort) { + return false; + } + if (error instanceof IcePanelApiError) { + return isRetryableStatus(error.status); + } + if (error instanceof Error) { + return error.name === "AbortError" || error instanceof TypeError; + } + return false; +} + +function getRetryDelay(attempt: number): number { + const delay = API_RETRY_BASE_DELAY_MS * Math.pow(2, attempt); + return Math.min(delay, MAX_API_RETRY_BASE_DELAY_MS); +} + +/** + * Handle API errors with actionable messages per mcp-builder skill guidelines + * + * @param error - The caught error + * @returns A user-friendly error message with guidance + */ +export function handleApiError(error: unknown): string { + if (error instanceof IcePanelApiError) { + switch (error.status) { + case 400: + return "Error: Invalid request. Check that all required fields are provided and IDs are 20 characters. " + + (error.body?.message ? `Details: ${error.body.message}` : ""); + case 401: + return "Error: Authentication failed. Verify your API_KEY is correct and has not expired."; + case 403: + return "Error: Permission denied. Your API key may only have read access. Generate a new key with write permissions."; + case 404: + return "Error: Resource not found. Verify the landscapeId and object IDs are correct. Use icepanel_list_model_objects to find valid IDs."; + case 409: + return "Error: Conflict. The resource may have been modified by another user. Fetch the latest version and try again."; + case 422: + return "Error: Validation failed. " + (error.body?.message ? `Details: ${error.body.message}` : "Check input parameters."); + case 429: + return "Error: Rate limit exceeded. Wait a moment before retrying."; + default: + return `Error: API request failed (${error.status}). ${error.body?.message || error.statusText}`; + } + } + return `Error: ${error instanceof Error ? error.message : String(error)}`; +} + +/** + * Make an authenticated request to the IcePanel API + */ +async function apiRequest(path: string, options: RequestInit = {}): Promise { + const url = `${API_BASE_URL}${path}`; + + const headers = { + Accept: "application/json", + "Content-Type": "application/json", + Authorization: `ApiKey ${getApiKey()}`, + ...options.headers, + }; + + const method = (options.method || "GET").toUpperCase(); + const canRetry = method === "GET" || method === "HEAD"; + + for (let attempt = 0; attempt <= API_MAX_RETRIES; attempt++) { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), API_TIMEOUT_MS); + const abortListener = () => controller.abort(); + + if (options.signal) { + if (options.signal.aborted) { + controller.abort(); + } else { + options.signal.addEventListener("abort", abortListener, { once: true }); + } + } + + try { + const response = await fetch(url, { + ...options, + headers, + signal: controller.signal, + }); + + if (!response.ok) { + const rawText = await response.text(); + let body: any; + try { + body = JSON.parse(rawText); + } catch { + body = rawText || undefined; + } + + const apiError = new IcePanelApiError(response.status, response.statusText, body); + if (canRetry && attempt < API_MAX_RETRIES && isRetryableStatus(response.status)) { + await sleep(getRetryDelay(attempt)); + continue; + } + throw apiError; + } + + // Handle 204 No Content (for DELETE operations) + if (response.status === 204) { + return {} as T; + } + + const data = await response.json(); + return data as T; + } catch (error) { + if (canRetry && attempt < API_MAX_RETRIES && isRetryableError(error, options.signal?.aborted ?? false)) { + await sleep(getRetryDelay(attempt)); + continue; + } + throw error; + } finally { + clearTimeout(timeoutId); + if (options.signal) { + options.signal.removeEventListener("abort", abortListener); + } + } + } + + throw new Error("Unexpected error in apiRequest"); +} + +/** + * Build URLSearchParams from a filter object + * + * Converts a filter object to query parameters in the format expected by the IcePanel API. + * Handles arrays, null values, labels objects, and simple values. + * + * @param filter - The filter object to convert + * @returns URLSearchParams ready to be appended to a URL + */ +function buildFilterParams(filter: Record): URLSearchParams { + const params = new URLSearchParams(); + + Object.entries(filter).forEach(([key, value]) => { + if (value === undefined) return; + + if (key === "labels" && typeof value === "object" && value !== null) { + // Handle labels object + Object.entries(value as Record).forEach(([labelKey, labelValue]) => { + params.append(`filter[labels][${labelKey}]`, labelValue); + }); + } else if (Array.isArray(value)) { + // Handle array values + value.forEach((item) => { + params.append(`filter[${key}][]`, String(item)); + }); + } else if (value === null) { + // Handle null values + params.append(`filter[${key}]`, "null"); + } else { + // Handle simple values + params.append(`filter[${key}]`, String(value)); + } + }); + + return params; +} + +/** + * Get all landscapes + */ +export async function getLandscapes(organizationId: string) { + return apiRequest(`/organizations/${organizationId}/landscapes`); +} + +/** + * Get a specific landscape + */ +export async function getLandscape(organizationId: string, landscapeId: string) { + return apiRequest(`/organizations/${organizationId}/landscapes/${landscapeId}`); +} + +/** + * Get a specific version + */ +export async function getVersion(landscapeId: string, versionId: string = "latest") { + return apiRequest(`/landscapes/${landscapeId}/versions/${versionId}`); +} + +/** + * Get catalog technologies + * + * Retrieves a list of technologies from the IcePanel catalog + * + * @param options - Filter options for the catalog technologies + * @param options.filter.provider - Filter by provider (aws, azure, gcp, etc.) + * @param options.filter.type - Filter by technology type (data-storage, deployment, etc.) + * @param options.filter.restrictions - Filter by restrictions (actor, app, component, etc.) + * @param options.filter.status - Filter by status (approved, pending-review, rejected) + * @returns Promise with catalog technologies response + */ +export async function getCatalogTechnologies( + options: { + filter?: { + provider?: string | string[] | null; + type?: string | string[] | null; + restrictions?: string | string[]; + status?: string | string[]; + }; + } = {} +) { + const params = options.filter ? buildFilterParams(options.filter) : new URLSearchParams(); + const queryString = params.toString(); + const url = `/catalog/technologies${queryString ? `?${queryString}` : ""}`; + + return apiRequest(url) as Promise; +} + +/** + * Get organization technologies + * + * Retrieves a list of technologies from an organization + * + * @param organizationId - The ID of the organization + * @param options - Filter options for the organization technologies + * @param options.filter.provider - Filter by provider (aws, azure, gcp, etc.) + * @param options.filter.type - Filter by technology type (data-storage, deployment, etc.) + * @param options.filter.restrictions - Filter by restrictions (actor, app, component, etc.) + * @param options.filter.status - Filter by status (approved, pending-review, rejected) + * @returns Promise with catalog technologies response + */ +export async function getOrganizationTechnologies( + organizationId: string, + options: { + filter?: { + provider?: string | string[] | null; + type?: string | string[] | null; + restrictions?: string | string[]; + status?: string | string[]; + }; + } = {} +) { + const params = options.filter ? buildFilterParams(options.filter) : new URLSearchParams(); + const queryString = params.toString(); + const url = `/organizations/${organizationId}/technologies${queryString ? `?${queryString}` : ""}`; + + return apiRequest(url) as Promise; +} + +/** + * Get all model objects for a landscape version + */ +export async function getModelObjects( + landscapeId: string, + versionId: string = "latest", + options: { + filter?: { + domainId?: string | string[]; + external?: boolean; + handleId?: string | string[]; + labels?: Record; + name?: string; + parentId?: string | null; + status?: string | string[]; + type?: string | string[]; + }; + } = {} +): Promise { + const params = options.filter ? buildFilterParams(options.filter) : new URLSearchParams(); + const queryString = params.toString(); + const url = `/landscapes/${landscapeId}/versions/${versionId}/model/objects${queryString ? `?${queryString}` : ""}`; + + return apiRequest(url) as Promise; +} + +/** + * Get a specific model object + */ +export async function getModelObject( + landscapeId: string, + modelObjectId: string, + versionId: string = "latest" +) { + return apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/model/objects/${modelObjectId}` + ) as Promise; +} + +/** + * Get all model connections + * + * Retrieves a list of connections between model objects + * + * @param landscapeId - The ID of the landscape + * @param versionId - The ID of the version (defaults to "latest") + * @param options - Filter options for the model connections + * @param options.filter.direction - Filter by connection direction (outgoing, bidirectional) + * @param options.filter.handleId - Filter by handle ID + * @param options.filter.labels - Filter by labels + * @param options.filter.name - Filter by name + * @param options.filter.originId - Filter by origin ID + * @param options.filter.status - Filter by status (deprecated, future, live, removed) + * @param options.filter.targetId - Filter by target ID + * @returns Promise with model connections response + */ +export async function getModelConnections( + landscapeId: string, + versionId: string = "latest", + options: { + filter?: { + direction?: "outgoing" | "bidirectional" | null; + handleId?: string | string[]; + labels?: Record; + name?: string; + originId?: string | string[]; + status?: ("deprecated" | "future" | "live" | "removed") | ("deprecated" | "future" | "live" | "removed")[]; + targetId?: string | string[]; + }; + } = {} +): Promise { + const params = options.filter ? buildFilterParams(options.filter) : new URLSearchParams(); + const queryString = params.toString(); + const url = `/landscapes/${landscapeId}/versions/${versionId}/model/connections${queryString ? `?${queryString}` : ""}`; + + return apiRequest(url) as Promise; +} + +/** + * Get a specific connection + */ +export async function getConnection( + landscapeId: string, + versionId: string, + connectionId: string +): Promise { + return apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/model/connections/${connectionId}` + ) as Promise; +} + +// ============================================================================ +// Model Object Write Operations +// ============================================================================ + +/** + * Create a new model object + * + * @param landscapeId - The landscape ID + * @param data - The model object data to create + * @param versionId - The version ID (defaults to "latest") + * @returns Promise with the created model object + */ +export async function createModelObject( + landscapeId: string, + data: CreateModelObjectRequest, + versionId: string = "latest" +): Promise { + return apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/model/objects`, + { + method: "POST", + body: JSON.stringify(data), + } + ); +} + +/** + * Update an existing model object + * + * @param landscapeId - The landscape ID + * @param modelObjectId - The model object ID to update + * @param data - The fields to update + * @param versionId - The version ID (defaults to "latest") + * @returns Promise with the updated model object + */ +export async function updateModelObject( + landscapeId: string, + modelObjectId: string, + data: UpdateModelObjectRequest, + versionId: string = "latest" +): Promise { + return apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/model/objects/${modelObjectId}`, + { + method: "PATCH", + body: JSON.stringify(data), + } + ); +} + +/** + * Delete a model object + * + * @param landscapeId - The landscape ID + * @param modelObjectId - The model object ID to delete + * @param versionId - The version ID (defaults to "latest") + * @returns Promise that resolves when deletion is complete + */ +export async function deleteModelObject( + landscapeId: string, + modelObjectId: string, + versionId: string = "latest" +): Promise { + await apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/model/objects/${modelObjectId}`, + { + method: "DELETE", + } + ); +} + +// ============================================================================ +// Connection Write Operations +// ============================================================================ + +export async function createConnection( + landscapeId: string, + data: CreateConnectionRequest, + versionId: string = "latest" +): Promise { + return apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/model/connections`, + { method: "POST", body: JSON.stringify(data) } + ); +} + +export async function updateConnection( + landscapeId: string, + connectionId: string, + data: UpdateConnectionRequest, + versionId: string = "latest" +): Promise { + return apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/model/connections/${connectionId}`, + { method: "PATCH", body: JSON.stringify(data) } + ); +} + +export async function deleteConnection( + landscapeId: string, + connectionId: string, + versionId: string = "latest" +): Promise { + await apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/model/connections/${connectionId}`, + { method: "DELETE" } + ); +} + +// ============================================================================ +// Tag Write Operations +// ============================================================================ + +export async function createTag( + landscapeId: string, + data: CreateTagRequest, + versionId: string = "latest" +): Promise { + return apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/tags`, + { method: "POST", body: JSON.stringify(data) } + ); +} + +export async function updateTag( + landscapeId: string, + tagId: string, + data: UpdateTagRequest, + versionId: string = "latest" +): Promise { + return apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/tags/${tagId}`, + { method: "PATCH", body: JSON.stringify(data) } + ); +} + +export async function deleteTag( + landscapeId: string, + tagId: string, + versionId: string = "latest" +): Promise { + await apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/tags/${tagId}`, + { method: "DELETE" } + ); +} + +// ============================================================================ +// Domain Write Operations +// ============================================================================ + +export async function createDomain( + landscapeId: string, + data: CreateDomainRequest, + versionId: string = "latest" +): Promise { + return apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/domains`, + { method: "POST", body: JSON.stringify(data) } + ); +} + +export async function updateDomain( + landscapeId: string, + domainId: string, + data: UpdateDomainRequest, + versionId: string = "latest" +): Promise { + return apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/domains/${domainId}`, + { method: "PATCH", body: JSON.stringify(data) } + ); +} + +export async function deleteDomain( + landscapeId: string, + domainId: string, + versionId: string = "latest" +): Promise { + await apiRequest( + `/landscapes/${landscapeId}/versions/${versionId}/domains/${domainId}`, + { method: "DELETE" } + ); +} diff --git a/src/tools/connections.ts b/src/tools/connections.ts new file mode 100644 index 0000000..0201e20 --- /dev/null +++ b/src/tools/connections.ts @@ -0,0 +1,138 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { + createConnection, + deleteConnection, + getConnection, + handleApiError, + updateConnection, +} from "../services/icepanel-client.js"; +import { ConnectionDirectionSchema, IcePanelIdSchema, StatusSchema } from "../schemas/index.js"; + +const CreateConnectionSchema = z + .object({ + landscapeId: IcePanelIdSchema, + name: z.string().min(1).max(255), + originId: IcePanelIdSchema, + targetId: IcePanelIdSchema, + direction: ConnectionDirectionSchema, + description: z.string().optional(), + status: StatusSchema.default("live"), + }) + .strict(); + +const UpdateConnectionSchema = z + .object({ + landscapeId: IcePanelIdSchema, + connectionId: IcePanelIdSchema, + name: z.string().min(1).max(255).optional(), + direction: ConnectionDirectionSchema.optional(), + description: z.string().optional(), + status: StatusSchema.optional(), + }) + .strict(); + +const DeleteConnectionSchema = z + .object({ + landscapeId: IcePanelIdSchema, + connectionId: IcePanelIdSchema, + }) + .strict(); + +export function registerConnectionTools(server: McpServer) { + server.registerTool( + "icepanel_create_connection", + { + title: "Create IcePanel Connection", + description: `Create a new connection between model objects in IcePanel.`, + inputSchema: CreateConnectionSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ landscapeId, ...data }) => { + try { + const result = await createConnection(landscapeId, data); + const conn = result.modelConnection; + return { + content: [ + { + type: "text", + text: `# Connection Created\n\n- ID: ${conn.id}\n- Name: ${conn.name}\n- Status: ${conn.status}`, + }, + ], + structuredContent: { connection: conn }, + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } + ); + + server.registerTool( + "icepanel_update_connection", + { + title: "Update IcePanel Connection", + description: `Update an existing connection in IcePanel. Only provided fields will be updated.`, + inputSchema: UpdateConnectionSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, connectionId, ...data }) => { + try { + const updateData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v !== undefined)); + const result = await updateConnection(landscapeId, connectionId, updateData); + return { + content: [ + { + type: "text", + text: `# Connection Updated\n\n- ID: ${result.modelConnection.id}\n- Name: ${result.modelConnection.name}`, + }, + ], + structuredContent: { connection: result.modelConnection }, + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } + ); + + server.registerTool( + "icepanel_delete_connection", + { + title: "Delete IcePanel Connection", + description: `Delete a connection from IcePanel. WARNING: This action cannot be undone.`, + inputSchema: DeleteConnectionSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, connectionId }) => { + try { + const existing = await getConnection(landscapeId, "latest", connectionId); + await deleteConnection(landscapeId, connectionId); + return { + content: [ + { + type: "text", + text: `# Connection Deleted\n\nDeleted "${existing.modelConnection.name}" (ID: ${connectionId}).`, + }, + ], + structuredContent: { deleted: { id: connectionId, name: existing.modelConnection.name } }, + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } + ); +} diff --git a/src/tools/domains.ts b/src/tools/domains.ts new file mode 100644 index 0000000..c6ef961 --- /dev/null +++ b/src/tools/domains.ts @@ -0,0 +1,119 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createDomain, deleteDomain, handleApiError, updateDomain } from "../services/icepanel-client.js"; +import { ColorSchema, IcePanelIdSchema } from "../schemas/index.js"; + +const CreateDomainSchema = z + .object({ + landscapeId: IcePanelIdSchema, + name: z.string().min(1).max(255), + color: ColorSchema.optional(), + }) + .strict(); + +const UpdateDomainSchema = z + .object({ + landscapeId: IcePanelIdSchema, + domainId: IcePanelIdSchema, + name: z.string().min(1).max(255).optional(), + color: ColorSchema.optional(), + }) + .strict(); + +const DeleteDomainSchema = z + .object({ + landscapeId: IcePanelIdSchema, + domainId: IcePanelIdSchema, + }) + .strict(); + +export function registerDomainTools(server: McpServer) { + server.registerTool( + "icepanel_create_domain", + { + title: "Create IcePanel Domain", + description: `Create a new domain in an IcePanel landscape. Domains organize model objects into logical groupings.`, + inputSchema: CreateDomainSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ landscapeId, ...data }) => { + try { + const result = await createDomain(landscapeId, data); + return { + content: [ + { + type: "text", + text: `# Domain Created\n\n- ID: ${result.domain.id}\n- Name: ${result.domain.name}`, + }, + ], + structuredContent: { domain: result.domain }, + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } + ); + + server.registerTool( + "icepanel_update_domain", + { + title: "Update IcePanel Domain", + description: `Update an existing domain in IcePanel.`, + inputSchema: UpdateDomainSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, domainId, ...data }) => { + try { + const updateData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v !== undefined)); + const result = await updateDomain(landscapeId, domainId, updateData); + return { + content: [ + { + type: "text", + text: `# Domain Updated\n\n- ID: ${result.domain.id}\n- Name: ${result.domain.name}`, + }, + ], + structuredContent: { domain: result.domain }, + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } + ); + + server.registerTool( + "icepanel_delete_domain", + { + title: "Delete IcePanel Domain", + description: `Delete a domain from IcePanel. WARNING: This action cannot be undone.`, + inputSchema: DeleteDomainSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, domainId }) => { + try { + await deleteDomain(landscapeId, domainId); + return { + content: [{ type: "text", text: `# Domain Deleted\n\nDeleted domain (ID: ${domainId}).` }], + structuredContent: { deleted: { id: domainId } }, + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } + ); +} diff --git a/src/tools/index.ts b/src/tools/index.ts new file mode 100644 index 0000000..19832e5 --- /dev/null +++ b/src/tools/index.ts @@ -0,0 +1,16 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerConnectionTools } from "./connections.js"; +import { registerDomainTools } from "./domains.js"; +import { registerLandscapeTools } from "./landscapes.js"; +import { registerModelObjectTools } from "./model-objects.js"; +import { registerTagTools } from "./tags.js"; +import { registerTechnologyTools } from "./technologies.js"; + +export function registerAllTools(server: McpServer, organizationId: string) { + registerLandscapeTools(server, organizationId); + registerModelObjectTools(server, organizationId); + registerConnectionTools(server); + registerTechnologyTools(server, organizationId); + registerTagTools(server); + registerDomainTools(server); +} diff --git a/src/tools/landscapes.ts b/src/tools/landscapes.ts new file mode 100644 index 0000000..28ed738 --- /dev/null +++ b/src/tools/landscapes.ts @@ -0,0 +1,113 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { handleApiError, getLandscape, getLandscapes } from "../services/icepanel-client.js"; +import { ResponseFormatSchema, PaginationSchema, IcePanelIdSchema } from "../schemas/index.js"; +import { applyCharacterLimit, formatOutput, paginateArray } from "./utils.js"; + +const ListLandscapesSchema = PaginationSchema.extend({ + response_format: ResponseFormatSchema, +}).strict(); + +const GetLandscapeSchema = z + .object({ + landscapeId: IcePanelIdSchema, + response_format: ResponseFormatSchema, + }) + .strict(); + +function formatLandscapeItem(landscape: Record) { + const name = landscape.name ?? "Untitled landscape"; + const id = landscape.id ?? "unknown"; + const description = landscape.description ? `\n- Description: ${landscape.description}` : ""; + return `# ${name}\n- ID: ${id}${description}`; +} + +export function registerLandscapeTools(server: McpServer, organizationId: string) { + server.registerTool( + "icepanel_list_landscapes", + { + title: "List IcePanel Landscapes", + description: `Get all landscapes in your IcePanel organization. + +Args: + - limit (number): Max results to return (default: 50) + - offset (number): Number of results to skip (default: 0) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Paginated list of landscapes with ID, name, and description when available. + Includes pagination metadata: total, count, has_more, next_offset.`, + inputSchema: ListLandscapesSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ limit, offset, response_format }) => { + try { + const result = await getLandscapes(organizationId); + const landscapes = Array.isArray((result as any).landscapes) + ? (result as any).landscapes + : Array.isArray(result) + ? result + : []; + + const paged = paginateArray(landscapes, offset, limit); + const { output, rendered } = applyCharacterLimit( + { ...paged }, + response_format, + (current) => current.items.map((item) => formatLandscapeItem(item as Record)).join("\n\n") + ); + + return { + content: [{ type: "text", text: rendered }], + structuredContent: output, + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } + ); + + server.registerTool( + "icepanel_get_landscape", + { + title: "Get IcePanel Landscape", + description: `Get details for a single IcePanel landscape by ID. + +Args: + - landscapeId (string): Landscape ID (20 characters) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Landscape details including ID, name, and metadata.`, + inputSchema: GetLandscapeSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, response_format }) => { + try { + const result = await getLandscape(organizationId, landscapeId); + const markdown = formatLandscapeItem(result as Record); + return { + content: [{ type: "text", text: formatOutput(response_format, markdown, result) }], + structuredContent: result, + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } + ); +} diff --git a/src/tools/model-objects.ts b/src/tools/model-objects.ts new file mode 100644 index 0000000..f69fee6 --- /dev/null +++ b/src/tools/model-objects.ts @@ -0,0 +1,391 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import Fuse from "fuse.js"; +import { + createModelObject, + deleteModelObject, + getModelConnections, + getModelObject, + getModelObjects, + handleApiError, + updateModelObject, +} from "../services/icepanel-client.js"; +import { + formatConnections, + formatModelObjectItem, + formatModelObjectListItem, +} from "../services/formatters.js"; +import { + IcePanelIdSchema, + ModelObjectTypeSchema, + ResponseFormatSchema, + StatusSchema, + PaginationSchema, +} from "../schemas/index.js"; +import { applyCharacterLimit, formatOutput, paginateArray } from "./utils.js"; + +const IdOrIdsSchema = z.union([IcePanelIdSchema, z.array(IcePanelIdSchema)]); +const MutableModelObjectTypeSchema = z.enum(["actor", "app", "component", "group", "store", "system"]); + +const ListModelObjectsSchema = z + .object({ + landscapeId: IcePanelIdSchema, + domainId: IdOrIdsSchema.optional(), + external: z.boolean().optional().default(false), + name: z.string().optional(), + parentId: z.string().nullable().optional(), + status: z.union([StatusSchema, z.array(StatusSchema)]).optional(), + type: z.union([ModelObjectTypeSchema, z.array(ModelObjectTypeSchema)]).optional(), + technologyId: IdOrIdsSchema.optional(), + search: z.string().optional(), + limit: PaginationSchema.shape.limit, + offset: PaginationSchema.shape.offset, + response_format: ResponseFormatSchema, + }) + .strict(); + +const GetModelObjectSchema = z + .object({ + landscapeId: IcePanelIdSchema, + modelObjectId: IcePanelIdSchema, + includeHierarchicalInfo: z + .boolean() + .default(false) + .describe( + "Include hierarchical information like parent and child objects. (Only use this when necessary as it is an expensive operation.)" + ), + response_format: ResponseFormatSchema, + }) + .strict(); + +const GetModelObjectConnectionsSchema = z + .object({ + landscapeId: IcePanelIdSchema, + modelObjectId: IcePanelIdSchema, + response_format: ResponseFormatSchema, + }) + .strict(); + +const CreateModelObjectSchema = z + .object({ + landscapeId: IcePanelIdSchema, + name: z.string().min(1).max(255), + type: MutableModelObjectTypeSchema, + parentId: IcePanelIdSchema, + description: z.string().optional(), + status: StatusSchema.default("live"), + external: z.boolean().default(false), + technologyIds: z.array(IcePanelIdSchema).optional(), + caption: z.string().optional(), + }) + .strict(); + +const UpdateModelObjectSchema = z + .object({ + landscapeId: IcePanelIdSchema, + modelObjectId: IcePanelIdSchema, + name: z.string().min(1).max(255).optional(), + description: z.string().optional(), + status: StatusSchema.optional(), + external: z.boolean().optional(), + parentId: IcePanelIdSchema.optional(), + type: MutableModelObjectTypeSchema.optional(), + technologyIds: z.array(IcePanelIdSchema).optional(), + caption: z.string().optional(), + }) + .strict(); + +const DeleteModelObjectSchema = z + .object({ + landscapeId: IcePanelIdSchema, + modelObjectId: IcePanelIdSchema, + }) + .strict(); + +export function registerModelObjectTools(server: McpServer, organizationId: string) { + server.registerTool( + "icepanel_list_model_objects", + { + title: "List IcePanel Model Objects", + description: `Get model objects within an IcePanel landscape. + +IcePanel is a C4 diagramming tool. C4 is a framework for visualizing the architecture of software systems. +To get the C1 level objects - query for 'system' type. +To get the C2 level objects - query for 'app' and 'store' component types. +To get the C3 level objects - query for the 'component' type. + +The 'group' and 'actor' types can be used in any of the levels, and should generally be included in user queries. +- 'group' - is a type agnostic group which groups objects together +- 'actor' - is a actor in the system, typically a kind of user. Ex. 'our customer', 'admin user', etc. + +Args: + - landscapeId (string): Landscape ID (20 characters) + - limit (number): Max results to return (default: 50) + - offset (number): Number of results to skip (default: 0) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Paginated list of model objects with IDs and basic metadata.`, + inputSchema: ListModelObjectsSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, search, limit, offset, response_format, ...filters }) => { + try { + const result = await getModelObjects(landscapeId, "latest", { + filter: filters, + }); + let modelObjects = result.modelObjects; + if (search) { + const fuseInstance = new Fuse(modelObjects, { + keys: ["name", "description"], + threshold: 0.3, + }); + modelObjects = fuseInstance.search(search).map((resultItem) => resultItem.item); + } + + const paged = paginateArray(modelObjects, offset, limit); + const { output, rendered } = applyCharacterLimit( + { ...paged }, + response_format, + (current) => + current.items.map((item) => formatModelObjectListItem(landscapeId, item)).join("\n") + ); + + return { + content: [{ type: "text", text: rendered }], + structuredContent: output, + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } + ); + + server.registerTool( + "icepanel_get_model_object", + { + title: "Get IcePanel Model Object", + description: `Get detailed information about a model object in IcePanel. + +IcePanel is a C4 diagramming tool. C4 is a framework for visualizing the architecture of software systems. +Use this tool to get detailed information about a model object, such as its description, type, hierarchical information (parent and children), +and technologies it uses.`, + inputSchema: GetModelObjectSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, modelObjectId, includeHierarchicalInfo, response_format }) => { + try { + const result = await getModelObject(landscapeId, modelObjectId); + const modelObject = result.modelObject; + + let parentObject; + let childObjects; + + if (includeHierarchicalInfo) { + const listResult = await getModelObjects(landscapeId); + const modelObjectList = listResult.modelObjects; + parentObject = + modelObject.parentId && modelObject.parentId !== "root" + ? modelObjectList.find((o) => o.id === modelObject.parentId) + : undefined; + childObjects = + modelObject.childIds && modelObject.childIds.length > 0 + ? modelObjectList.filter((o) => modelObject.childIds.includes(o.id)) + : undefined; + } + + const markdown = formatModelObjectItem(landscapeId, modelObject, parentObject, childObjects); + const structuredContent = { + modelObject, + parentObject, + childObjects, + }; + + return { + content: [{ type: "text", text: formatOutput(response_format, markdown, structuredContent) }], + structuredContent, + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } + ); + + server.registerTool( + "icepanel_get_model_object_connections", + { + title: "Get IcePanel Model Object Connections", + description: `Get information about the relationships a model object has in IcePanel. + +Use this tool when you want to know about what objects are related to the current object. It provides a succinct list of related items.`, + inputSchema: GetModelObjectConnectionsSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, modelObjectId, response_format }) => { + try { + const modelObjectResult = await getModelObject(landscapeId, modelObjectId); + const modelObjectsResult = await getModelObjects(landscapeId); + const outgoingConnectionsResult = await getModelConnections(landscapeId, "latest", { + filter: { + originId: modelObjectId, + }, + }); + const incomingConnectionsResult = await getModelConnections(landscapeId, "latest", { + filter: { + targetId: modelObjectId, + }, + }); + + const markdown = formatConnections( + modelObjectResult.modelObject, + incomingConnectionsResult.modelConnections, + outgoingConnectionsResult.modelConnections, + modelObjectsResult.modelObjects + ); + + const structuredContent = { + modelObject: modelObjectResult.modelObject, + incomingConnections: incomingConnectionsResult.modelConnections, + outgoingConnections: outgoingConnectionsResult.modelConnections, + }; + + return { + content: [{ type: "text", text: formatOutput(response_format, markdown, structuredContent) }], + structuredContent, + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } + ); + + server.registerTool( + "icepanel_create_model_object", + { + title: "Create IcePanel Model Object", + description: `Create a new model object (system, app, component, etc.) in IcePanel. + +This tool CREATES a new C4 architecture element in your landscape.`, + inputSchema: CreateModelObjectSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ landscapeId, ...data }) => { + try { + const result = await createModelObject(landscapeId, data); + const markdown = `# Model Object Created Successfully\n\n${formatModelObjectItem( + landscapeId, + result.modelObject + )}`; + return { + content: [{ type: "text", text: markdown }], + structuredContent: { modelObject: result.modelObject }, + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } + ); + + server.registerTool( + "icepanel_update_model_object", + { + title: "Update IcePanel Model Object", + description: `Update an existing model object in IcePanel. Only provided fields will be updated.`, + inputSchema: UpdateModelObjectSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, modelObjectId, ...data }) => { + try { + const updateData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v !== undefined)); + const result = await updateModelObject(landscapeId, modelObjectId, updateData); + const markdown = `# Model Object Updated Successfully\n\n${formatModelObjectItem( + landscapeId, + result.modelObject + )}`; + return { + content: [{ type: "text", text: markdown }], + structuredContent: { modelObject: result.modelObject }, + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } + ); + + server.registerTool( + "icepanel_delete_model_object", + { + title: "Delete IcePanel Model Object", + description: `Delete a model object from IcePanel. WARNING: This action cannot be undone.`, + inputSchema: DeleteModelObjectSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, modelObjectId }) => { + try { + const existing = await getModelObject(landscapeId, modelObjectId); + const objectName = existing.modelObject.name; + + await deleteModelObject(landscapeId, modelObjectId); + return { + content: [ + { + type: "text", + text: `# Model Object Deleted\n\nSuccessfully deleted model object "${objectName}" (ID: ${modelObjectId}).`, + }, + ], + structuredContent: { deleted: { id: modelObjectId, name: objectName } }, + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } + ); +} diff --git a/src/tools/tags.ts b/src/tools/tags.ts new file mode 100644 index 0000000..935b409 --- /dev/null +++ b/src/tools/tags.ts @@ -0,0 +1,114 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import { createTag, deleteTag, handleApiError, updateTag } from "../services/icepanel-client.js"; +import { ColorSchema, IcePanelIdSchema } from "../schemas/index.js"; + +const CreateTagSchema = z + .object({ + landscapeId: IcePanelIdSchema, + name: z.string().min(1).max(255), + groupId: IcePanelIdSchema, + color: ColorSchema.optional(), + }) + .strict(); + +const UpdateTagSchema = z + .object({ + landscapeId: IcePanelIdSchema, + tagId: IcePanelIdSchema, + name: z.string().min(1).max(255).optional(), + color: ColorSchema.optional(), + }) + .strict(); + +const DeleteTagSchema = z + .object({ + landscapeId: IcePanelIdSchema, + tagId: IcePanelIdSchema, + }) + .strict(); + +export function registerTagTools(server: McpServer) { + server.registerTool( + "icepanel_create_tag", + { + title: "Create IcePanel Tag", + description: `Create a new tag in an IcePanel landscape.`, + inputSchema: CreateTagSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + }, + }, + async ({ landscapeId, ...data }) => { + try { + const result = await createTag(landscapeId, data); + return { + content: [ + { type: "text", text: `# Tag Created\n\n- ID: ${result.tag.id}\n- Name: ${result.tag.name}` }, + ], + structuredContent: { tag: result.tag }, + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } + ); + + server.registerTool( + "icepanel_update_tag", + { + title: "Update IcePanel Tag", + description: `Update an existing tag in IcePanel.`, + inputSchema: UpdateTagSchema, + annotations: { + readOnlyHint: false, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, tagId, ...data }) => { + try { + const updateData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v !== undefined)); + const result = await updateTag(landscapeId, tagId, updateData); + return { + content: [ + { type: "text", text: `# Tag Updated\n\n- ID: ${result.tag.id}\n- Name: ${result.tag.name}` }, + ], + structuredContent: { tag: result.tag }, + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } + ); + + server.registerTool( + "icepanel_delete_tag", + { + title: "Delete IcePanel Tag", + description: `Delete a tag from IcePanel. WARNING: This action cannot be undone.`, + inputSchema: DeleteTagSchema, + annotations: { + readOnlyHint: false, + destructiveHint: true, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ landscapeId, tagId }) => { + try { + await deleteTag(landscapeId, tagId); + return { + content: [{ type: "text", text: `# Tag Deleted\n\nDeleted tag (ID: ${tagId}).` }], + structuredContent: { deleted: { id: tagId } }, + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } + ); +} diff --git a/src/tools/technologies.ts b/src/tools/technologies.ts new file mode 100644 index 0000000..cee13cd --- /dev/null +++ b/src/tools/technologies.ts @@ -0,0 +1,97 @@ +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { z } from "zod"; +import Fuse from "fuse.js"; +import { + getCatalogTechnologies, + getOrganizationTechnologies, + handleApiError, +} from "../services/icepanel-client.js"; +import { formatCatalogTechnology } from "../services/formatters.js"; +import { + CatalogProviderSchema, + CatalogRestrictionSchema, + CatalogTechnologyTypeSchema, + PaginationSchema, + ResponseFormatSchema, +} from "../schemas/index.js"; +import { applyCharacterLimit, paginateArray } from "./utils.js"; + +const ProviderOrArraySchema = z.union([CatalogProviderSchema, z.array(CatalogProviderSchema)]); +const TypeOrArraySchema = z.union([CatalogTechnologyTypeSchema, z.array(CatalogTechnologyTypeSchema)]); +const RestrictionsOrArraySchema = z.union([CatalogRestrictionSchema, z.array(CatalogRestrictionSchema)]); + +const ListTechnologiesSchema = PaginationSchema.extend({ + provider: ProviderOrArraySchema.nullable().optional(), + type: TypeOrArraySchema.nullable().optional(), + restrictions: RestrictionsOrArraySchema.optional(), + search: z.string().optional(), + response_format: ResponseFormatSchema, +}).strict(); + +export function registerTechnologyTools(server: McpServer, organizationId: string) { + server.registerTool( + "icepanel_list_technologies", + { + title: "List IcePanel Technologies", + description: `Get the technology catalog in IcePanel. + +Args: + - provider (string|string[], optional): Provider filter (aws, azure, gcp, microsoft, salesforce, atlassian, apache, supabase) + - type (string|string[], optional): Technology type filter + - restrictions (string|string[], optional): Restrictions filter + - search (string, optional): Search by name/description + - limit (number): Max results to return (default: 50) + - offset (number): Number of results to skip (default: 0) + - response_format ('markdown' | 'json'): Output format (default: 'markdown') + +Returns: + Paginated list of technologies from catalog and organization.`, + inputSchema: ListTechnologiesSchema, + annotations: { + readOnlyHint: true, + destructiveHint: false, + idempotentHint: true, + openWorldHint: true, + }, + }, + async ({ provider, type, restrictions, search, limit, offset, response_format }) => { + try { + const result = await getCatalogTechnologies({ + filter: { provider, type, restrictions, status: "approved" }, + }); + const organizationResult = await getOrganizationTechnologies(organizationId, { + filter: { provider, type, restrictions }, + }); + + let combinedTechnologies = result.catalogTechnologies.concat( + organizationResult.catalogTechnologies + ); + + if (search) { + const fuse = new Fuse(combinedTechnologies, { + keys: ["name", "description"], + threshold: 0.3, + }); + combinedTechnologies = fuse.search(search).map((resultItem) => resultItem.item); + } + + const paged = paginateArray(combinedTechnologies, offset, limit); + const { output, rendered } = applyCharacterLimit( + { ...paged }, + response_format, + (current) => current.items.map((item) => formatCatalogTechnology(item)).join("\n") + ); + + return { + content: [{ type: "text", text: rendered }], + structuredContent: output, + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } + ); +} diff --git a/src/tools/utils.ts b/src/tools/utils.ts new file mode 100644 index 0000000..c274748 --- /dev/null +++ b/src/tools/utils.ts @@ -0,0 +1,54 @@ +import { CHARACTER_LIMIT } from "../constants.js"; +import type { ResponseFormat } from "../schemas/index.js"; + +export function formatOutput( + responseFormat: ResponseFormat, + markdown: string, + structuredContent: unknown +): string { + if (responseFormat === "json") { + return JSON.stringify(structuredContent, null, 2); + } + return markdown; +} + +export function paginateArray(items: T[], offset: number, limit: number) { + const total = items.length; + const pagedItems = items.slice(offset, offset + limit); + const hasMore = offset + pagedItems.length < total; + return { + total, + count: pagedItems.length, + offset, + items: pagedItems, + has_more: hasMore, + next_offset: hasMore ? offset + pagedItems.length : undefined, + }; +} + +export function applyCharacterLimit( + output: T, + responseFormat: ResponseFormat, + markdownRenderer: (current: T) => string +) { + let rendered = formatOutput(responseFormat, markdownRenderer(output), output); + + if (!Array.isArray(output.items)) { + return { output, rendered }; + } + + while (rendered.length > CHARACTER_LIMIT && output.items.length > 1) { + output.items = output.items.slice(0, Math.ceil(output.items.length / 2)); + output.count = output.items.length; + output.has_more = true; + const offset = (output as T & { offset?: number }).offset ?? 0; + output.next_offset = offset + output.items.length; + (output as T & { truncated?: boolean; truncation_message?: string }).truncated = true; + (output as T & { truncation_message?: string }).truncation_message = + "Response truncated to fit size limits. Use limit/offset or filters to page through results."; + + rendered = formatOutput(responseFormat, markdownRenderer(output), output); + } + + return { output, rendered }; +} diff --git a/src/transports/http-server.ts b/src/transports/http-server.ts new file mode 100644 index 0000000..af3b0a5 --- /dev/null +++ b/src/transports/http-server.ts @@ -0,0 +1,125 @@ +/** + * HTTP Server for IcePanel MCP + * + * Provides Streamable HTTP transport for the MCP server. + * This is the new standard transport, replacing the deprecated SSE transport. + * + * Single endpoint architecture: + * - GET/POST/DELETE /mcp - Main MCP endpoint (handles all communication) + * - GET /health - Health check endpoint + */ + +import express, { type Request, type Response } from "express"; +import cors from "cors"; +import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js"; +import type { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import type { Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +/** + * Create and start an HTTP server with Streamable HTTP transport for the MCP server + * + * @param server - The configured McpServer instance + * @param port - Port to listen on (default: 3000) + */ +export async function startHttpServer( + server: McpServer, + port: number = 3000, + options: { enableShutdownHandlers?: boolean } = {} +): Promise<{ server: Server; port: number; close: () => Promise }> { + const app = express(); + const enableShutdownHandlers = options.enableShutdownHandlers ?? true; + + // Enable CORS for all origins (MCP clients may be on different ports) + app.use(cors()); + + // Parse JSON bodies for health endpoint only + app.use("/health", express.json()); + + const transport = new StreamableHTTPServerTransport({ + sessionIdGenerator: undefined, + enableJsonResponse: true, + }); + + try { + await server.connect(transport); + } catch (error: any) { + console.error("Failed to connect MCP server to HTTP transport:", error?.message || error); + throw error; + } + + // Health check endpoint + app.get("/health", (_req: Request, res: Response) => { + res.json({ + status: "ok", + transport: "streamable-http", + version: "0.3.0", + }); + }); + + // Main MCP endpoint - handles all MCP communication + app.all("/mcp", async (req: Request, res: Response) => { + try { + await transport.handleRequest(req, res); + } catch (error: any) { + // Only send error if response hasn't been sent + if (!res.headersSent) { + res.status(500).json({ + error: "Internal server error", + message: error.message, + }); + } + } + }); + + // Legacy SSE endpoint - redirect to new endpoint with helpful message + app.get("/sse", (_req: Request, res: Response) => { + res.status(410).json({ + error: "Endpoint deprecated", + message: "The /sse endpoint has been replaced by /mcp. Please update your MCP client configuration.", + newEndpoint: "/mcp", + }); + }); + + // Start the server + const httpServer: Server = await new Promise((resolve, reject) => { + const serverInstance = app.listen(port, () => resolve(serverInstance)); + serverInstance.on("error", reject); + }); + + const address = httpServer.address(); + const actualPort = typeof address === "object" && address ? (address as AddressInfo).port : port; + + console.log(`IcePanel MCP Server (Streamable HTTP) listening on http://localhost:${actualPort}`); + console.log(` MCP endpoint: http://localhost:${actualPort}/mcp`); + console.log(` Health check: http://localhost:${actualPort}/health`); + + // Graceful shutdown + const shutdown = () => { + console.log("\nShutting down HTTP server..."); + transport.close(); + httpServer.close(() => { + console.log("HTTP server closed"); + process.exit(0); + }); + }; + + if (enableShutdownHandlers) { + process.on("SIGINT", shutdown); + process.on("SIGTERM", shutdown); + } + + const close = (): Promise => + new Promise((resolve, reject) => { + transport.close(); + httpServer.close((error) => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + + return { server: httpServer, port: actualPort, close }; +} diff --git a/src/types.ts b/src/types.ts index d5cb729..ea36e59 100644 --- a/src/types.ts +++ b/src/types.ts @@ -17,12 +17,12 @@ export interface ModelObject { links: Record; name: string; parentId: string; - status: 'deprecated'; + status: 'deprecated' | 'future' | 'live' | 'removed'; tagIds: string[]; teamIds: string[]; teamOnlyEditing: boolean; technologyIds: string[]; - type: 'actor'; + type: 'actor' | 'app' | 'component' | 'group' | 'root' | 'store' | 'system'; domainId: string; handleId: string; childDiagramIds: string[]; @@ -96,25 +96,6 @@ export interface CatalogTechnologyResponse { catalogTechnologies: CatalogTechnology[]; } -export interface Team { - color: string; - name: string; - userIds: string[]; - createdAt: string; - createdBy: string; - createdById: string; - id: string; - modelObjectHandleIds: string[]; - organizationId: string; - updatedAt: string; - updatedBy: string; - updatedById: string; -} - -export interface TeamsResponse { - teams: Team[]; -} - export interface ModelConnectionDirection { direction: 'outgoing' | 'bidirectional' | null; } @@ -163,3 +144,145 @@ export interface ModelConnection { export interface ModelConnectionsResponse { modelConnections: ModelConnection[]; } + +export interface ModelConnectionResponse { + modelConnection: ModelConnection; +} + +// ============================================================================ +// Write Operation Types +// ============================================================================ + +/** + * Request body for creating a model object + */ +export interface CreateModelObjectRequest { + name: string; + parentId: string; + type: 'actor' | 'app' | 'component' | 'group' | 'store' | 'system'; + caption?: string; + description?: string; + external?: boolean; + status?: 'deprecated' | 'future' | 'live' | 'removed'; + groupIds?: string[]; + labels?: Record; + links?: Record; + tagIds?: string[]; + technologyIds?: string[]; + domainId?: string; + handleId?: string; +} + +/** + * Request body for updating a model object (all fields optional) + */ +export interface UpdateModelObjectRequest { + name?: string; + parentId?: string | null; + type?: 'actor' | 'app' | 'component' | 'group' | 'store' | 'system'; + caption?: string; + description?: string; + external?: boolean; + status?: 'deprecated' | 'future' | 'live' | 'removed'; + groupIds?: string[]; + labels?: Record; + links?: Record; + tagIds?: string[]; + technologyIds?: string[]; +} + +/** + * Request body for creating a model connection + */ +export interface CreateConnectionRequest { + name: string; + originId: string; + targetId: string; + direction: 'outgoing' | 'bidirectional' | null; + description?: string; + status?: 'deprecated' | 'future' | 'live' | 'removed'; + labels?: Record; + tagIds?: string[]; + technologyIds?: string[]; +} + +/** + * Request body for updating a model connection + */ +export interface UpdateConnectionRequest { + name?: string; + direction?: 'outgoing' | 'bidirectional' | null; + description?: string; + status?: 'deprecated' | 'future' | 'live' | 'removed'; + labels?: Record; + tagIds?: string[]; + technologyIds?: string[]; +} + +/** + * Tag entity + */ +export interface Tag { + id: string; + name: string; + color?: string; + landscapeId: string; + tagGroupId?: string; +} + +/** + * Request body for creating a tag + */ +export interface CreateTagRequest { + name: string; + color?: string; + groupId: string; +} + +/** + * Request body for updating a tag + */ +export interface UpdateTagRequest { + name?: string; + color?: string; +} + +/** + * Response for single tag + */ +export interface TagResponse { + tag: Tag; +} + +/** + * Domain entity + */ +export interface Domain { + id: string; + name: string; + color?: string; + landscapeId: string; +} + +/** + * Request body for creating a domain + */ +export interface CreateDomainRequest { + name: string; + color?: string; +} + +/** + * Request body for updating a domain + */ +export interface UpdateDomainRequest { + name?: string; + color?: string; +} + +/** + * Response for single domain + */ +export interface DomainResponse { + domain: Domain; +} diff --git a/tests/helpers/mcp.ts b/tests/helpers/mcp.ts new file mode 100644 index 0000000..63c3072 --- /dev/null +++ b/tests/helpers/mcp.ts @@ -0,0 +1,142 @@ +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { startHttpServer } from "../../src/transports/http-server.js"; +import { registerAllTools } from "../../src/tools/index.js"; + +type McpResult = { + content: { type: string; text?: string }[]; + structuredContent?: unknown; + isError?: boolean; +}; + +type McpResponse = { + jsonrpc: string; + id: number | string | null; + result?: McpResult; + error?: { code: number; message: string; data?: unknown }; +}; + +export async function callTool(baseUrl: string, name: string, args: Record) { + const response = await fetch(`${baseUrl}/mcp`, { + method: "POST", + headers: { + "content-type": "application/json", + accept: "application/json, text/event-stream", + }, + body: JSON.stringify({ + jsonrpc: "2.0", + id: 1, + method: "tools/call", + params: { name, arguments: args }, + }), + }); + + const payload = (await response.json()) as McpResponse; + + if (payload.error) { + throw new Error(`MCP error ${payload.error.code}: ${payload.error.message}`); + } + if (!payload.result) { + throw new Error("Missing MCP result"); + } + + if (payload.result.isError) { + const message = payload.result.content?.[0]?.text || "Unknown MCP tool error"; + throw new Error(message); + } + + return payload.result; +} + +function ensureTestEnv() { + if (process.env.ICEPANEL_MCP_API_KEY) { + process.env.API_KEY = process.env.ICEPANEL_MCP_API_KEY; + } + if (process.env.ICEPANEL_MCP_ORGANIZATION_ID) { + process.env.ORGANIZATION_ID = process.env.ICEPANEL_MCP_ORGANIZATION_ID; + } + + if (!process.env.API_KEY) { + throw new Error("Missing API_KEY (or ICEPANEL_MCP_API_KEY) for integration tests"); + } + if (!process.env.ORGANIZATION_ID) { + throw new Error("Missing ORGANIZATION_ID (or ICEPANEL_MCP_ORGANIZATION_ID) for integration tests"); + } +} + +export async function startTestServer(organizationId: string) { + ensureTestEnv(); + const server = new McpServer({ name: "icepanel-mcp-server-test", version: "test" }); + registerAllTools(server, organizationId); + + const started = await startHttpServer(server, 0, { enableShutdownHandlers: false }); + const baseUrl = `http://localhost:${started.port}`; + + return { + baseUrl, + close: started.close, + }; +} + +function normalizeLandscapeName(name: string): string { + return name.trim().toLowerCase().replace(/[’]/g, "'"); +} + +export async function resolveLandscapeId(baseUrl: string, landscapeName: string): Promise { + if (process.env.ICEPANEL_MCP_TEST_LANDSCAPE_ID) { + return process.env.ICEPANEL_MCP_TEST_LANDSCAPE_ID; + } + + const listResult = await callTool(baseUrl, "icepanel_list_landscapes", { + response_format: "json", + limit: 100, + offset: 0, + }); + const structured = listResult.structuredContent as { items?: { id?: string; name?: string }[] } | undefined; + if (!structured?.items) { + const text = listResult.content?.[0]?.text || "No content returned"; + throw new Error(`List landscapes failed: ${text}`); + } + const target = normalizeLandscapeName(landscapeName); + const match = structured?.items?.find((item) => normalizeLandscapeName(item.name || "") === target); + if (!match?.id) { + const names = structured?.items?.map((item) => item.name).filter(Boolean).join(", ") || "none"; + throw new Error(`Landscape "${landscapeName}" not found. Available: ${names}`); + } + return match.id; +} + +export function normalizeNameForTest(name: string): string { + return normalizeLandscapeName(name); +} + +export async function getModelObjectIds( + baseUrl: string, + landscapeId: string, + limit: number = 2 +): Promise { + const modelObjectsResult = await callTool(baseUrl, "icepanel_list_model_objects", { + response_format: "json", + landscapeId, + limit, + offset: 0, + }); + const structured = modelObjectsResult.structuredContent as { items?: { id?: string }[] } | undefined; + return (structured?.items || []) + .map((item) => item.id) + .filter((id): id is string => Boolean(id)); +} + +export async function listModelObjects( + baseUrl: string, + landscapeId: string, + limit: number = 50 +): Promise<{ id?: string; type?: string }[]> { + const modelObjectsResult = await callTool(baseUrl, "icepanel_list_model_objects", { + response_format: "json", + landscapeId, + limit, + offset: 0, + }); + const structured = modelObjectsResult.structuredContent as { items?: { id?: string; type?: string }[] } | undefined; + return structured?.items || []; +} diff --git a/tests/read-tools.int.test.ts b/tests/read-tools.int.test.ts new file mode 100644 index 0000000..94cffa4 --- /dev/null +++ b/tests/read-tools.int.test.ts @@ -0,0 +1,126 @@ +import { beforeAll, afterAll, describe, expect, test } from "vitest"; +import { + callTool, + getModelObjectIds, + normalizeNameForTest, + resolveLandscapeId, + startTestServer, +} from "./helpers/mcp.js"; + +const hasCredentials = Boolean( + (process.env.API_KEY || process.env.ICEPANEL_MCP_API_KEY) && + (process.env.ORGANIZATION_ID || process.env.ICEPANEL_MCP_ORGANIZATION_ID) +); +const TARGET_LANDSCAPE_NAME = process.env.ICEPANEL_MCP_TEST_LANDSCAPE_NAME || "Alex's landscape"; + +const integrationDescribe = hasCredentials ? describe : describe.skip; + +integrationDescribe("MCP read tools (integration)", () => { + let baseUrl = ""; + let closeServer: (() => Promise) | null = null; + let landscapeId: string | null = null; + let modelObjectId: string | null = null; + + beforeAll(async () => { + const organizationId = + process.env.ORGANIZATION_ID || (process.env.ICEPANEL_MCP_ORGANIZATION_ID as string); + const started = await startTestServer(organizationId); + baseUrl = started.baseUrl; + closeServer = started.close; + + landscapeId = await resolveLandscapeId(baseUrl, TARGET_LANDSCAPE_NAME); + const modelObjectIds = await getModelObjectIds(baseUrl, landscapeId, 1); + modelObjectId = modelObjectIds[0] ?? null; + }); + + afterAll(async () => { + if (closeServer) { + await closeServer(); + } + }); + + test("list landscapes", async () => { + const result = await callTool(baseUrl, "icepanel_list_landscapes", { + response_format: "json", + limit: 10, + offset: 0, + }); + const structured = result.structuredContent as { items?: { name?: string }[] } | undefined; + const names = (structured?.items || []) + .map((item) => item.name) + .filter(Boolean) + .map((name) => normalizeNameForTest(name as string)); + expect(names).toContain(normalizeNameForTest(TARGET_LANDSCAPE_NAME)); + }); + + test("get landscape", async () => { + if (!landscapeId) { + return; + } + const result = await callTool(baseUrl, "icepanel_get_landscape", { + response_format: "json", + landscapeId, + }); + const structured = result.structuredContent as { id?: string; landscape?: { id?: string } } | undefined; + const resolvedId = structured?.id ?? structured?.landscape?.id; + expect(resolvedId).toBe(landscapeId); + }); + + test("list model objects", async () => { + if (!landscapeId) { + return; + } + const result = await callTool(baseUrl, "icepanel_list_model_objects", { + response_format: "json", + landscapeId, + limit: 10, + offset: 0, + }); + const structured = result.structuredContent as { items?: unknown[] } | undefined; + expect(structured?.items).toBeDefined(); + }); + + test("get model object", async () => { + if (!landscapeId || !modelObjectId) { + return; + } + const result = await callTool(baseUrl, "icepanel_get_model_object", { + response_format: "json", + landscapeId, + modelObjectId, + includeHierarchicalInfo: false, + }); + const structured = result.structuredContent as { modelObject?: { id?: string } } | undefined; + expect(structured?.modelObject?.id).toBe(modelObjectId); + }); + + test("get model object connections", async () => { + if (!landscapeId || !modelObjectId) { + return; + } + const result = await callTool(baseUrl, "icepanel_get_model_object_connections", { + response_format: "json", + landscapeId, + modelObjectId, + }); + const structured = result.structuredContent as { + modelObject?: { id?: string }; + incomingConnections?: unknown[]; + outgoingConnections?: unknown[]; + } | undefined; + expect(structured?.modelObject?.id).toBe(modelObjectId); + expect(structured?.incomingConnections).toBeDefined(); + expect(structured?.outgoingConnections).toBeDefined(); + }); + + test("list technologies", async () => { + const result = await callTool(baseUrl, "icepanel_list_technologies", { + response_format: "json", + limit: 5, + offset: 0, + }); + const structured = result.structuredContent as { items?: unknown[] } | undefined; + expect(structured?.items).toBeDefined(); + }); + +}); diff --git a/tests/setup.ts b/tests/setup.ts new file mode 100644 index 0000000..97fbeb5 --- /dev/null +++ b/tests/setup.ts @@ -0,0 +1,7 @@ +if (process.env.ICEPANEL_MCP_API_KEY) { + process.env.API_KEY = process.env.ICEPANEL_MCP_API_KEY; +} + +if (process.env.ICEPANEL_MCP_ORGANIZATION_ID) { + process.env.ORGANIZATION_ID = process.env.ICEPANEL_MCP_ORGANIZATION_ID; +} diff --git a/tests/write-tools.int.test.ts b/tests/write-tools.int.test.ts new file mode 100644 index 0000000..bb3a078 --- /dev/null +++ b/tests/write-tools.int.test.ts @@ -0,0 +1,240 @@ +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { + callTool, + listModelObjects, + resolveLandscapeId, + startTestServer, +} from "./helpers/mcp.js"; + +const hasCredentials = Boolean( + (process.env.API_KEY || process.env.ICEPANEL_MCP_API_KEY) && + (process.env.ORGANIZATION_ID || process.env.ICEPANEL_MCP_ORGANIZATION_ID) +); +const TARGET_LANDSCAPE_NAME = process.env.ICEPANEL_MCP_TEST_LANDSCAPE_NAME || "Alex's landscape"; + +const organizationId = + process.env.ORGANIZATION_ID || (process.env.ICEPANEL_MCP_ORGANIZATION_ID as string); + +async function detectWriteAccess(): Promise { + const started = await startTestServer(organizationId); + try { + const landscapeId = await resolveLandscapeId(started.baseUrl, TARGET_LANDSCAPE_NAME); + const probeName = `Test MCP Write Probe ${Date.now()}`; + const createResult = await callTool(started.baseUrl, "icepanel_create_domain", { + landscapeId, + name: probeName, + color: "grey", + }); + const domainId = (createResult.structuredContent as { domain?: { id?: string } } | undefined)?.domain + ?.id; + + if (domainId) { + await callTool(started.baseUrl, "icepanel_delete_domain", { landscapeId, domainId }); + } + return true; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + if (message.includes("Authentication failed") || message.includes("Permission denied")) { + return false; + } + throw error; + } finally { + await started.close(); + } +} + +const writeEnabled = hasCredentials ? await detectWriteAccess() : false; +const integrationDescribe = writeEnabled ? describe.sequential : describe.skip; + +integrationDescribe("MCP write tools (integration)", () => { + let baseUrl = ""; + let closeServer: (() => Promise) | null = null; + let landscapeId = ""; + let cleanupConnectionId: string | null = null; + let cleanupModelObjectIds: string[] = []; + let cleanupTagId: string | null = null; + let cleanupDomainId: string | null = null; + + const suffix = `mcp-${Date.now()}`; + const tagGroupId = process.env.ICEPANEL_MCP_TAG_GROUP_ID; + + beforeAll(async () => { + const started = await startTestServer(organizationId); + baseUrl = started.baseUrl; + closeServer = started.close; + landscapeId = await resolveLandscapeId(baseUrl, TARGET_LANDSCAPE_NAME); + }); + + afterAll(async () => { + if (cleanupConnectionId) { + await callTool(baseUrl, "icepanel_delete_connection", { + landscapeId, + connectionId: cleanupConnectionId, + }); + } + + for (const modelObjectId of cleanupModelObjectIds) { + await callTool(baseUrl, "icepanel_delete_model_object", { + landscapeId, + modelObjectId, + }); + } + + if (cleanupTagId) { + await callTool(baseUrl, "icepanel_delete_tag", { landscapeId, tagId: cleanupTagId }); + } + + if (cleanupDomainId) { + await callTool(baseUrl, "icepanel_delete_domain", { landscapeId, domainId: cleanupDomainId }); + } + + + if (closeServer) { + await closeServer(); + } + }); + + + const tagTest = tagGroupId ? test : test.skip; + + tagTest("create/read/delete tag", async () => { + const createResult = await callTool(baseUrl, "icepanel_create_tag", { + landscapeId, + name: `Test MCP Tag ${suffix}`, + groupId: tagGroupId as string, + color: "green", + }); + const tag = createResult.structuredContent as { tag?: { id?: string; name?: string } } | undefined; + cleanupTagId = tag?.tag?.id ?? null; + expect(cleanupTagId).toBeTruthy(); + + await callTool(baseUrl, "icepanel_delete_tag", { landscapeId, tagId: cleanupTagId }); + cleanupTagId = null; + }); + + test("create/read/delete domain", async () => { + const createResult = await callTool(baseUrl, "icepanel_create_domain", { + landscapeId, + name: `Test MCP Domain ${suffix}`, + color: "purple", + }); + const domain = createResult.structuredContent as { + domain?: { id?: string; name?: string }; + } | undefined; + cleanupDomainId = domain?.domain?.id ?? null; + expect(cleanupDomainId).toBeTruthy(); + + await callTool(baseUrl, "icepanel_delete_domain", { landscapeId, domainId: cleanupDomainId }); + cleanupDomainId = null; + }); + + test("create/read/delete model object", async () => { + const existingObjects = await listModelObjects(baseUrl, landscapeId, 50); + const parent = existingObjects.find((obj) => obj.type === "app"); + const parentId = parent?.id; + if (!parentId) { + throw new Error("No app model objects found to use as parentId"); + } + + const createResult = await callTool(baseUrl, "icepanel_create_model_object", { + landscapeId, + name: `Test MCP Object ${suffix}`, + type: "component", + parentId, + status: "live", + external: false, + }); + const modelObject = createResult.structuredContent as { + modelObject?: { id?: string; name?: string }; + } | undefined; + const modelObjectId = modelObject?.modelObject?.id ?? null; + expect(modelObjectId).toBeTruthy(); + if (modelObjectId) { + cleanupModelObjectIds.push(modelObjectId); + } + + const getResult = await callTool(baseUrl, "icepanel_get_model_object", { + response_format: "json", + landscapeId, + modelObjectId, + includeHierarchicalInfo: false, + }); + const getStructured = getResult.structuredContent as { modelObject?: { id?: string } } | undefined; + expect(getStructured?.modelObject?.id).toBe(modelObjectId); + + await callTool(baseUrl, "icepanel_delete_model_object", { landscapeId, modelObjectId }); + cleanupModelObjectIds = cleanupModelObjectIds.filter((id) => id !== modelObjectId); + }); + + test("create/read/delete connection", async () => { + const existingObjects = await listModelObjects(baseUrl, landscapeId, 50); + const parent = existingObjects.find((obj) => obj.type === "app"); + const parentId = parent?.id; + if (!parentId) { + throw new Error("No app model objects found to use as parentId"); + } + + const createOrigin = await callTool(baseUrl, "icepanel_create_model_object", { + landscapeId, + name: `Test MCP Origin ${suffix}`, + type: "component", + parentId, + status: "live", + external: false, + }); + const createTarget = await callTool(baseUrl, "icepanel_create_model_object", { + landscapeId, + name: `Test MCP Target ${suffix}`, + type: "component", + parentId, + status: "live", + external: false, + }); + + const originId = (createOrigin.structuredContent as { modelObject?: { id?: string } } | undefined)?.modelObject + ?.id; + const targetId = (createTarget.structuredContent as { modelObject?: { id?: string } } | undefined)?.modelObject + ?.id; + + if (!originId || !targetId) { + throw new Error("Failed to create model objects for connection test"); + } + + cleanupModelObjectIds.push(originId, targetId); + + const createConnectionResult = await callTool(baseUrl, "icepanel_create_connection", { + landscapeId, + name: `Test MCP Connection ${suffix}`, + originId, + targetId, + direction: "outgoing", + status: "live", + }); + const connection = createConnectionResult.structuredContent as { + connection?: { id?: string }; + } | undefined; + cleanupConnectionId = connection?.connection?.id ?? null; + expect(cleanupConnectionId).toBeTruthy(); + + const getConnections = await callTool(baseUrl, "icepanel_get_model_object_connections", { + response_format: "json", + landscapeId, + modelObjectId: originId, + }); + const structured = getConnections.structuredContent as { + outgoingConnections?: { id?: string }[]; + } | undefined; + const connectionIds = structured?.outgoingConnections?.map((c) => c.id) || []; + expect(connectionIds).toContain(cleanupConnectionId); + + await callTool(baseUrl, "icepanel_delete_connection", { + landscapeId, + connectionId: cleanupConnectionId, + }); + cleanupConnectionId = null; + + await callTool(baseUrl, "icepanel_delete_model_object", { landscapeId, modelObjectId: originId }); + await callTool(baseUrl, "icepanel_delete_model_object", { landscapeId, modelObjectId: targetId }); + cleanupModelObjectIds = cleanupModelObjectIds.filter((id) => id !== originId && id !== targetId); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..20e56b9 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + environment: "node", + testTimeout: 30000, + setupFiles: ["./tests/setup.ts"], + }, +}); From 49f3fd479211960760d7b7cddf2aa2db47e61182 Mon Sep 17 00:00:00 2001 From: AlexanderNZ Date: Thu, 29 Jan 2026 17:07:14 +1300 Subject: [PATCH 2/2] remove hardcoded test values --- README.md | 14 ++++++-- bin/icepanel-mcp-server.js | 45 +++++++++++++++++------ src/cli/config.ts | 57 ++++++++++++++++++++++++++++++ src/index.ts | 6 ++-- src/tools/model-objects.ts | 4 +-- tests/cli-config.test.ts | 32 +++++++++++++++++ tests/helpers/mcp.ts | 3 ++ tests/model-objects-schema.test.ts | 27 ++++++++++++++ tests/read-tools.int.test.ts | 10 ++++-- tests/write-tools.int.test.ts | 17 +++++++-- 10 files changed, 192 insertions(+), 23 deletions(-) create mode 100644 src/cli/config.ts create mode 100644 tests/cli-config.test.ts create mode 100644 tests/model-objects-schema.test.ts diff --git a/README.md b/README.md index f6b8eac..d57a820 100644 --- a/README.md +++ b/README.md @@ -37,6 +37,8 @@ Use `command` + `args` to launch the server locally (shown above). For MCP clients that support HTTP transport: +This mode is intended for localhost-only usage on your machine. If you choose to expose it beyond localhost, you must secure it yourself (for example, with a reverse proxy, authentication, and network controls). + ```json { "mcpServers": { @@ -91,6 +93,12 @@ All tools follow the `icepanel_*` naming convention and return structured output - `MCP_TRANSPORT`: `stdio` (default) or `http` - `MCP_PORT`: HTTP port for Streamable HTTP transport (default: 3000) +### Transport configuration precedence + +- `--transport` / `--port` CLI flags override `MCP_TRANSPORT` / `MCP_PORT` +- `MCP_TRANSPORT` / `MCP_PORT` are honored when running `src/index.ts` directly +- If using the CLI wrapper, those values are forwarded to the server automatically + ## How to Run Integration Tests Use this guide to run the live integration tests against your IcePanel org. @@ -98,7 +106,7 @@ Use this guide to run the live integration tests against your IcePanel org. ### Prerequisites - A valid IcePanel API key -- A landscape in your org (for example, `Alex's landscape`) +- A target landscape name or ID provided via test environment variables ### Steps @@ -126,7 +134,7 @@ export ICEPANEL_MCP_TAG_GROUP_ID="your-tag-group-id" 4. Run the suite: ```bash -pnpm test +ICEPANEL_MCP_TEST_LANDSCAPE_NAME="your-landscape-name" pnpm test ``` ### Notes @@ -198,7 +206,7 @@ Add this to your MCP Clients' MCP config file: For standalone HTTP server mode, use the `--transport http` flag: ```bash -docker run -d -p 9846:9846 \ +docker run -d -p 127.0.0.1:9846:9846 \ -e API_KEY="your-api-key" \ -e ORGANIZATION_ID="your-org-id" \ icepanel-mcp-server --transport http --port 9846 diff --git a/bin/icepanel-mcp-server.js b/bin/icepanel-mcp-server.js index 0739df1..b0a4bd3 100755 --- a/bin/icepanel-mcp-server.js +++ b/bin/icepanel-mcp-server.js @@ -4,19 +4,44 @@ * IcePanel MCP Server * * Environment variables: - * - API_KEY: Your IcePanel API key - * - ORGANIZATION_ID: Your IcePanel organization ID + * - API_KEY: Your IcePanel API key (required) + * - ORGANIZATION_ID: Your IcePanel organization ID (required) * - ICEPANEL_API_BASE_URL: (Optional) Override the API base URL for different environments + * - MCP_TRANSPORT: Transport type: 'stdio' (default) or 'http' + * - MCP_PORT: HTTP server port for HTTP transport (default: 3000) + * + * CLI flags: + * - --transport : Transport type (overrides MCP_TRANSPORT) + * - --port : HTTP port for HTTP transport (overrides MCP_PORT) */ -// Parse any environment variables passed as arguments -process.argv.slice(2).forEach(arg => { - const match = arg.match(/^([^=]+)=(.*)$/); - if (match) { - const [, key, value] = match; - process.env[key] = value.replace(/^["'](.*)["']$/, '$1'); // Remove quotes if present - } -}); +import { parseCliConfig } from "../dist/cli/config.js"; + +// Parse command line arguments +const args = process.argv.slice(2); +const { transport, port, portRaw, updatedEnv, usedDeprecatedSse } = parseCliConfig(args, process.env); +Object.assign(process.env, updatedEnv); + +// Support legacy 'sse' transport name (map to 'http') +if (usedDeprecatedSse) { + console.warn("Warning: 'sse' transport is deprecated. Using 'http' (Streamable HTTP) instead."); +} + +// Validate transport +if (!['stdio', 'http'].includes(transport)) { + console.error(`Invalid transport: ${transport}. Must be 'stdio' or 'http'.`); + process.exit(1); +} + +// Validate port +if (isNaN(port) || port < 1 || port > 65535) { + console.error(`Invalid port: ${portRaw}. Must be a number between 1 and 65535.`); + process.exit(1); +} + +// Store config for main module +process.env._MCP_TRANSPORT = transport; +process.env._MCP_PORT = String(port); import('../dist/index.js').catch(err => { console.error('Failed to start IcePanel MCP Server:', err); diff --git a/src/cli/config.ts b/src/cli/config.ts new file mode 100644 index 0000000..699f5dc --- /dev/null +++ b/src/cli/config.ts @@ -0,0 +1,57 @@ +export type CliConfig = { + transport: string; + port: number; + portRaw: string; + updatedEnv: NodeJS.ProcessEnv; + usedDeprecatedSse: boolean; +}; + +function stripOuterQuotes(value: string): string { + return value.replace(/^["'](.*)["']$/, "$1"); +} + +export function parseCliConfig(args: string[], env: NodeJS.ProcessEnv): CliConfig { + const updatedEnv: NodeJS.ProcessEnv = { ...env }; + + for (const arg of args) { + const match = arg.match(/^([^=]+)=(.*)$/); + if (match) { + const [, key, value] = match; + updatedEnv[key] = stripOuterQuotes(value); + } + } + + let transport = updatedEnv.MCP_TRANSPORT || "stdio"; + let portRaw = updatedEnv.MCP_PORT || "3000"; + + for (let i = 0; i < args.length; i++) { + const arg = args[i]; + + if (arg === "--transport" && args[i + 1]) { + transport = args[i + 1]; + i++; + continue; + } + + if (arg === "--port" && args[i + 1]) { + portRaw = args[i + 1]; + i++; + } + } + + let usedDeprecatedSse = false; + if (transport === "sse") { + usedDeprecatedSse = true; + transport = "http"; + } + + const port = Number.parseInt(portRaw, 10); + + return { + transport, + port, + portRaw, + updatedEnv, + usedDeprecatedSse, + }; +} diff --git a/src/index.ts b/src/index.ts index 3ad7cf8..420cad0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,9 +25,9 @@ const server = new McpServer({ registerAllTools(server, ORGANIZATION_ID); -// Get transport configuration from CLI (set by bin/icepanel-mcp-server.js) -const transportType = process.env._MCP_TRANSPORT || "stdio"; -const portRaw = process.env._MCP_PORT || "3000"; +// Get transport configuration from CLI (set by bin/icepanel-mcp-server.js) or env +const transportType = process.env._MCP_TRANSPORT || process.env.MCP_TRANSPORT || "stdio"; +const portRaw = process.env._MCP_PORT || process.env.MCP_PORT || "3000"; const port = Number.parseInt(portRaw, 10); if (!Number.isInteger(port) || port < 1 || port > 65535) { diff --git a/src/tools/model-objects.ts b/src/tools/model-objects.ts index f69fee6..b3dc656 100644 --- a/src/tools/model-objects.ts +++ b/src/tools/model-objects.ts @@ -27,11 +27,11 @@ import { applyCharacterLimit, formatOutput, paginateArray } from "./utils.js"; const IdOrIdsSchema = z.union([IcePanelIdSchema, z.array(IcePanelIdSchema)]); const MutableModelObjectTypeSchema = z.enum(["actor", "app", "component", "group", "store", "system"]); -const ListModelObjectsSchema = z +export const ListModelObjectsSchema = z .object({ landscapeId: IcePanelIdSchema, domainId: IdOrIdsSchema.optional(), - external: z.boolean().optional().default(false), + external: z.boolean().optional(), name: z.string().optional(), parentId: z.string().nullable().optional(), status: z.union([StatusSchema, z.array(StatusSchema)]).optional(), diff --git a/tests/cli-config.test.ts b/tests/cli-config.test.ts new file mode 100644 index 0000000..2864ad2 --- /dev/null +++ b/tests/cli-config.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "vitest"; +import { parseCliConfig } from "../src/cli/config.js"; + +describe("parseCliConfig", () => { + test("uses MCP env defaults when no args", () => { + const env = { MCP_TRANSPORT: "http", MCP_PORT: "8123" }; + const result = parseCliConfig([], env); + expect(result.transport).toBe("http"); + expect(result.port).toBe(8123); + }); + + test("applies KEY=value args before defaults", () => { + const result = parseCliConfig(["MCP_TRANSPORT=http", "MCP_PORT=9123"], {}); + expect(result.transport).toBe("http"); + expect(result.port).toBe(9123); + }); + + test("flags override MCP env and KEY=value args", () => { + const result = parseCliConfig( + ["MCP_TRANSPORT=stdio", "--transport", "http", "MCP_PORT=3001", "--port", "4000"], + { MCP_TRANSPORT: "stdio", MCP_PORT: "3000" } + ); + expect(result.transport).toBe("http"); + expect(result.port).toBe(4000); + }); + + test("maps deprecated sse transport to http", () => { + const result = parseCliConfig(["MCP_TRANSPORT=sse"], {}); + expect(result.transport).toBe("http"); + expect(result.usedDeprecatedSse).toBe(true); + }); +}); diff --git a/tests/helpers/mcp.ts b/tests/helpers/mcp.ts index 63c3072..46c7b6b 100644 --- a/tests/helpers/mcp.ts +++ b/tests/helpers/mcp.ts @@ -85,6 +85,9 @@ export async function resolveLandscapeId(baseUrl: string, landscapeName: string) if (process.env.ICEPANEL_MCP_TEST_LANDSCAPE_ID) { return process.env.ICEPANEL_MCP_TEST_LANDSCAPE_ID; } + if (!landscapeName.trim()) { + throw new Error("Set ICEPANEL_MCP_TEST_LANDSCAPE_NAME or ICEPANEL_MCP_TEST_LANDSCAPE_ID"); + } const listResult = await callTool(baseUrl, "icepanel_list_landscapes", { response_format: "json", diff --git a/tests/model-objects-schema.test.ts b/tests/model-objects-schema.test.ts new file mode 100644 index 0000000..ab82970 --- /dev/null +++ b/tests/model-objects-schema.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, test } from "vitest"; +import { ListModelObjectsSchema } from "../src/tools/model-objects.js"; + +describe("ListModelObjectsSchema", () => { + test("does not default external filter", () => { + const parsed = ListModelObjectsSchema.parse({ + landscapeId: "abcdefghijklmnopqrst", + limit: 10, + offset: 0, + response_format: "json", + }); + + expect("external" in parsed).toBe(false); + }); + + test("keeps explicit external filter", () => { + const parsed = ListModelObjectsSchema.parse({ + landscapeId: "abcdefghijklmnopqrst", + limit: 10, + offset: 0, + response_format: "json", + external: true, + }); + + expect(parsed.external).toBe(true); + }); +}); diff --git a/tests/read-tools.int.test.ts b/tests/read-tools.int.test.ts index 94cffa4..0611cc9 100644 --- a/tests/read-tools.int.test.ts +++ b/tests/read-tools.int.test.ts @@ -11,7 +11,8 @@ const hasCredentials = Boolean( (process.env.API_KEY || process.env.ICEPANEL_MCP_API_KEY) && (process.env.ORGANIZATION_ID || process.env.ICEPANEL_MCP_ORGANIZATION_ID) ); -const TARGET_LANDSCAPE_NAME = process.env.ICEPANEL_MCP_TEST_LANDSCAPE_NAME || "Alex's landscape"; +const TARGET_LANDSCAPE_NAME = process.env.ICEPANEL_MCP_TEST_LANDSCAPE_NAME; +const TARGET_LANDSCAPE_ID = process.env.ICEPANEL_MCP_TEST_LANDSCAPE_ID; const integrationDescribe = hasCredentials ? describe : describe.skip; @@ -22,13 +23,18 @@ integrationDescribe("MCP read tools (integration)", () => { let modelObjectId: string | null = null; beforeAll(async () => { + if (!TARGET_LANDSCAPE_NAME && !TARGET_LANDSCAPE_ID) { + throw new Error( + "Set ICEPANEL_MCP_TEST_LANDSCAPE_NAME or ICEPANEL_MCP_TEST_LANDSCAPE_ID for integration tests" + ); + } const organizationId = process.env.ORGANIZATION_ID || (process.env.ICEPANEL_MCP_ORGANIZATION_ID as string); const started = await startTestServer(organizationId); baseUrl = started.baseUrl; closeServer = started.close; - landscapeId = await resolveLandscapeId(baseUrl, TARGET_LANDSCAPE_NAME); + landscapeId = await resolveLandscapeId(baseUrl, TARGET_LANDSCAPE_NAME || ""); const modelObjectIds = await getModelObjectIds(baseUrl, landscapeId, 1); modelObjectId = modelObjectIds[0] ?? null; }); diff --git a/tests/write-tools.int.test.ts b/tests/write-tools.int.test.ts index bb3a078..8ab71fb 100644 --- a/tests/write-tools.int.test.ts +++ b/tests/write-tools.int.test.ts @@ -10,7 +10,8 @@ const hasCredentials = Boolean( (process.env.API_KEY || process.env.ICEPANEL_MCP_API_KEY) && (process.env.ORGANIZATION_ID || process.env.ICEPANEL_MCP_ORGANIZATION_ID) ); -const TARGET_LANDSCAPE_NAME = process.env.ICEPANEL_MCP_TEST_LANDSCAPE_NAME || "Alex's landscape"; +const TARGET_LANDSCAPE_NAME = process.env.ICEPANEL_MCP_TEST_LANDSCAPE_NAME; +const TARGET_LANDSCAPE_ID = process.env.ICEPANEL_MCP_TEST_LANDSCAPE_ID; const organizationId = process.env.ORGANIZATION_ID || (process.env.ICEPANEL_MCP_ORGANIZATION_ID as string); @@ -18,7 +19,12 @@ const organizationId = async function detectWriteAccess(): Promise { const started = await startTestServer(organizationId); try { - const landscapeId = await resolveLandscapeId(started.baseUrl, TARGET_LANDSCAPE_NAME); + if (!TARGET_LANDSCAPE_NAME && !TARGET_LANDSCAPE_ID) { + throw new Error( + "Set ICEPANEL_MCP_TEST_LANDSCAPE_NAME or ICEPANEL_MCP_TEST_LANDSCAPE_ID for integration tests" + ); + } + const landscapeId = await resolveLandscapeId(started.baseUrl, TARGET_LANDSCAPE_NAME || ""); const probeName = `Test MCP Write Probe ${Date.now()}`; const createResult = await callTool(started.baseUrl, "icepanel_create_domain", { landscapeId, @@ -62,7 +68,12 @@ integrationDescribe("MCP write tools (integration)", () => { const started = await startTestServer(organizationId); baseUrl = started.baseUrl; closeServer = started.close; - landscapeId = await resolveLandscapeId(baseUrl, TARGET_LANDSCAPE_NAME); + if (!TARGET_LANDSCAPE_NAME && !TARGET_LANDSCAPE_ID) { + throw new Error( + "Set ICEPANEL_MCP_TEST_LANDSCAPE_NAME or ICEPANEL_MCP_TEST_LANDSCAPE_ID for integration tests" + ); + } + landscapeId = await resolveLandscapeId(baseUrl, TARGET_LANDSCAPE_NAME || ""); }); afterAll(async () => {