From 05358b8467eef87fd400527b1d0a9ef738b88939 Mon Sep 17 00:00:00 2001 From: AlexanderNZ Date: Thu, 29 Jan 2026 17:40:29 +1300 Subject: [PATCH] docs: document new read tools and tests --- README.md | 263 +++++++++++++++++++++--- tests/read-tools.int.test.ts | 386 +++++++++++++++++++++++++++++++++++ 2 files changed, 621 insertions(+), 28 deletions(-) create mode 100644 tests/read-tools.int.test.ts diff --git a/README.md b/README.md index 4380585..2ed5164 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,179 @@ 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: + +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": { + "@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_get_model_objects_csv` +- `icepanel_get_model_object_dependencies_json` +- `icepanel_list_connections` +- `icepanel_get_connection` +- `icepanel_get_model_connections_csv` +- `icepanel_list_tags` +- `icepanel_get_tag` +- `icepanel_list_tag_groups` +- `icepanel_get_tag_group` +- `icepanel_list_domains` +- `icepanel_get_domain` +- `icepanel_list_diagrams` +- `icepanel_get_diagram` +- `icepanel_list_diagram_thumbnails` +- `icepanel_get_diagram_thumbnail` +- `icepanel_list_flows` +- `icepanel_get_flow` +- `icepanel_list_flow_thumbnails` +- `icepanel_get_flow_thumbnail` +- `icepanel_get_flow_text` +- `icepanel_get_flow_code` +- `icepanel_get_flow_mermaid` +- `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) + +### 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. + +### Prerequisites + +- A valid IcePanel API key +- A target landscape name or ID provided via test environment variables + +### 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 +ICEPANEL_MCP_TEST_LANDSCAPE_NAME="your-landscape-name" 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 +188,22 @@ Please use MCP Servers with caution; only install tools you trust. - Cursor - Windsurf -### Installation - -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! - +### Build the Docker Image -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 +211,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 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 +``` + +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/tests/read-tools.int.test.ts b/tests/read-tools.int.test.ts new file mode 100644 index 0000000..1d9ea4e --- /dev/null +++ b/tests/read-tools.int.test.ts @@ -0,0 +1,386 @@ +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; +const TARGET_LANDSCAPE_ID = process.env.ICEPANEL_MCP_TEST_LANDSCAPE_ID; + +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 () => { + 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 || ""); + 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(); + }); + + test("list tags and get tag", async () => { + if (!landscapeId) { + return; + } + const listResult = await callTool(baseUrl, "icepanel_list_tags", { + response_format: "json", + landscapeId, + limit: 10, + offset: 0, + }); + const listStructured = listResult.structuredContent as { items?: { id?: string }[] } | undefined; + expect(listStructured?.items).toBeDefined(); + + const tagId = listStructured?.items?.[0]?.id; + if (!tagId) { + return; + } + const getResult = await callTool(baseUrl, "icepanel_get_tag", { + response_format: "json", + landscapeId, + tagId, + }); + const getStructured = getResult.structuredContent as { tag?: { id?: string } } | undefined; + expect(getStructured?.tag?.id).toBe(tagId); + }); + + test("list tag groups and get tag group", async () => { + if (!landscapeId) { + return; + } + const listResult = await callTool(baseUrl, "icepanel_list_tag_groups", { + response_format: "json", + landscapeId, + limit: 10, + offset: 0, + }); + const listStructured = listResult.structuredContent as { items?: { id?: string }[] } | undefined; + expect(listStructured?.items).toBeDefined(); + + const tagGroupId = listStructured?.items?.[0]?.id; + if (!tagGroupId) { + return; + } + const getResult = await callTool(baseUrl, "icepanel_get_tag_group", { + response_format: "json", + landscapeId, + tagGroupId, + }); + const getStructured = getResult.structuredContent as { tagGroup?: { id?: string } } | undefined; + expect(getStructured?.tagGroup?.id).toBe(tagGroupId); + }); + + test("list domains and get domain", async () => { + if (!landscapeId) { + return; + } + const listResult = await callTool(baseUrl, "icepanel_list_domains", { + response_format: "json", + landscapeId, + limit: 10, + offset: 0, + }); + const listStructured = listResult.structuredContent as { items?: { id?: string }[] } | undefined; + expect(listStructured?.items).toBeDefined(); + + const domainId = listStructured?.items?.[0]?.id; + if (!domainId) { + return; + } + const getResult = await callTool(baseUrl, "icepanel_get_domain", { + response_format: "json", + landscapeId, + domainId, + }); + const getStructured = getResult.structuredContent as { domain?: { id?: string } } | undefined; + expect(getStructured?.domain?.id).toBe(domainId); + }); + + test("list connections and get connection", async () => { + if (!landscapeId) { + return; + } + const listResult = await callTool(baseUrl, "icepanel_list_connections", { + response_format: "json", + landscapeId, + limit: 10, + offset: 0, + }); + const listStructured = listResult.structuredContent as { items?: { id?: string }[] } | undefined; + expect(listStructured?.items).toBeDefined(); + + const connectionId = listStructured?.items?.[0]?.id; + if (!connectionId) { + return; + } + const getResult = await callTool(baseUrl, "icepanel_get_connection", { + response_format: "json", + landscapeId, + connectionId, + }); + const getStructured = getResult.structuredContent as { + modelConnection?: { id?: string }; + connection?: { id?: string }; + } | undefined; + const resolvedId = getStructured?.modelConnection?.id ?? getStructured?.connection?.id; + expect(resolvedId).toBe(connectionId); + }); + + test("list diagrams and get diagram", async () => { + if (!landscapeId) { + return; + } + const listResult = await callTool(baseUrl, "icepanel_list_diagrams", { + response_format: "json", + landscapeId, + limit: 10, + offset: 0, + }); + const listStructured = listResult.structuredContent as { items?: { id?: string }[] } | undefined; + expect(listStructured?.items).toBeDefined(); + + const diagramId = listStructured?.items?.[0]?.id; + if (!diagramId) { + return; + } + const getResult = await callTool(baseUrl, "icepanel_get_diagram", { + response_format: "json", + landscapeId, + diagramId, + }); + const getStructured = getResult.structuredContent as { diagram?: { id?: string } } | undefined; + expect(getStructured?.diagram?.id).toBe(diagramId); + }); + + test("list flows and get flow", async () => { + if (!landscapeId) { + return; + } + const listResult = await callTool(baseUrl, "icepanel_list_flows", { + response_format: "json", + landscapeId, + limit: 10, + offset: 0, + }); + const listStructured = listResult.structuredContent as { items?: { id?: string }[] } | undefined; + expect(listStructured?.items).toBeDefined(); + + const flowId = listStructured?.items?.[0]?.id; + if (!flowId) { + return; + } + const getResult = await callTool(baseUrl, "icepanel_get_flow", { + response_format: "json", + landscapeId, + flowId, + }); + const getStructured = getResult.structuredContent as { flow?: { id?: string } } | undefined; + expect(getStructured?.flow?.id).toBe(flowId); + }); + + test("list diagram thumbnails", async () => { + if (!landscapeId) { + return; + } + const result = await callTool(baseUrl, "icepanel_list_diagram_thumbnails", { + response_format: "json", + landscapeId, + limit: 5, + offset: 0, + }); + const structured = result.structuredContent as { items?: unknown[] } | undefined; + expect(structured?.items).toBeDefined(); + }); + + test("list flow thumbnails", async () => { + if (!landscapeId) { + return; + } + const result = await callTool(baseUrl, "icepanel_list_flow_thumbnails", { + response_format: "json", + landscapeId, + limit: 5, + offset: 0, + }); + const structured = result.structuredContent as { items?: unknown[] } | undefined; + expect(structured?.items).toBeDefined(); + }); + + test("flow text/code/mermaid exports", async () => { + if (!landscapeId) { + return; + } + const listResult = await callTool(baseUrl, "icepanel_list_flows", { + response_format: "json", + landscapeId, + limit: 1, + offset: 0, + }); + const flowId = (listResult.structuredContent as { items?: { id?: string }[] } | undefined)?.items?.[0]?.id; + if (!flowId) { + return; + } + const textResult = await callTool(baseUrl, "icepanel_get_flow_text", { + response_format: "json", + landscapeId, + flowId, + }); + const codeResult = await callTool(baseUrl, "icepanel_get_flow_code", { + response_format: "json", + landscapeId, + flowId, + }); + const mermaidResult = await callTool(baseUrl, "icepanel_get_flow_mermaid", { + response_format: "json", + landscapeId, + flowId, + }); + expect((textResult.structuredContent as { text?: string } | undefined)?.text).toBeDefined(); + expect((codeResult.structuredContent as { code?: string } | undefined)?.code).toBeDefined(); + expect((mermaidResult.structuredContent as { mermaid?: string } | undefined)?.mermaid).toBeDefined(); + }); + + test("model object and connection exports", async () => { + if (!landscapeId) { + return; + } + const objectsCsv = await callTool(baseUrl, "icepanel_get_model_objects_csv", { + response_format: "json", + landscapeId, + }); + const connectionsCsv = await callTool(baseUrl, "icepanel_get_model_connections_csv", { + response_format: "json", + landscapeId, + }); + expect((objectsCsv.structuredContent as { csv?: string } | undefined)?.csv).toBeDefined(); + expect((connectionsCsv.structuredContent as { csv?: string } | undefined)?.csv).toBeDefined(); + + if (!modelObjectId) { + return; + } + const dependencies = await callTool(baseUrl, "icepanel_get_model_object_dependencies_json", { + response_format: "json", + landscapeId, + modelObjectId, + }); + const structured = dependencies.structuredContent as { + incomingConnections?: unknown[]; + outgoingConnections?: unknown[]; + } | undefined; + expect(structured).toBeDefined(); + expect(structured?.incomingConnections).toBeDefined(); + expect(structured?.outgoingConnections).toBeDefined(); + }); + +});