diff --git a/src/helpers/errorHandling.ts b/src/helpers/errorHandling.ts new file mode 100644 index 0000000..5a00bda --- /dev/null +++ b/src/helpers/errorHandling.ts @@ -0,0 +1,94 @@ +/** + * Enhanced error handling utilities for Octopus Deploy MCP Server tools + */ + +/** + * Checks if the error is an Error instance and has a message containing the specified text + */ +export function isErrorWithMessage(error: unknown, messageFragment: string): error is Error { + return error instanceof Error && error.message?.includes(messageFragment) === true; +} + +/** + * Common error handler for Octopus Deploy API errors with actionable messages + */ +export function handleOctopusApiError(error: unknown, context: { + entityType?: string; + entityId?: string; + spaceName?: string; + helpText?: string; +}): never { + const { entityType, entityId, spaceName, helpText } = context; + + // Handle 404/not found errors + if (isErrorWithMessage(error, 'not found') || isErrorWithMessage(error, '404')) { + if (entityType && entityId && spaceName) { + throw new Error( + `${entityType.charAt(0).toUpperCase() + entityType.slice(1)} '${entityId}' not found in space '${spaceName}'. ` + + (helpText || `Verify the ${entityType} ID is correct using list_${entityType}s.`) + ); + } + if (spaceName) { + throw new Error( + `Space '${spaceName}' not found. Use list_spaces to see available spaces. Space names are case-sensitive.` + ); + } + } + + // Handle authentication errors + if (isErrorWithMessage(error, 'authentication') || + isErrorWithMessage(error, '401') || + isErrorWithMessage(error, 'You must be logged in to request this resource') || + isErrorWithMessage(error, 'provide a valid API key')) { + throw new Error( + "Authentication failed. Ensure OCTOPUS_API_KEY environment variable is set with a valid API key. " + + "You can generate an API key from your Octopus Deploy user profile." + ); + } + + // Handle connection errors + if (isErrorWithMessage(error, 'connect') || isErrorWithMessage(error, 'timeout')) { + throw new Error( + "Cannot connect to Octopus Deploy instance. Check that OCTOPUS_URL environment variable is set correctly " + + "(e.g., 'https://your-instance.octopus.app') and that the instance is accessible." + ); + } + + // Re-throw the original error if no specific handling applies + throw error; +} + +/** + * Validates entity ID format with actionable error messages + */ +export function validateEntityId(id: string | undefined, entityType: string, prefix: string): void { + if (!id) { + // This shouldn't happen due to Zod validation, but kept for safety + throw new Error( + `${entityType.charAt(0).toUpperCase() + entityType.slice(1)} ID is required. ` + + `Use list_${entityType}s to find ${entityType} IDs.` + ); + } + + if (!id.startsWith(prefix)) { + throw new Error( + `Invalid ${entityType} ID format '${id}'. ${entityType.charAt(0).toUpperCase() + entityType.slice(1)} IDs should start with '${prefix}' followed by numbers. ` + + `Use list_${entityType}s to find valid ${entityType} IDs.` + ); + } +} + +/** + * Entity ID prefixes for common Octopus Deploy entities + */ +export const ENTITY_PREFIXES = { + task: 'ServerTasks-', + project: 'Projects-', + environment: 'Environments-', + tenant: 'Tenants-', + release: 'Releases-', + machine: 'Machines-', + certificate: 'Certificates-', + account: 'Accounts-', + deploymentProcess: 'DeploymentProcesses-' +} as const; \ No newline at end of file diff --git a/src/tools/getAccount.ts b/src/tools/getAccount.ts index 2ac8df5..8f83d03 100644 --- a/src/tools/getAccount.ts +++ b/src/tools/getAccount.ts @@ -7,6 +7,7 @@ import { type AccountResource, mapAccountResource } from "../types/accountTypes.js"; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from "../helpers/errorHandling.js"; export function registerGetAccountTool(server: McpServer) { server.tool( @@ -23,28 +24,38 @@ This tool retrieves detailed information about a specific account using its ID. readOnlyHint: true, }, async ({ spaceName, accountId }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const spaceId = await resolveSpaceId(client, spaceName); + validateEntityId(accountId, 'account', ENTITY_PREFIXES.account); - const response = await client.get( - "~/api/{spaceId}/accounts/{id}", - { - spaceId, - id: accountId, - } - ); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const spaceId = await resolveSpaceId(client, spaceName); - const account = mapAccountResource(response); - - return { - content: [ + const response = await client.get( + "~/api/{spaceId}/accounts/{id}", { - type: "text", - text: JSON.stringify(account), - }, - ], - }; + spaceId, + id: accountId, + } + ); + + const account = mapAccountResource(response); + + return { + content: [ + { + type: "text", + text: JSON.stringify(account), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: 'account', + entityId: accountId, + spaceName + }); + } } ); } diff --git a/src/tools/getBranches.ts b/src/tools/getBranches.ts index 775baf0..1390841 100644 --- a/src/tools/getBranches.ts +++ b/src/tools/getBranches.ts @@ -4,6 +4,7 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerToolDefinition } from "../types/toolConfig.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { getProjectBranches } from "../helpers/vcsProjectHelpers.js"; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from "../helpers/errorHandling.js"; export function registerGetBranchesTool(server: McpServer) { server.tool( @@ -23,9 +24,7 @@ This tool retrieves Git branches for a specific project in a space. The space na readOnlyHint: true, }, async ({ spaceName, projectId, searchByName, skip, take }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const spaceId = await resolveSpaceId(client, spaceName); + validateEntityId(projectId, 'project', ENTITY_PREFIXES.project); const options = { searchByName, @@ -33,27 +32,46 @@ This tool retrieves Git branches for a specific project in a space. The space na take, }; - const branches = await getProjectBranches(client, spaceId, projectId, options); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const spaceId = await resolveSpaceId(client, spaceName); - return { - content: [ - { - type: "text", - text: JSON.stringify({ - Items: branches.Items.map(branch => ({ - Name: branch.Name, - IsProtected: branch.IsProtected, - CanonicalName: branch.CanonicalName, - })), - TotalResults: branches.TotalResults, - ItemsPerPage: branches.ItemsPerPage, - NumberOfPages: branches.NumberOfPages, - LastPageNumber: branches.LastPageNumber, - ItemType: branches.ItemType, - }), - }, - ], - }; + const branches = await getProjectBranches(client, spaceId, projectId, options); + + if (branches.Items.length === 0 && !searchByName) { + throw new Error( + `No branches found for project '${projectId}'. This may indicate that the project is not version controlled or ` + + "uses database storage instead of Git. Only version controlled projects have branches." + ); + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + Items: branches.Items.map(branch => ({ + Name: branch.Name, + IsProtected: branch.IsProtected, + CanonicalName: branch.CanonicalName, + })), + TotalResults: branches.TotalResults, + ItemsPerPage: branches.ItemsPerPage, + NumberOfPages: branches.NumberOfPages, + LastPageNumber: branches.LastPageNumber, + ItemType: branches.ItemType, + }), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: 'project', + entityId: projectId, + spaceName + }); + } } ); } diff --git a/src/tools/getCertificate.ts b/src/tools/getCertificate.ts index 65cc505..ca224cd 100644 --- a/src/tools/getCertificate.ts +++ b/src/tools/getCertificate.ts @@ -4,6 +4,7 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerToolDefinition } from "../types/toolConfig.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { type CertificateResource, mapCertificateResource } from "../types/certificateTypes.js"; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from "../helpers/errorHandling.js"; export function registerGetCertificateTool(server: McpServer) { server.tool( @@ -20,28 +21,38 @@ This tool retrieves detailed information about a specific certificate using its readOnlyHint: true, }, async ({ spaceName, certificateId }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const spaceId = await resolveSpaceId(client, spaceName); + validateEntityId(certificateId, 'certificate', ENTITY_PREFIXES.certificate); - const response = await client.get( - "~/api/{spaceId}/certificates/{id}", - { - spaceId, - id: certificateId, - } - ); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const spaceId = await resolveSpaceId(client, spaceName); - const certificate = mapCertificateResource(response); - - return { - content: [ + const response = await client.get( + "~/api/{spaceId}/certificates/{id}", { - type: "text", - text: JSON.stringify(certificate), - }, - ], - }; + spaceId, + id: certificateId, + } + ); + + const certificate = mapCertificateResource(response); + + return { + content: [ + { + type: "text", + text: JSON.stringify(certificate), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: 'certificate', + entityId: certificateId, + spaceName + }); + } } ); } diff --git a/src/tools/getCurrentUser.ts b/src/tools/getCurrentUser.ts index ea03d8f..793f612 100644 --- a/src/tools/getCurrentUser.ts +++ b/src/tools/getCurrentUser.ts @@ -2,6 +2,7 @@ import { Client } from "@octopusdeploy/api-client"; import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerToolDefinition } from "../types/toolConfig.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; +import { handleOctopusApiError } from "../helpers/errorHandling.js"; interface CurrentUser { Id: string; @@ -26,30 +27,34 @@ This tool retrieves information about the currently authenticated user from the readOnlyHint: true, }, async () => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); - const user = await client.get("~/api/users/me"); + const user = await client.get("~/api/users/me"); - const currentUser = { - id: user.Id, - username: user.Username, - displayName: user.DisplayName, - isActive: user.IsActive, - isService: user.IsService, - emailAddress: user.EmailAddress, - canPasswordBeEdited: user.CanPasswordBeEdited, - isRequestor: user.IsRequestor, - }; + const currentUser = { + id: user.Id, + username: user.Username, + displayName: user.DisplayName, + isActive: user.IsActive, + isService: user.IsService, + emailAddress: user.EmailAddress, + canPasswordBeEdited: user.CanPasswordBeEdited, + isRequestor: user.IsRequestor, + }; - return { - content: [ - { - type: "text", - text: JSON.stringify(currentUser), - }, - ], - }; + return { + content: [ + { + type: "text", + text: JSON.stringify(currentUser), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, {}); + } } ); } diff --git a/src/tools/getDeploymentProcess.ts b/src/tools/getDeploymentProcess.ts index eabb073..d3501fe 100644 --- a/src/tools/getDeploymentProcess.ts +++ b/src/tools/getDeploymentProcess.ts @@ -31,11 +31,32 @@ This tool retrieves a deployment process by its ID. Each project has a deploymen const project = projectRepository && projectId ? await projectRepository.get(projectId) : null; if (!processId && !projectId) { - throw new Error("Either processId or projectId must be provided."); + throw new Error( + "Either processId or projectId must be provided. " + + "Use list_projects to find project IDs (starting with 'Projects-') or " + + "use list_deployments to find process IDs (starting with 'DeploymentProcesses-')." + ); + } + + if (projectId && !projectId.startsWith('Projects-')) { + throw new Error( + `Invalid project ID format '${projectId}'. Project IDs should start with 'Projects-' followed by numbers. ` + + "Use list_projects to find valid project IDs." + ); + } + + if (processId && !processId.startsWith('DeploymentProcesses-')) { + throw new Error( + `Invalid process ID format '${processId}'. Process IDs should start with 'DeploymentProcesses-' followed by numbers. ` + + "Use list_deployments to find valid process IDs." + ); } if (project?.IsVersionControlled && !branchName) { - throw new Error("Branch name must be provided for version controlled projects."); + throw new Error( + `Branch name required for version controlled project '${projectId}'. ` + + "Try 'main' or 'master', or use get_branches tool to list available branches." + ); } // If using branchName get the canonical ref first diff --git a/src/tools/getDeploymentTarget.ts b/src/tools/getDeploymentTarget.ts index 69f6d1f..2d0266d 100644 --- a/src/tools/getDeploymentTarget.ts +++ b/src/tools/getDeploymentTarget.ts @@ -4,6 +4,7 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerToolDefinition } from "../types/toolConfig.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { type DeploymentTargetResource } from "../types/deploymentTargetTypes.js"; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from "../helpers/errorHandling.js"; export function registerGetDeploymentTargetTool(server: McpServer) { server.tool( @@ -20,54 +21,65 @@ This tool retrieves detailed information about a specific deployment target usin readOnlyHint: true, }, async ({ spaceName, targetId }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const spaceId = await resolveSpaceId(client, spaceName); + validateEntityId(targetId, 'machine', ENTITY_PREFIXES.machine); - const target = await client.get( - "~/api/{spaceId}/machines/{id}", - { - spaceId, - id: targetId, - } - ); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const spaceId = await resolveSpaceId(client, spaceName); - const deploymentTarget = { - spaceId: target.SpaceId, - id: target.Id, - name: target.Name, - slug: target.Slug, - isDisabled: target.IsDisabled, - healthStatus: target.HealthStatus, - statusSummary: target.StatusSummary, - environmentIds: target.EnvironmentIds, - roles: target.Roles, - tenantedDeploymentParticipation: target.TenantedDeploymentParticipation, - tenantIds: target.TenantIds, - tenantTags: target.TenantTags, - endpoint: { - id: target.Endpoint.Id, - communicationStyle: target.Endpoint.CommunicationStyle, - uri: target.Endpoint.Uri, - fingerprint: target.Endpoint.Fingerprint, - proxyId: target.Endpoint.ProxyId, - tentacleVersionDetails: target.Endpoint.TentacleVersionDetails, - }, - shellName: target.ShellName, - machinePolicyId: target.MachinePolicyId, - hasLatestCalamari: target.HasLatestCalamari, - isInProcess: target.IsInProcess, - links: target.Links, - }; - - return { - content: [ + const target = await client.get( + "~/api/{spaceId}/machines/{id}", { - type: "text", - text: JSON.stringify(deploymentTarget), + spaceId, + id: targetId, + } + ); + + const deploymentTarget = { + spaceId: target.SpaceId, + id: target.Id, + name: target.Name, + slug: target.Slug, + isDisabled: target.IsDisabled, + healthStatus: target.HealthStatus, + statusSummary: target.StatusSummary, + environmentIds: target.EnvironmentIds, + roles: target.Roles, + tenantedDeploymentParticipation: target.TenantedDeploymentParticipation, + tenantIds: target.TenantIds, + tenantTags: target.TenantTags, + endpoint: { + id: target.Endpoint.Id, + communicationStyle: target.Endpoint.CommunicationStyle, + uri: target.Endpoint.Uri, + fingerprint: target.Endpoint.Fingerprint, + proxyId: target.Endpoint.ProxyId, + tentacleVersionDetails: target.Endpoint.TentacleVersionDetails, }, - ], - }; + shellName: target.ShellName, + machinePolicyId: target.MachinePolicyId, + hasLatestCalamari: target.HasLatestCalamari, + isInProcess: target.IsInProcess, + links: target.Links, + }; + + return { + content: [ + { + type: "text", + text: JSON.stringify(deploymentTarget), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: 'deployment target', + entityId: targetId, + spaceName, + helpText: "Use list_deployment_targets to find valid target IDs." + }); + } } ); } diff --git a/src/tools/getKubernetesLiveStatus.ts b/src/tools/getKubernetesLiveStatus.ts index 8d41bce..bd9f678 100644 --- a/src/tools/getKubernetesLiveStatus.ts +++ b/src/tools/getKubernetesLiveStatus.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { registerToolDefinition } from "../types/toolConfig.js"; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES, isErrorWithMessage } from "../helpers/errorHandling.js"; export function registerGetKubernetesLiveStatusTool(server: McpServer) { server.tool( @@ -22,51 +23,74 @@ export function registerGetKubernetesLiveStatusTool(server: McpServer) { readOnlyHint: true, }, async ({ spaceName, projectId, environmentId, tenantId, summaryOnly = false }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const observabilityRepository = new ObservabilityRepository(client, spaceName); + validateEntityId(projectId, 'project', ENTITY_PREFIXES.project); + validateEntityId(environmentId, 'environment', ENTITY_PREFIXES.environment); + if (tenantId) { + validateEntityId(tenantId, 'tenant', ENTITY_PREFIXES.tenant); + } - const liveStatus = await observabilityRepository.getLiveStatus( - projectId, - environmentId, - tenantId, - summaryOnly - ); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const observabilityRepository = new ObservabilityRepository(client, spaceName); - return { - content: [ - { - type: "text", - text: JSON.stringify({ - projectId, - environmentId, - tenantId, - summaryOnly: summaryOnly, - liveStatus: { - machineStatuses: liveStatus.MachineStatuses?.map((machine: KubernetesMachineLiveStatusResource) => ({ - machineId: machine.MachineId, - status: machine.Status, - resources: machine.Resources?.map((resource: KubernetesLiveStatusResource) => ({ - name: resource.Name, - namespace: resource.Namespace, - kind: resource.Kind, - healthStatus: resource.HealthStatus, - syncStatus: resource.SyncStatus, - machineId: resource.MachineId, - children: resource.Children, - desiredResourceId: resource.DesiredResourceId, - resourceId: resource.ResourceId - })) - })), - summary: liveStatus.Summary ? { - status: liveStatus.Summary.Status, - lastUpdated: liveStatus.Summary.LastUpdated - } : undefined - } - }), - }, - ], - }; + const liveStatus = await observabilityRepository.getLiveStatus( + projectId, + environmentId, + tenantId, + summaryOnly + ); + + if (!liveStatus || (!liveStatus.MachineStatuses && !liveStatus.Summary)) { + throw new Error( + `No Kubernetes live status found for project '${projectId}' in environment '${environmentId}'${tenantId ? ` for tenant '${tenantId}'` : ''}. ` + + "This may indicate that the project is not deployed to Kubernetes in this environment, or the resources are not being monitored." + ); + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + projectId, + environmentId, + tenantId, + summaryOnly: summaryOnly, + liveStatus: { + machineStatuses: liveStatus.MachineStatuses?.map((machine: KubernetesMachineLiveStatusResource) => ({ + machineId: machine.MachineId, + status: machine.Status, + resources: machine.Resources?.map((resource: KubernetesLiveStatusResource) => ({ + name: resource.Name, + namespace: resource.Namespace, + kind: resource.Kind, + healthStatus: resource.HealthStatus, + syncStatus: resource.SyncStatus, + machineId: resource.MachineId, + children: resource.Children, + desiredResourceId: resource.DesiredResourceId, + resourceId: resource.ResourceId + })) + })), + summary: liveStatus.Summary ? { + status: liveStatus.Summary.Status, + lastUpdated: liveStatus.Summary.LastUpdated + } : undefined + } + }), + }, + ], + }; + } catch (error) { + if (isErrorWithMessage(error, 'minimum version')) { + throw new Error( + `Kubernetes live status requires Octopus Deploy version 2025.3 or later. ` + + "This feature is not available in your Octopus Deploy instance version." + ); + } + handleOctopusApiError(error, { spaceName }); + } } ); } diff --git a/src/tools/getMissingTenantVariables.ts b/src/tools/getMissingTenantVariables.ts index b1b2cf4..cc55c00 100644 --- a/src/tools/getMissingTenantVariables.ts +++ b/src/tools/getMissingTenantVariables.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { registerToolDefinition } from "../types/toolConfig.js"; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from "../helpers/errorHandling.js"; export function registerGetMissingTenantVariablesTool(server: McpServer) { server.tool( @@ -22,9 +23,15 @@ export function registerGetMissingTenantVariablesTool(server: McpServer) { readOnlyHint: true, }, async ({ spaceName, tenantId, projectId, environmentId, includeDetails = false }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const tenantRepository = new TenantRepository(client, spaceName); + if (tenantId) { + validateEntityId(tenantId, 'tenant', ENTITY_PREFIXES.tenant); + } + if (projectId) { + validateEntityId(projectId, 'project', ENTITY_PREFIXES.project); + } + if (environmentId) { + validateEntityId(environmentId, 'environment', ENTITY_PREFIXES.environment); + } const filterOptions = { tenantId, @@ -32,20 +39,44 @@ export function registerGetMissingTenantVariablesTool(server: McpServer) { environmentId }; - const missingVariables = await tenantRepository.missingVariables(filterOptions, includeDetails); - - return { - content: [ - { - type: "text", - text: JSON.stringify({ - filters: filterOptions, - includeDetails, - missingVariables - }), - }, - ], - }; + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const tenantRepository = new TenantRepository(client, spaceName); + + const missingVariables = await tenantRepository.missingVariables(filterOptions, includeDetails); + + if (!missingVariables || (Array.isArray(missingVariables) && missingVariables.length === 0)) { + const filterDescription = Object.entries(filterOptions) + .filter(([, value]) => value) + .map(([key, value]) => `${key}: ${value}`) + .join(', ') || 'no filters'; + + return { + content: [ + { + type: "text", + text: `No missing tenant variables found with filters: ${filterDescription}. All required variables appear to be configured.`, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + filters: filterOptions, + includeDetails, + missingVariables + }), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { spaceName }); + } } ); } diff --git a/src/tools/getReleaseById.ts b/src/tools/getReleaseById.ts index 423f6d9..179adc2 100644 --- a/src/tools/getReleaseById.ts +++ b/src/tools/getReleaseById.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { registerToolDefinition } from "../types/toolConfig.js"; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from "../helpers/errorHandling.js"; export function registerGetReleaseByIdTool(server: McpServer) { server.tool( @@ -17,32 +18,43 @@ export function registerGetReleaseByIdTool(server: McpServer) { readOnlyHint: true, }, async ({ spaceName, releaseId }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const releaseRepository = new ReleaseRepository(client, spaceName); + validateEntityId(releaseId, 'release', ENTITY_PREFIXES.release); - const release = await releaseRepository.get(releaseId); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const releaseRepository = new ReleaseRepository(client, spaceName); - return { - content: [ - { - type: "text", - text: JSON.stringify({ - id: release.Id, - version: release.Version, - channelId: release.ChannelId, - projectId: release.ProjectId, - releaseNotes: release.ReleaseNotes, - assembled: release.Assembled, - ignoreChannelRules: release.IgnoreChannelRules, - selectedPackages: release.SelectedPackages, - selectedGitResources: release.SelectedGitResources, - buildInformation: release.BuildInformation, - customFields: release.CustomFields - }), - }, - ], - }; + const release = await releaseRepository.get(releaseId); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + id: release.Id, + version: release.Version, + channelId: release.ChannelId, + projectId: release.ProjectId, + releaseNotes: release.ReleaseNotes, + assembled: release.Assembled, + ignoreChannelRules: release.IgnoreChannelRules, + selectedPackages: release.SelectedPackages, + selectedGitResources: release.SelectedGitResources, + buildInformation: release.BuildInformation, + customFields: release.CustomFields + }), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: 'release', + entityId: releaseId, + spaceName, + helpText: "Use list_releases or list_releases_for_project to find valid release IDs." + }); + } } ); } diff --git a/src/tools/getTaskById.ts b/src/tools/getTaskById.ts index 20dff92..1219505 100644 --- a/src/tools/getTaskById.ts +++ b/src/tools/getTaskById.ts @@ -4,6 +4,7 @@ import { getClientConfigurationFromEnvironment } from '../helpers/getClientConfi import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { registerToolDefinition } from '../types/toolConfig.js'; import { tasksDescription } from '../types/taskTypes.js'; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from '../helpers/errorHandling.js'; export interface GetTaskByIdParams { spaceName: string; @@ -12,10 +13,8 @@ export interface GetTaskByIdParams { export async function getTaskById(client: Client, params: GetTaskByIdParams) { const { spaceName, taskId } = params; - - if (!taskId) { - throw new Error("Task ID is required"); - } + + validateEntityId(taskId, 'task', ENTITY_PREFIXES.task); const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); const response = await serverTaskRepository.getById(taskId); @@ -33,25 +32,32 @@ export function registerGetTaskByIdTool(server: McpServer) { }, async (args) => { const { spaceName, taskId } = args as GetTaskByIdParams; - - if (!taskId) { - throw new Error("Task ID is required"); - } - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); - - const response = await serverTaskRepository.getById(taskId); - - return { - content: [ - { - type: "text", - text: JSON.stringify(response), - }, - ], - }; + validateEntityId(taskId, 'task', ENTITY_PREFIXES.task); + + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); + + const response = await serverTaskRepository.getById(taskId); + + return { + content: [ + { + type: "text", + text: JSON.stringify(response), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: 'task', + entityId: taskId, + spaceName, + helpText: "Use list_deployments or list_releases to find valid task IDs." + }); + } } ); } diff --git a/src/tools/getTaskDetails.ts b/src/tools/getTaskDetails.ts index 6013e90..5710e5a 100644 --- a/src/tools/getTaskDetails.ts +++ b/src/tools/getTaskDetails.ts @@ -4,6 +4,7 @@ import { getClientConfigurationFromEnvironment } from '../helpers/getClientConfi import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { registerToolDefinition } from '../types/toolConfig.js'; import { tasksDescription } from '../types/taskTypes.js'; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from '../helpers/errorHandling.js'; export interface GetTaskDetailsParams { spaceName: string; @@ -12,10 +13,8 @@ export interface GetTaskDetailsParams { export async function getTaskDetails(client: Client, params: GetTaskDetailsParams) { const { spaceName, taskId } = params; - - if (!taskId) { - throw new Error("Task ID is required"); - } + + validateEntityId(taskId, 'task', ENTITY_PREFIXES.task); const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); const response = await serverTaskRepository.getDetails(taskId); @@ -33,25 +32,32 @@ export function registerGetTaskDetailsTool(server: McpServer) { }, async (args) => { const { spaceName, taskId } = args as GetTaskDetailsParams; - - if (!taskId) { - throw new Error("Task ID is required"); - } - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); - - const response = await serverTaskRepository.getDetails(taskId); - - return { - content: [ - { - type: "text", - text: JSON.stringify(response), - }, - ], - }; + validateEntityId(taskId, 'task', ENTITY_PREFIXES.task); + + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); + + const response = await serverTaskRepository.getDetails(taskId); + + return { + content: [ + { + type: "text", + text: JSON.stringify(response), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: 'task', + entityId: taskId, + spaceName, + helpText: "Use list_deployments or list_releases to find valid task IDs." + }); + } } ); } diff --git a/src/tools/getTaskRaw.ts b/src/tools/getTaskRaw.ts index 5ba7293..c2b579e 100644 --- a/src/tools/getTaskRaw.ts +++ b/src/tools/getTaskRaw.ts @@ -3,6 +3,7 @@ import { z } from 'zod'; import { getClientConfigurationFromEnvironment } from '../helpers/getClientConfigurationFromEnvironment.js'; import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { registerToolDefinition } from '../types/toolConfig.js'; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from '../helpers/errorHandling.js'; export interface GetTaskRawParams { spaceName: string; @@ -11,10 +12,8 @@ export interface GetTaskRawParams { export async function getTaskRaw(client: Client, params: GetTaskRawParams) { const { spaceName, taskId } = params; - - if (!taskId) { - throw new Error("Task ID is required"); - } + + validateEntityId(taskId, 'task', ENTITY_PREFIXES.task); const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); const response = await serverTaskRepository.getRaw(taskId); @@ -32,25 +31,32 @@ export function registerGetTaskRawTool(server: McpServer) { }, async (args) => { const { spaceName, taskId } = args as GetTaskRawParams; - - if (!taskId) { - throw new Error("Task ID is required"); - } - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); - - const response = await serverTaskRepository.getRaw(taskId); - - return { - content: [ - { - type: "text", - text: response, - }, - ], - }; + validateEntityId(taskId, 'task', ENTITY_PREFIXES.task); + + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); + + const response = await serverTaskRepository.getRaw(taskId); + + return { + content: [ + { + type: "text", + text: response, + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: 'task', + entityId: taskId, + spaceName, + helpText: "Use list_deployments or list_releases to find valid task IDs." + }); + } } ); } diff --git a/src/tools/getTenantById.ts b/src/tools/getTenantById.ts index edc7b41..d3d51f6 100644 --- a/src/tools/getTenantById.ts +++ b/src/tools/getTenantById.ts @@ -5,6 +5,7 @@ import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfi import { registerToolDefinition } from "../types/toolConfig.js"; import { tenantsDescription } from "../types/tenantsTypes.js"; import { getPublicUrl } from "../helpers/getPublicUrl.js"; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from "../helpers/errorHandling.js"; export function registerGetTenantByIdTool(server: McpServer) { server.tool( @@ -19,30 +20,40 @@ export function registerGetTenantByIdTool(server: McpServer) { readOnlyHint: true, }, async ({ spaceName, tenantId }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const tenantRepository = new TenantRepository(client, spaceName); + validateEntityId(tenantId, 'tenant', ENTITY_PREFIXES.tenant); - const tenant = await tenantRepository.get(tenantId); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const tenantRepository = new TenantRepository(client, spaceName); - return { - content: [ - { - type: "text", - text: JSON.stringify({ - id: tenant.Id, - name: tenant.Name, - description: tenant.Description, - projectEnvironments: tenant.ProjectEnvironments, - tenantTags: tenant.TenantTags, - clonedFromTenantId: tenant.ClonedFromTenantId, - spaceId: tenant.SpaceId, - publicUrl: getPublicUrl(`${configuration.instanceURL}/app#/{spaceId}/tenants/{tenantId}/overview`, { spaceId: tenant.SpaceId, tenantId: tenant.Id }), - publicUrlInstruction: `You can view more details about this tenant in the Octopus Deploy web portal at the provided publicUrl.` - }), - }, - ], - }; + const tenant = await tenantRepository.get(tenantId); + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + id: tenant.Id, + name: tenant.Name, + description: tenant.Description, + projectEnvironments: tenant.ProjectEnvironments, + tenantTags: tenant.TenantTags, + clonedFromTenantId: tenant.ClonedFromTenantId, + spaceId: tenant.SpaceId, + publicUrl: getPublicUrl(`${configuration.instanceURL}/app#/{spaceId}/tenants/{tenantId}/overview`, { spaceId: tenant.SpaceId, tenantId: tenant.Id }), + publicUrlInstruction: `You can view more details about this tenant in the Octopus Deploy web portal at the provided publicUrl.` + }), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: 'tenant', + entityId: tenantId, + spaceName + }); + } } ); } diff --git a/src/tools/getTenantVariables.ts b/src/tools/getTenantVariables.ts index 6e272a2..996a6c5 100644 --- a/src/tools/getTenantVariables.ts +++ b/src/tools/getTenantVariables.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { registerToolDefinition } from "../types/toolConfig.js"; +import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from "../helpers/errorHandling.js"; export function registerGetTenantVariablesTool(server: McpServer) { server.tool( @@ -24,39 +25,60 @@ export function registerGetTenantVariablesTool(server: McpServer) { readOnlyHint: true, }, async ({ spaceName, tenantId, variableType, includeMissingVariables = false }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const tenantRepository = new TenantRepository(client, spaceName); + validateEntityId(tenantId, 'tenant', ENTITY_PREFIXES.tenant); - let variables; - - switch (variableType) { - case "all": { - const tenant = await tenantRepository.get(tenantId); - variables = await tenantRepository.getVariables(tenant); - break; - } - case "common": - variables = await tenantRepository.getCommonVariablesById(tenantId, includeMissingVariables); - break; - case "project": - variables = await tenantRepository.getProjectVariablesById(tenantId, includeMissingVariables); - break; + if (!variableType) { + throw new Error( + "Variable type is required. Valid values are: 'all', 'common', or 'project'. " + + "'all' returns all variables, 'common' returns shared variables, 'project' returns project-specific variables." + ); } - return { - content: [ - { - type: "text", - text: JSON.stringify({ - tenantId, - variableType, - includeMissingVariables, - variables - }), - }, - ], - }; + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const tenantRepository = new TenantRepository(client, spaceName); + + let variables; + + switch (variableType) { + case "all": { + const tenant = await tenantRepository.get(tenantId); + variables = await tenantRepository.getVariables(tenant); + break; + } + case "common": + variables = await tenantRepository.getCommonVariablesById(tenantId, includeMissingVariables); + break; + case "project": + variables = await tenantRepository.getProjectVariablesById(tenantId, includeMissingVariables); + break; + default: + throw new Error( + `Invalid variable type '${variableType}'. Valid values are: 'all', 'common', or 'project'.` + ); + } + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + tenantId, + variableType, + includeMissingVariables, + variables + }), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: 'tenant', + entityId: tenantId, + spaceName + }); + } } ); } diff --git a/src/tools/listEnvironments.ts b/src/tools/listEnvironments.ts index a721341..84f9b5f 100644 --- a/src/tools/listEnvironments.ts +++ b/src/tools/listEnvironments.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { registerToolDefinition } from "../types/toolConfig.js"; +import { handleOctopusApiError } from "../helpers/errorHandling.js"; export function registerListEnvironmentsTool(server: McpServer) { server.tool( @@ -16,30 +17,49 @@ export function registerListEnvironmentsTool(server: McpServer) { readOnlyHint: true, }, async ({ spaceName, partialName }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const environmentRepository = new EnvironmentRepository(client, spaceName); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const environmentRepository = new EnvironmentRepository(client, spaceName); - const environmentsResponse = await environmentRepository.list({ partialName }); - const environments = environmentsResponse.Items.map((environment: DeploymentEnvironment) => ({ - spaceId: environment.SpaceId, - id: environment.Id, - name: environment.Name, - description: environment.Description, - sortOrder: environment.SortOrder, - useGuidedFailure: environment.UseGuidedFailure, - allowDynamicInfrastructure: environment.AllowDynamicInfrastructure, - extensionSettings: environment.ExtensionSettings, - })); + const environmentsResponse = await environmentRepository.list({ partialName }); + const environments = environmentsResponse.Items.map((environment: DeploymentEnvironment) => ({ + spaceId: environment.SpaceId, + id: environment.Id, + name: environment.Name, + description: environment.Description, + sortOrder: environment.SortOrder, + useGuidedFailure: environment.UseGuidedFailure, + allowDynamicInfrastructure: environment.AllowDynamicInfrastructure, + extensionSettings: environment.ExtensionSettings, + })); - return { - content: [ - { - type: "text", - text: JSON.stringify(environments), - }, - ], - }; + if (environments.length === 0) { + const message = partialName + ? `No environments found matching '${partialName}' in space '${spaceName}'. Environment names are case-sensitive.` + : `No environments found in space '${spaceName}'. This space may not have any environments configured.`; + + return { + content: [ + { + type: "text", + text: message, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify(environments), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { spaceName }); + } } ); } diff --git a/src/tools/listProjects.ts b/src/tools/listProjects.ts index 95ca6ae..ba98851 100644 --- a/src/tools/listProjects.ts +++ b/src/tools/listProjects.ts @@ -4,6 +4,7 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerToolDefinition } from "../types/toolConfig.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { projectsDescription } from "../types/projectTypes.js"; +import { handleOctopusApiError } from "../helpers/errorHandling.js"; export function registerListProjectsTool(server: McpServer) { server.tool( @@ -15,34 +16,53 @@ export function registerListProjectsTool(server: McpServer) { readOnlyHint: true, }, async ({ spaceName, partialName }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const projectRepository = new ProjectRepository(client, spaceName); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const projectRepository = new ProjectRepository(client, spaceName); - const projectsResponse = await projectRepository.list({ partialName }); - const projects = projectsResponse.Items.map((project: Project) => ({ - spaceId: project.SpaceId, - id: project.Id, - name: project.Name, - description: project.Description, - slug: project.Slug, - deploymentProcessId: project.DeploymentProcessId, - lifecycleId: project.LifecycleId, - isDisabled: project.IsDisabled, - repositoryUrl: - project.PersistenceSettings.Type === "VersionControlled" - ? project.PersistenceSettings.Url - : null, - })); + const projectsResponse = await projectRepository.list({ partialName }); + const projects = projectsResponse.Items.map((project: Project) => ({ + spaceId: project.SpaceId, + id: project.Id, + name: project.Name, + description: project.Description, + slug: project.Slug, + deploymentProcessId: project.DeploymentProcessId, + lifecycleId: project.LifecycleId, + isDisabled: project.IsDisabled, + repositoryUrl: + project.PersistenceSettings.Type === "VersionControlled" + ? project.PersistenceSettings.Url + : null, + })); - return { - content: [ - { - type: "text", - text: JSON.stringify(projects), - }, - ], - }; + if (projects.length === 0) { + const message = partialName + ? `No projects found matching '${partialName}' in space '${spaceName}'. Project names are case-sensitive.` + : `No projects found in space '${spaceName}'. This space may be empty or you may not have permission to view projects.`; + + return { + content: [ + { + type: "text", + text: message, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify(projects), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { spaceName }); + } } ); } diff --git a/src/tools/listSpaces.ts b/src/tools/listSpaces.ts index 108f3c5..1bac018 100644 --- a/src/tools/listSpaces.ts +++ b/src/tools/listSpaces.ts @@ -4,6 +4,7 @@ import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfi import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { registerToolDefinition } from "../types/toolConfig.js"; import { spacesDescription } from "../types/spaceTypes.js"; +import { handleOctopusApiError } from "../helpers/errorHandling.js"; export function registerListSpacesTool(server: McpServer) { server.tool( @@ -15,27 +16,46 @@ export function registerListSpacesTool(server: McpServer) { readOnlyHint: true, }, async ({ partialName }) => { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const spaceRepository = new SpaceRepository(client); + try { + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const spaceRepository = new SpaceRepository(client); - const spacesResponse = await spaceRepository.list({ partialName }); - const spaces = spacesResponse.Items.map((space) => ({ - id: space.Id, - name: space.Name, - description: space.Description, - isDefault: space.IsDefault, - taskQueueStopped: space.TaskQueueStopped, - })); + const spacesResponse = await spaceRepository.list({ partialName }); + const spaces = spacesResponse.Items.map((space) => ({ + id: space.Id, + name: space.Name, + description: space.Description, + isDefault: space.IsDefault, + taskQueueStopped: space.TaskQueueStopped, + })); - return { - content: [ - { - type: "text", - text: JSON.stringify(spaces), - }, - ], - }; + if (spaces.length === 0) { + const message = partialName + ? `No spaces found matching '${partialName}'. Space names are case-sensitive.` + : "No spaces found. This may indicate a configuration or permission issue."; + + return { + content: [ + { + type: "text", + text: message, + }, + ], + }; + } + + return { + content: [ + { + type: "text", + text: JSON.stringify(spaces), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, {}); + } } ); }