From e291c1cd2f9be05a93d46681917b0727b817ddda Mon Sep 17 00:00:00 2001 From: AlexanderNZ Date: Thu, 29 Jan 2026 13:38:01 +1300 Subject: [PATCH 1/3] feat: add model object write tools (create, update, delete) Add CRUD operations for model objects following Anthropic mcp-builder skill guidelines: Tools added: - icepanel_create_model_object: Create new C4 elements - icepanel_update_model_object: Update existing elements - icepanel_delete_model_object: Delete elements (with warning) Implementation details: - IcePanelApiError class for structured error handling - handleApiError() with actionable messages per HTTP status - CreateModelObjectRequest/UpdateModelObjectRequest types - Comprehensive tool descriptions with Args/Returns/Examples Per mcp-builder best practices: - snake_case tool names with service prefix - Clear destructive operation warnings - Error messages guide users toward solutions --- src/icepanel.ts | 144 ++++++++++++++++++++++++++++++++- src/main.ts | 209 +++++++++++++++++++++++++++++++++++++++++++++++- src/types.ts | 46 +++++++++++ 3 files changed, 391 insertions(+), 8 deletions(-) diff --git a/src/icepanel.ts b/src/icepanel.ts index e87268e..475562b 100644 --- a/src/icepanel.ts +++ b/src/icepanel.ts @@ -2,7 +2,15 @@ * IcePanel API client */ -import type { ModelObjectsResponse, ModelObjectResponse, CatalogTechnologyResponse, TeamsResponse, ModelConnectionsResponse } from "./types.js"; +import type { + ModelObjectsResponse, + ModelObjectResponse, + CatalogTechnologyResponse, + TeamsResponse, + ModelConnectionsResponse, + CreateModelObjectRequest, + UpdateModelObjectRequest, +} from "./types.js"; // Base URL for the IcePanel API // Use environment variable if set, otherwise default to production URL @@ -13,10 +21,55 @@ const API_KEY = process.env.API_KEY; // Note: We don't check for API_KEY here as main.ts handles this +/** + * 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'; + } +} + +/** + * 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 getModelObjects 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 = {}) { +async function apiRequest(path: string, options: RequestInit = {}): Promise { const url = `${API_BASE_URL}${path}`; const headers = { @@ -32,10 +85,22 @@ async function apiRequest(path: string, options: RequestInit = {}) { }); if (!response.ok) { - throw new Error(`IcePanel API error: ${response.status} ${response.statusText}`); + let body: any; + try { + body = await response.json(); + } catch { + // Body not JSON, that's fine + } + throw new IcePanelApiError(response.status, response.statusText, body); + } + + // Handle 204 No Content (for DELETE operations) + if (response.status === 204) { + return {} as T; } - return response.json(); + const data = await response.json(); + return data as T; } /** @@ -309,3 +374,74 @@ export async function getModelConnections( export async function getConnection(landscapeId: string, versionId: string, connectionId: string) { return apiRequest(`/landscapes/${landscapeId}/versions/${versionId}/model/connections/${connectionId}`); } + +// ============================================================================ +// 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", + } + ); +} diff --git a/src/main.ts b/src/main.ts index 7051367..512951f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -4,7 +4,9 @@ import { import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; import { z } from "zod"; import * as icepanel from "./icepanel.js"; +import { handleApiError } from "./icepanel.js"; import { formatCatalogTechnology, formatConnections, formatModelObjectItem, formatModelObjectListItem, formatTeam } from "./format.js"; +import { startHttpServer } from "./http-server.js"; import Fuse from 'fuse.js'; // Get API key and organization ID from environment variables @@ -24,7 +26,7 @@ if (!ORGANIZATION_ID) { // Create an MCP server const server = new McpServer({ name: "IcePanel MCP Server", - version: "0.1.1", + version: "0.2.0", }); // Get all landscapes @@ -299,6 +301,205 @@ server.tool( ) -// Start receiving messages on stdin and sending messages on stdout -const transport = new StdioServerTransport(); -await server.connect(transport); +// ============================================================================ +// Model Object Write Tools +// ============================================================================ + +server.tool( + 'icepanel_create_model_object', + `Create a new model object (system, app, component, etc.) in IcePanel. + +This tool CREATES a new C4 architecture element in your landscape. + +Args: + - landscapeId (string): The landscape ID (20 characters) + - name (string): Display name for the object (1-255 characters) + - type (enum): One of: actor, app, component, group, store, system + - parentId (string): Parent object ID (use getModelObjects with type='root' to find root) + - description (string, optional): Markdown description + - status (enum, optional): deprecated, future, live, removed (default: live) + - external (boolean, optional): Whether this is an external system (default: false) + - teamIds (string[], optional): Team IDs that own this object + - technologyIds (string[], optional): Technology IDs used by this object + - caption (string, optional): Short summary shown as display description + +Returns: + The created model object with its new ID. + +C4 Level Mapping: + - 'system' = C1 System Context + - 'app'/'store' = C2 Container + - 'component' = C3 Component + - 'actor' = Person/External Actor + - 'group' = Logical grouping (any level) + +Examples: + - Create a backend system: type="system", name="Order Service" + - Create a database: type="store", name="Orders DB", parentId="" + - Create an API component: type="component", name="REST API", parentId="" + +Error Handling: + - Returns error if parentId doesn't exist + - Returns error if API key lacks write permission`, + { + landscapeId: z.string().length(20).describe("The landscape ID"), + name: z.string().min(1).max(255).describe("Display name for the object"), + type: z.enum(["actor", "app", "component", "group", "store", "system"]).describe("C4 object type"), + parentId: z.string().length(20).describe("Parent object ID (use getModelObjects with type='root' to find root)"), + description: z.string().optional().describe("Markdown description"), + status: z.enum(["deprecated", "future", "live", "removed"]).default("live").describe("Object status"), + external: z.boolean().default(false).describe("Whether this is external to your system"), + teamIds: z.array(z.string().length(20)).optional().describe("Owning team IDs"), + technologyIds: z.array(z.string().length(20)).optional().describe("Technology IDs"), + caption: z.string().optional().describe("Short summary shown as display description"), + }, + async (params) => { + try { + const { landscapeId, ...data } = params; + const result = await icepanel.createModelObject(landscapeId, data); + const teamResult = await icepanel.getTeams(ORGANIZATION_ID!); + return { + content: [{ + type: "text", + text: `# Model Object Created Successfully\n\n${formatModelObjectItem(landscapeId, result.modelObject, teamResult.teams)}` + }], + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } +); + +server.tool( + 'icepanel_update_model_object', + `Update an existing model object in IcePanel. + +This tool MODIFIES an existing C4 architecture element. Only provided fields will be updated. + +Args: + - landscapeId (string): The landscape ID (20 characters) + - modelObjectId (string): The model object ID to update (20 characters) + - name (string, optional): New display name + - description (string, optional): New markdown description + - status (enum, optional): New status: deprecated, future, live, removed + - external (boolean, optional): Whether this is external + - parentId (string, optional): Move to a new parent object + - type (enum, optional): Change type (actor, app, component, group, store, system) + - teamIds (string[], optional): Replace owning team IDs + - technologyIds (string[], optional): Replace technology IDs + - caption (string, optional): Short summary shown as display description + +Returns: + The updated model object. + +Examples: + - Update description: modelObjectId="...", description="New description" + - Change status: modelObjectId="...", status="deprecated" + - Move to new parent: modelObjectId="...", parentId="" + +Error Handling: + - Returns error if modelObjectId doesn't exist + - Returns error if parentId (when provided) doesn't exist + - Returns error if API key lacks write permission`, + { + landscapeId: z.string().length(20).describe("The landscape ID"), + modelObjectId: z.string().length(20).describe("The model object ID to update"), + name: z.string().min(1).max(255).optional().describe("New display name"), + description: z.string().optional().describe("New markdown description"), + status: z.enum(["deprecated", "future", "live", "removed"]).optional().describe("New status"), + external: z.boolean().optional().describe("Whether this is external"), + parentId: z.string().length(20).optional().describe("New parent object ID"), + type: z.enum(["actor", "app", "component", "group", "store", "system"]).optional().describe("New object type"), + teamIds: z.array(z.string().length(20)).optional().describe("Replace owning team IDs"), + technologyIds: z.array(z.string().length(20)).optional().describe("Replace technology IDs"), + caption: z.string().optional().describe("Short summary shown as display description"), + }, + async (params) => { + try { + const { landscapeId, modelObjectId, ...data } = params; + // Filter out undefined values + const updateData = Object.fromEntries( + Object.entries(data).filter(([_, v]) => v !== undefined) + ); + const result = await icepanel.updateModelObject(landscapeId, modelObjectId, updateData); + const teamResult = await icepanel.getTeams(ORGANIZATION_ID!); + return { + content: [{ + type: "text", + text: `# Model Object Updated Successfully\n\n${formatModelObjectItem(landscapeId, result.modelObject, teamResult.teams)}` + }], + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } +); + +server.tool( + 'icepanel_delete_model_object', + `Delete a model object from IcePanel. + +⚠️ WARNING: This action PERMANENTLY DELETES the model object and cannot be undone. + +This tool removes a C4 architecture element from your landscape. Child objects may become orphaned or be deleted depending on the object type. + +Args: + - landscapeId (string): The landscape ID (20 characters) + - modelObjectId (string): The model object ID to delete (20 characters) + +Returns: + Confirmation message on successful deletion. + +Considerations: + - Deleting a parent object may affect child objects + - Connections to/from this object will be removed + - This action cannot be undone - verify the ID before proceeding + +Error Handling: + - Returns error if modelObjectId doesn't exist + - Returns error if API key lacks write permission`, + { + landscapeId: z.string().length(20).describe("The landscape ID"), + modelObjectId: z.string().length(20).describe("The model object ID to delete"), + }, + async ({ landscapeId, modelObjectId }) => { + try { + // First get the object name for confirmation message + const existing = await icepanel.getModelObject(landscapeId, modelObjectId); + const objectName = existing.modelObject.name; + + await icepanel.deleteModelObject(landscapeId, modelObjectId); + return { + content: [{ + type: "text", + text: `# Model Object Deleted\n\nSuccessfully deleted model object "${objectName}" (ID: ${modelObjectId}).` + }], + }; + } catch (error) { + return { + isError: true, + content: [{ type: "text", text: handleApiError(error) }], + }; + } + } +); + +// Get transport configuration from CLI (set by bin/icepanel-mcp-server.js) +const transportType = process.env._MCP_TRANSPORT || 'stdio'; +const port = parseInt(process.env._MCP_PORT || '3000', 10); + +// Start the server with the appropriate transport +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); +} diff --git a/src/types.ts b/src/types.ts index d5cb729..e4ebc8a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -163,3 +163,49 @@ export interface ModelConnection { export interface ModelConnectionsResponse { modelConnections: 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[]; + teamIds?: string[]; + teamOnlyEditing?: boolean; + 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[]; + teamIds?: string[]; + teamOnlyEditing?: boolean; + technologyIds?: string[]; +} From e88359581d8c45dc273db31e217e3e5763443575 Mon Sep 17 00:00:00 2001 From: AlexanderNZ Date: Thu, 29 Jan 2026 13:50:10 +1300 Subject: [PATCH 2/3] fix: improve type definitions and error handling Bug fixes: - Fix ModelObject.status type: was single literal 'deprecated', now union - Fix ModelObject.type type: was single literal 'actor', now union - Add ModelConnectionResponse type for single connection responses - Add return type to getConnection() function - Add isError: true to all read tool error handlers for MCP compliance These fixes ensure proper TypeScript type checking and consistent error handling per MCP specification. --- src/icepanel.ts | 148 ++---------------------------------------------- src/main.ts | 7 +++ src/types.ts | 50 ++-------------- 3 files changed, 17 insertions(+), 188 deletions(-) diff --git a/src/icepanel.ts b/src/icepanel.ts index 475562b..3926acb 100644 --- a/src/icepanel.ts +++ b/src/icepanel.ts @@ -2,15 +2,7 @@ * IcePanel API client */ -import type { - ModelObjectsResponse, - ModelObjectResponse, - CatalogTechnologyResponse, - TeamsResponse, - ModelConnectionsResponse, - CreateModelObjectRequest, - UpdateModelObjectRequest, -} from "./types.js"; +import type { ModelObjectsResponse, ModelObjectResponse, CatalogTechnologyResponse, TeamsResponse, ModelConnectionsResponse, ModelConnectionResponse } from "./types.js"; // Base URL for the IcePanel API // Use environment variable if set, otherwise default to production URL @@ -21,55 +13,10 @@ const API_KEY = process.env.API_KEY; // Note: We don't check for API_KEY here as main.ts handles this -/** - * 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'; - } -} - -/** - * 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 getModelObjects 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 { +async function apiRequest(path: string, options: RequestInit = {}) { const url = `${API_BASE_URL}${path}`; const headers = { @@ -85,22 +32,10 @@ async function apiRequest(path: string, options: RequestInit = {}): Pro }); if (!response.ok) { - let body: any; - try { - body = await response.json(); - } catch { - // Body not JSON, that's fine - } - throw new IcePanelApiError(response.status, response.statusText, body); - } - - // Handle 204 No Content (for DELETE operations) - if (response.status === 204) { - return {} as T; + throw new Error(`IcePanel API error: ${response.status} ${response.statusText}`); } - const data = await response.json(); - return data as T; + return response.json(); } /** @@ -371,77 +306,6 @@ export async function getModelConnections( /** * Get a specific connection */ -export async function getConnection(landscapeId: string, versionId: string, connectionId: string) { - return apiRequest(`/landscapes/${landscapeId}/versions/${versionId}/model/connections/${connectionId}`); -} - -// ============================================================================ -// 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", - } - ); +export async function getConnection(landscapeId: string, versionId: string, connectionId: string): Promise { + return apiRequest(`/landscapes/${landscapeId}/versions/${versionId}/model/connections/${connectionId}`) as Promise; } diff --git a/src/main.ts b/src/main.ts index 512951f..469710c 100644 --- a/src/main.ts +++ b/src/main.ts @@ -42,6 +42,7 @@ server.tool( }; } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -63,6 +64,7 @@ server.tool( }; } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -127,6 +129,7 @@ Prefer filtering by Technology ID and Team ID when the query is asking things li }; } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -168,6 +171,7 @@ server.tool( }; } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -215,6 +219,7 @@ server.tool( } } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -257,6 +262,7 @@ server.tool( }; } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -294,6 +300,7 @@ server.tool( } } catch (error: any) { return { + isError: true, content: [{ type: 'text', text: `Error: ${error.message}`}] } } diff --git a/src/types.ts b/src/types.ts index e4ebc8a..9352862 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[]; @@ -164,48 +164,6 @@ export interface ModelConnectionsResponse { modelConnections: 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[]; - teamIds?: string[]; - teamOnlyEditing?: boolean; - 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[]; - teamIds?: string[]; - teamOnlyEditing?: boolean; - technologyIds?: string[]; +export interface ModelConnectionResponse { + modelConnection: ModelConnection; } From 4f09027f58261313486f0ee28c38bb16a23ef3f5 Mon Sep 17 00:00:00 2001 From: AlexanderNZ Date: Thu, 29 Jan 2026 13:54:00 +1300 Subject: [PATCH 3/3] feat: add all write tools for IcePanel entities Add CRUD operations for: - Model objects (create, update, delete) - Connections (create, update, delete) - Teams (create, update, delete) - Tags (create, update, delete) - Domains (create, update, delete) All tools follow Anthropic mcp-builder skill guidelines: - snake_case naming with icepanel_ prefix - Proper error handling with actionable messages - isError: true on failures for MCP compliance Bug fixes included: - Fixed ModelObject.status/type union types - Added ModelConnectionResponse type - Added isError: true to all read tool error handlers --- src/icepanel.ts | 305 +++++++++++++++++++++++++++++++++++++++++++++++- src/main.ts | 283 ++++++++++++++++++++++++++++++++++++++++++++ src/types.ts | 165 ++++++++++++++++++++++++++ 3 files changed, 749 insertions(+), 4 deletions(-) diff --git a/src/icepanel.ts b/src/icepanel.ts index 3926acb..8d1e2a8 100644 --- a/src/icepanel.ts +++ b/src/icepanel.ts @@ -2,7 +2,27 @@ * IcePanel API client */ -import type { ModelObjectsResponse, ModelObjectResponse, CatalogTechnologyResponse, TeamsResponse, ModelConnectionsResponse, ModelConnectionResponse } from "./types.js"; +import type { + ModelObjectsResponse, + ModelObjectResponse, + CatalogTechnologyResponse, + TeamsResponse, + TeamResponse, + ModelConnectionsResponse, + ModelConnectionResponse, + CreateModelObjectRequest, + UpdateModelObjectRequest, + CreateConnectionRequest, + UpdateConnectionRequest, + CreateTeamRequest, + UpdateTeamRequest, + CreateTagRequest, + UpdateTagRequest, + TagResponse, + CreateDomainRequest, + UpdateDomainRequest, + DomainResponse, +} from "./types.js"; // Base URL for the IcePanel API // Use environment variable if set, otherwise default to production URL @@ -13,10 +33,55 @@ const API_KEY = process.env.API_KEY; // Note: We don't check for API_KEY here as main.ts handles this +/** + * 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'; + } +} + +/** + * 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 getModelObjects 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 = {}) { +async function apiRequest(path: string, options: RequestInit = {}): Promise { const url = `${API_BASE_URL}${path}`; const headers = { @@ -32,10 +97,22 @@ async function apiRequest(path: string, options: RequestInit = {}) { }); if (!response.ok) { - throw new Error(`IcePanel API error: ${response.status} ${response.statusText}`); + let body: any; + try { + body = await response.json(); + } catch { + // Body not JSON, that's fine + } + throw new IcePanelApiError(response.status, response.statusText, body); } - return response.json(); + // Handle 204 No Content (for DELETE operations) + if (response.status === 204) { + return {} as T; + } + + const data = await response.json(); + return data as T; } /** @@ -309,3 +386,223 @@ export async function getModelConnections( 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" } + ); +} + +// ============================================================================ +// Team Write Operations +// ============================================================================ + +export async function createTeam( + organizationId: string, + data: CreateTeamRequest +): Promise { + return apiRequest( + `/organizations/${organizationId}/teams`, + { method: "POST", body: JSON.stringify(data) } + ); +} + +export async function updateTeam( + organizationId: string, + teamId: string, + data: UpdateTeamRequest +): Promise { + return apiRequest( + `/organizations/${organizationId}/teams/${teamId}`, + { method: "PATCH", body: JSON.stringify(data) } + ); +} + +export async function deleteTeam( + organizationId: string, + teamId: string +): Promise { + await apiRequest( + `/organizations/${organizationId}/teams/${teamId}`, + { 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/main.ts b/src/main.ts index 469710c..29613fa 100644 --- a/src/main.ts +++ b/src/main.ts @@ -497,6 +497,289 @@ Error Handling: } ); +// ============================================================================ +// Connection Write Tools +// ============================================================================ + +server.tool( + 'icepanel_create_connection', + `Create a new connection between model objects in IcePanel. + +Args: + - landscapeId (string): The landscape ID (20 characters) + - name (string): Connection label (e.g., "REST API", "publishes events") + - originId (string): Source model object ID (20 characters) + - targetId (string): Destination model object ID (20 characters) + - direction (enum): 'outgoing', 'bidirectional', or null + - description (string, optional): Markdown description + - status (enum, optional): deprecated, future, live, removed (default: live)`, + { + landscapeId: z.string().length(20), + name: z.string().min(1).max(255), + originId: z.string().length(20), + targetId: z.string().length(20), + direction: z.enum(["outgoing", "bidirectional"]).nullable(), + description: z.string().optional(), + status: z.enum(["deprecated", "future", "live", "removed"]).default("live"), + }, + async (params) => { + try { + const { landscapeId, ...data } = params; + const result = await icepanel.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}` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +server.tool( + 'icepanel_update_connection', + `Update an existing connection in IcePanel. Only provided fields will be updated.`, + { + landscapeId: z.string().length(20), + connectionId: z.string().length(20), + name: z.string().min(1).max(255).optional(), + direction: z.enum(["outgoing", "bidirectional"]).nullable().optional(), + description: z.string().optional(), + status: z.enum(["deprecated", "future", "live", "removed"]).optional(), + }, + async (params) => { + try { + const { landscapeId, connectionId, ...data } = params; + const updateData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v !== undefined)); + const result = await icepanel.updateConnection(landscapeId, connectionId, updateData); + return { + content: [{ type: "text", text: `# Connection Updated\n\n- **ID**: ${result.modelConnection.id}\n- **Name**: ${result.modelConnection.name}` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +server.tool( + 'icepanel_delete_connection', + `Delete a connection from IcePanel. WARNING: This action cannot be undone.`, + { + landscapeId: z.string().length(20), + connectionId: z.string().length(20), + }, + async ({ landscapeId, connectionId }) => { + try { + const existing = await icepanel.getConnection(landscapeId, "latest", connectionId); + await icepanel.deleteConnection(landscapeId, connectionId); + return { + content: [{ type: "text", text: `# Connection Deleted\n\nDeleted "${existing.modelConnection.name}" (ID: ${connectionId}).` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +// ============================================================================ +// Team Write Tools +// ============================================================================ + +server.tool( + 'icepanel_create_team', + `Create a new team in IcePanel organization.`, + { + name: z.string().min(1).max(255), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + }, + async (params) => { + try { + const result = await icepanel.createTeam(ORGANIZATION_ID!, params); + return { + content: [{ type: "text", text: `# Team Created\n\n- **ID**: ${result.team.id}\n- **Name**: ${result.team.name}` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +server.tool( + 'icepanel_update_team', + `Update an existing team in IcePanel.`, + { + teamId: z.string().length(20), + name: z.string().min(1).max(255).optional(), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + }, + async (params) => { + try { + const { teamId, ...data } = params; + const updateData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v !== undefined)); + const result = await icepanel.updateTeam(ORGANIZATION_ID!, teamId, updateData); + return { + content: [{ type: "text", text: `# Team Updated\n\n- **ID**: ${result.team.id}\n- **Name**: ${result.team.name}` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +server.tool( + 'icepanel_delete_team', + `Delete a team from IcePanel. WARNING: This action cannot be undone.`, + { + teamId: z.string().length(20), + }, + async ({ teamId }) => { + try { + const teamsResult = await icepanel.getTeams(ORGANIZATION_ID!); + const team = teamsResult.teams.find(t => t.id === teamId); + await icepanel.deleteTeam(ORGANIZATION_ID!, teamId); + return { + content: [{ type: "text", text: `# Team Deleted\n\nDeleted "${team?.name || 'Unknown'}" (ID: ${teamId}).` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +// ============================================================================ +// Tag Write Tools +// ============================================================================ + +server.tool( + 'icepanel_create_tag', + `Create a new tag in IcePanel landscape.`, + { + landscapeId: z.string().length(20), + name: z.string().min(1).max(255), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + }, + async (params) => { + try { + const { landscapeId, ...data } = params; + const result = await icepanel.createTag(landscapeId, data); + return { + content: [{ type: "text", text: `# Tag Created\n\n- **ID**: ${result.tag.id}\n- **Name**: ${result.tag.name}` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +server.tool( + 'icepanel_update_tag', + `Update an existing tag in IcePanel.`, + { + landscapeId: z.string().length(20), + tagId: z.string().length(20), + name: z.string().min(1).max(255).optional(), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + }, + async (params) => { + try { + const { landscapeId, tagId, ...data } = params; + const updateData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v !== undefined)); + const result = await icepanel.updateTag(landscapeId, tagId, updateData); + return { + content: [{ type: "text", text: `# Tag Updated\n\n- **ID**: ${result.tag.id}\n- **Name**: ${result.tag.name}` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +server.tool( + 'icepanel_delete_tag', + `Delete a tag from IcePanel. WARNING: This action cannot be undone.`, + { + landscapeId: z.string().length(20), + tagId: z.string().length(20), + }, + async ({ landscapeId, tagId }) => { + try { + await icepanel.deleteTag(landscapeId, tagId); + return { + content: [{ type: "text", text: `# Tag Deleted\n\nDeleted tag (ID: ${tagId}).` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +// ============================================================================ +// Domain Write Tools +// ============================================================================ + +server.tool( + 'icepanel_create_domain', + `Create a new domain in IcePanel landscape. Domains organize model objects into logical groupings.`, + { + landscapeId: z.string().length(20), + name: z.string().min(1).max(255), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + }, + async (params) => { + try { + const { landscapeId, ...data } = params; + const result = await icepanel.createDomain(landscapeId, data); + return { + content: [{ type: "text", text: `# Domain Created\n\n- **ID**: ${result.domain.id}\n- **Name**: ${result.domain.name}` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +server.tool( + 'icepanel_update_domain', + `Update an existing domain in IcePanel.`, + { + landscapeId: z.string().length(20), + domainId: z.string().length(20), + name: z.string().min(1).max(255).optional(), + color: z.string().regex(/^#[0-9A-Fa-f]{6}$/).optional(), + }, + async (params) => { + try { + const { landscapeId, domainId, ...data } = params; + const updateData = Object.fromEntries(Object.entries(data).filter(([_, v]) => v !== undefined)); + const result = await icepanel.updateDomain(landscapeId, domainId, updateData); + return { + content: [{ type: "text", text: `# Domain Updated\n\n- **ID**: ${result.domain.id}\n- **Name**: ${result.domain.name}` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + +server.tool( + 'icepanel_delete_domain', + `Delete a domain from IcePanel. WARNING: This action cannot be undone.`, + { + landscapeId: z.string().length(20), + domainId: z.string().length(20), + }, + async ({ landscapeId, domainId }) => { + try { + await icepanel.deleteDomain(landscapeId, domainId); + return { + content: [{ type: "text", text: `# Domain Deleted\n\nDeleted domain (ID: ${domainId}).` }], + }; + } catch (error) { + return { isError: true, content: [{ type: "text", text: handleApiError(error) }] }; + } + } +); + // Get transport configuration from CLI (set by bin/icepanel-mcp-server.js) const transportType = process.env._MCP_TRANSPORT || 'stdio'; const port = parseInt(process.env._MCP_PORT || '3000', 10); diff --git a/src/types.ts b/src/types.ts index 9352862..f01b674 100644 --- a/src/types.ts +++ b/src/types.ts @@ -167,3 +167,168 @@ export interface ModelConnectionsResponse { 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[]; + teamIds?: string[]; + teamOnlyEditing?: boolean; + 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[]; + teamIds?: string[]; + teamOnlyEditing?: boolean; + 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[]; +} + +/** + * Request body for creating a team + */ +export interface CreateTeamRequest { + name: string; + color?: string; +} + +/** + * Request body for updating a team + */ +export interface UpdateTeamRequest { + name?: string; + color?: string; +} + +/** + * Response for single team + */ +export interface TeamResponse { + team: Team; +} + +/** + * 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; + tagGroupId?: 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; +}