Skip to content
Open
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
2,188 changes: 2,124 additions & 64 deletions package-lock.json

Large diffs are not rendered by default.

10 changes: 8 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@
"build": "tsc && shx chmod +x dist/*.js",
"dev": "nodemon --quiet --exec \"tsc && node dist/index.js\" --ext ts --watch src",
"lint": "eslint src --ext .ts",
"lint:fix": "eslint src --ext .ts --fix"
"lint:fix": "eslint src --ext .ts --fix",
"test": "vitest run --reporter=verbose",
"test:watch": "vitest",
"test:coverage": "vitest run --coverage"
},
"keywords": [
"octopus deploy",
Expand All @@ -41,12 +44,15 @@
"devDependencies": {
"@eslint/js": "^9.35.0",
"@types/node": "^24.3.1",
"@vitest/coverage-v8": "^2.1.8",
"@vitest/ui": "^2.1.8",
"eslint": "^9.35.0",
"globals": "^16.3.0",
"jiti": "^2.5.1",
"nodemon": "^3.1.10",
"shx": "^0.4.0",
"typescript": "^5.9.2",
"typescript-eslint": "^8.42.0"
"typescript-eslint": "^8.42.0",
"vitest": "^2.1.8"
}
}
2 changes: 1 addition & 1 deletion src/helpers/getClientConfigurationFromEnvironment.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { type ClientConfiguration } from "@octopusdeploy/api-client";
import { env } from "process";
import { SEMVER_VERSION } from "../index.js";
import { SEMVER_VERSION } from "../utils/version.js";
import { getClientInfo } from "../utils/clientInfo.js";

const USER_AGENT_NAME = "octopus-mcp-server";
Expand Down
10 changes: 4 additions & 6 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,7 @@ import { DEFAULT_TOOLSETS, printToolVersionAnalysis } from "./types/toolConfig.j
import { getClientConfigurationFromEnvironment } from "./helpers/getClientConfigurationFromEnvironment.js";
import { setClientInfo } from "./utils/clientInfo.js";
import { logger } from "./utils/logger.js";
import packageJson from "../package.json" with { type: "json" };

export const SEMVER_VERSION = packageJson.version;
import { SEMVER_VERSION } from "./utils/version.js";

dotenv.config({ quiet: true });

Expand Down Expand Up @@ -61,9 +59,6 @@ if (options.apiKey) {
process.env.CLI_API_KEY = options.apiKey;
}

// Test configuration
getClientConfigurationFromEnvironment();

// Set up initialization callback to capture client info
server.server.oninitialized = () => {
const clientInfo = server.server.getClientVersion();
Expand All @@ -79,6 +74,9 @@ logger.info(`Starting Octopus Deploy MCP server (version: ${SEMVER_VERSION})`);

// Start server
async function runServer() {
// Test configuration
getClientConfigurationFromEnvironment();

const transport = new StdioServerTransport();
await server.connect(transport);
}
Expand Down
51 changes: 51 additions & 0 deletions src/tools/__tests__/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Integration Tests

This directory contains integration tests for MCP server tools that test against a real Octopus Deploy instance.

## Setup

1. Install dependencies:
```bash
npm install
```

2. Create a `.env` file in the project root with your Octopus Deploy credentials:
```bash
cp .env.example .env
```

3. Edit `.env` and add your actual Octopus Deploy instance URL and API key:
```
OCTOPUS_SERVER_URL=https://your-octopus-instance.octopus.app
OCTOPUS_API_KEY=API-XXXXXXXXXXXXXXXXXXXXXXXXXX
TEST_SPACE_NAME=Default
```

## Running Tests

```bash
# Run all tests once
npm test

# Run tests in watch mode during development
npm run test:watch

# Run tests with coverage report
npm run test:coverage

# Run tests with UI (optional)
npx vitest --ui
```

## Test Structure

- **Integration Tests**: Test against real Octopus Deploy API
- **Environment Validation**: Ensures required credentials are available

## Writing New Tests

1. Create a new test file following the pattern: `toolName.integration.test.ts`
2. Use the shared test utilities from `testSetup.ts`
3. Separate the tool registration with the MCP server with the API call handler
4. Follow the existing patterns for success and error scenarios
5. Register your tool with the mock server before testing
94 changes: 94 additions & 0 deletions src/tools/__tests__/listTenants.integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, it, expect } from "vitest";
import { testConfig, parseToolResponse } from "./testSetup.js";
import { listTenantsHandler } from "../listTenants.js";

describe("listTenants Integration Tests", () => {
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not too much value in these tests but they do make it easier to make changes and quickly checks that things are not completely broken.

describe("Successful scenarios", () => {
it("should list all tenants in the test space", async () => {
const response = await listTenantsHandler({
spaceName: testConfig.testSpaceName,
});

const data = parseToolResponse(response);

expect(data).toHaveProperty("totalResults");
expect(data).toHaveProperty("itemsPerPage");
expect(data).toHaveProperty("numberOfPages");
expect(data).toHaveProperty("lastPageNumber");
expect(data).toHaveProperty("items");
expect(Array.isArray(data.items)).toBe(true);

// Verify tenant structure if any tenants exist
if (data.items.length > 0) {
const tenant = data.items[0];
expect(tenant).toHaveProperty("id");
expect(tenant).toHaveProperty("name");
expect(tenant).toHaveProperty("description");
expect(tenant).toHaveProperty("projectEnvironments");
expect(tenant).toHaveProperty("tenantTags");
expect(tenant).toHaveProperty("spaceId");
expect(tenant).toHaveProperty("publicUrl");
expect(tenant).toHaveProperty("publicUrlInstruction");
expect(typeof tenant.id).toBe("string");
expect(typeof tenant.name).toBe("string");
expect(typeof tenant.publicUrl).toBe("string");
}
}, testConfig.timeout);

it("should support pagination with skip and take parameters", async () => {
const response = await listTenantsHandler({
spaceName: testConfig.testSpaceName,
skip: 0,
take: 5,
});

const data = parseToolResponse(response);

expect(data).toHaveProperty("totalResults");
expect(data).toHaveProperty("itemsPerPage");
expect(data.itemsPerPage).toBe(5);
expect(data.items.length).toBeLessThanOrEqual(5);
}, testConfig.timeout);

it("should support filtering by partial name", async () => {
const response = await listTenantsHandler({
spaceName: testConfig.testSpaceName,
partialName: "test",
});

const data = parseToolResponse(response);
expect(data).toHaveProperty("items");
expect(Array.isArray(data.items)).toBe(true);

// If results are found, verify they contain the search term
data.items.forEach((tenant: any) => {
if (tenant.name) {
expect(tenant.name.toLowerCase()).toContain("test");
}
});
}, testConfig.timeout);
});

describe("Error scenarios", () => {
it("should throw error for non-existent space", async () => {
await expect(
listTenantsHandler({
spaceName: "NonExistentSpace123456",
})
).rejects.toThrow();
}, testConfig.timeout);

it("should handle empty results gracefully", async () => {
const response = await listTenantsHandler({
spaceName: testConfig.testSpaceName,
partialName: "ThisTenantNameShouldNotExist123456789",
});

const data = parseToolResponse(response);
expect(data).toHaveProperty("items");
expect(Array.isArray(data.items)).toBe(true);
expect(data.items.length).toBe(0);
expect(data.totalResults).toBe(0);
}, testConfig.timeout);
});
});
64 changes: 64 additions & 0 deletions src/tools/__tests__/testSetup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { beforeAll, expect } from "vitest";
import { config } from "dotenv";

// Load environment variables from .env files
config();

export const testConfig = {
octopusServerUrl: process.env.OCTOPUS_SERVER_URL || process.env.CLI_SERVER_URL,
octopusApiKey: process.env.OCTOPUS_API_KEY || process.env.CLI_API_KEY,
testSpaceName: process.env.TEST_SPACE_NAME || "Default",
timeout: 30000, // 30 seconds
};

export function validateTestEnvironment(): void {
const missing: string[] = [];

if (!testConfig.octopusServerUrl) {
missing.push("OCTOPUS_SERVER_URL (or CLI_SERVER_URL)");
}

if (!testConfig.octopusApiKey) {
missing.push("OCTOPUS_API_KEY (or CLI_API_KEY)");
}

if (missing.length > 0) {
throw new Error(
`Missing required environment variables for integration tests: ${missing.join(", ")}. ` +
"Please set these variables or create a .env file in the project root."
);
}
}

/**
* Helper function to create a standardized error test case
*/
export function createErrorTestCase(
description: string,
setupFn: () => void,
expectedErrorMessage?: string
) {
return {
description,
setup: setupFn,
expectedErrorMessage,
};
}

export function assertToolResponse(response: any): void {
expect(response).toBeDefined();
expect(response.content).toBeDefined();
expect(Array.isArray(response.content)).toBe(true);
expect(response.content.length).toBeGreaterThan(0);
expect(response.content[0].type).toBe("text");
expect(response.content[0].text).toBeDefined();
}

export function parseToolResponse(response: any): any {
assertToolResponse(response);
return JSON.parse(response.content[0].text);
}

beforeAll(() => {
validateTestEnvironment();
});
95 changes: 54 additions & 41 deletions src/tools/listTenants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,64 @@ import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfi
import { registerToolDefinition } from "../types/toolConfig.js";
import { getPublicUrl } from "../helpers/getPublicUrl.js";

export interface ListTenantsParams {
spaceName: string;
skip?: number;
take?: number;
projectId?: string;
tags?: string;
ids?: string[];
partialName?: string;
}

export async function listTenantsHandler(params: ListTenantsParams) {
const { spaceName, skip, take, projectId, tags, ids, partialName } = params;
const configuration = getClientConfigurationFromEnvironment();
const client = await Client.create(configuration);
const tenantRepository = new TenantRepository(client, spaceName);

const tenantsResponse = await tenantRepository.list({
skip,
take,
projectId,
tags,
ids,
partialName
});

return {
content: [
{
type: "text" as const,
text: JSON.stringify({
totalResults: tenantsResponse.TotalResults,
itemsPerPage: tenantsResponse.ItemsPerPage,
numberOfPages: tenantsResponse.NumberOfPages,
lastPageNumber: tenantsResponse.LastPageNumber,
items: tenantsResponse.Items.map(tenant => ({
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.`
}))
}),
},
],
};
}

export function registerListTenantsTool(server: McpServer) {
server.tool(
"list_tenants",
`List tenants in a space

This tool lists all tenants in a given space. The space name is required. Optionally provide skip and take parameters for pagination.`,
{
{
spaceName: z.string().describe("The space name"),
skip: z.number().optional().describe("Number of items to skip for pagination"),
take: z.number().optional().describe("Number of items to take for pagination"),
Expand All @@ -24,45 +75,7 @@ export function registerListTenantsTool(server: McpServer) {
title: "List all tenants in an Octopus Deploy space",
readOnlyHint: true,
},
async ({ spaceName, skip, take, projectId, tags, ids, partialName }) => {
const configuration = getClientConfigurationFromEnvironment();
const client = await Client.create(configuration);
const tenantRepository = new TenantRepository(client, spaceName);

const tenantsResponse = await tenantRepository.list({
skip,
take,
projectId,
tags,
ids,
partialName
});

return {
content: [
{
type: "text",
text: JSON.stringify({
totalResults: tenantsResponse.TotalResults,
itemsPerPage: tenantsResponse.ItemsPerPage,
numberOfPages: tenantsResponse.NumberOfPages,
lastPageNumber: tenantsResponse.LastPageNumber,
items: tenantsResponse.Items.map(tenant => ({
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.`
}))
}),
},
],
};
}
listTenantsHandler
);
}

Expand Down
Loading