diff --git a/src/icepanel.ts b/src/icepanel.ts index e87268e..8d1e2a8 100644 --- a/src/icepanel.ts +++ b/src/icepanel.ts @@ -2,7 +2,27 @@ * IcePanel API client */ -import type { ModelObjectsResponse, ModelObjectResponse, CatalogTechnologyResponse, TeamsResponse, ModelConnectionsResponse } 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; } /** @@ -306,6 +383,226 @@ 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}`); +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 7051367..29613fa 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 @@ -40,6 +42,7 @@ server.tool( }; } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -61,6 +64,7 @@ server.tool( }; } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -125,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}` }], }; } @@ -166,6 +171,7 @@ server.tool( }; } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -213,6 +219,7 @@ server.tool( } } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -255,6 +262,7 @@ server.tool( }; } catch (error: any) { return { + isError: true, content: [{ type: "text", text: `Error: ${error.message}` }], }; } @@ -292,6 +300,7 @@ server.tool( } } catch (error: any) { return { + isError: true, content: [{ type: 'text', text: `Error: ${error.message}`}] } } @@ -299,6 +308,488 @@ 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) }], + }; + } + } +); + +// ============================================================================ +// 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); + +// 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..f01b674 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[]; @@ -163,3 +163,172 @@ 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[]; + 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; +}