Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
94 changes: 94 additions & 0 deletions src/helpers/errorHandling.ts
Original file line number Diff line number Diff line change
@@ -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;
49 changes: 30 additions & 19 deletions src/tools/getAccount.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<AccountResource>(
"~/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<AccountResource>(
"~/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
});
}
}
);
}
Expand Down
64 changes: 41 additions & 23 deletions src/tools/getBranches.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -23,37 +24,54 @@ 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,
skip,
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
});
}
}
);
}
Expand Down
49 changes: 30 additions & 19 deletions src/tools/getCertificate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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<CertificateResource>(
"~/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<CertificateResource>(
"~/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
});
}
}
);
}
Expand Down
47 changes: 26 additions & 21 deletions src/tools/getCurrentUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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<CurrentUser>("~/api/users/me");
const user = await client.get<CurrentUser>("~/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, {});
}
}
);
}
Expand Down
Loading