From 91aa162c03675a0cbea45c5996a4570ebc1197e3 Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Fri, 26 Sep 2025 15:46:37 +0930 Subject: [PATCH 01/11] WIP --- src/tools/getVariables.ts | 414 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 414 insertions(+) create mode 100644 src/tools/getVariables.ts diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts new file mode 100644 index 0000000..1324585 --- /dev/null +++ b/src/tools/getVariables.ts @@ -0,0 +1,414 @@ +import { + Client, + type DeploymentEnvironment, type Project, + ProjectRepository, + resolveSpaceId, type ResourcesById, + type TenantVariable, type VersionControlledPersistenceSettings +} from "@octopusdeploy/api-client"; +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 type {Tenant} from "@octopusdeploy/api-client/dist/features/tenants/tenant.js"; + +export function registerGetTenantVariablesTool(server: McpServer) { + server.tool( + "get_variables", + `Get all variables for a project. This tool retrieves all variables available to a project, + including project variables, library variable set variables, and tenant variables. + Results include variable names, values, and scopes. + `, + { + spaceName: z.string().describe("The space name"), + projectId: z.string().describe("The ID of the project to retrieve the variables for"), + gitRef: z.string().describe("The gitRef to retrieve the variables from, if the project is a config-as-code project").optional(), + }, + { + title: "Get variables for a Project from Octopus Deploy", + readOnlyHint: true, + }, + async ({ spaceName, projectId, gitRef }) => { + + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const spaceId = await resolveSpaceId(client, spaceName); + + const variables = getAllVariables({ + projectId: projectId, + spaceName: spaceName, + spaceId: spaceId, + gitRef: gitRef, + ephemeralEnvironmentsAreEnabled: false + }, client) + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + variables + }), + }, + ], + }; + } + ); +} + +registerToolDefinition({ + toolName: "get_tenant_variables", + config: { toolset: "tenants", readOnly: true }, + registerFn: registerGetTenantVariablesTool, +}); + +export type VariableResource = VariableResourceBase; + +export interface VariableResourceBase>, TVariablePromptOptions extends Readonly> { + Id: string; + Name: string; + Value: string | null; + Description: string | undefined; + Scope: TScopeSpecification; + IsEditable: boolean; + Prompt: TVariablePromptOptions | null; + Type: VariableType; + IsSensitive: boolean; //false; // For backwards compatibility +} + +type Arrays = { + [P in keyof T]: Array; +}; + +type ReadonlyArrays = { + [P in keyof T]: ReadonlyArray; +}; + +export type ScopeSpecification = Arrays; + +export interface ScopeSpecificationTypes { + Environment?: string; + Machine?: string; + Role?: string; + Action?: string; + Channel?: string; + TenantTag?: string; + ProcessOwner?: string; +} + +export interface VariablePromptOptions { + Label: string; + Description: string; + Required: boolean; + DisplaySettings: VariablePromptDisplaySettings; +} + +export enum ControlType { + SingleLineText = "SingleLineText", + MultiLineText = "MultiLineText", + Select = "Select", + Checkbox = "Checkbox", + Sensitive = "Sensitive", + StepName = "StepName", + AzureAccount = "AzureAccount", + Certificate = "Certificate", + WorkerPool = "WorkerPool", + AmazonWebServicesAccount = "AmazonWebServicesAccount", + UsernamePasswordAccount = "UsernamePasswordAccount", + GoogleCloudAccount = "GoogleCloudAccount", + GenericOidcAccount = "GenericOidcAccount", + Package = "Package", + Custom = "Custom", + TargetTags = "TargetTags", + Feed = "Feed", + Environments = "Environments", + TenantTags = "TenantTags", + Teams = "Teams", + Channels = "Channels", + Project = "Project", +} + +export interface VariablePromptDisplaySettings { + "Octopus.ControlType"?: ControlType; + "Octopus.SelectOptions"?: string; +} + +export enum VariableType { + String = "String", + Sensitive = "Sensitive", + Certificate = "Certificate", + AmazonWebServicesAccount = "AmazonWebServicesAccount", + AzureAccount = "AzureAccount", + GoogleCloudAccount = "GoogleCloudAccount", + WorkerPool = "WorkerPool", + UsernamePasswordAccount = "UsernamePasswordAccount", + GenericOidcAccount = "GenericOidcAccount", +} + +export type ReferenceType = VariableType.Certificate | VariableType.AmazonWebServicesAccount | VariableType.AzureAccount | VariableType.WorkerPool | VariableType.GoogleCloudAccount | VariableType.GenericOidcAccount; +export type VariableAccountType = VariableType.AmazonWebServicesAccount | VariableType.AzureAccount | VariableType.GoogleCloudAccount | VariableType.UsernamePasswordAccount | VariableType.GenericOidcAccount; + + +interface VariableSetResource { + Id: string; + SpaceId: string; + OwnerId: string; + ScopeValues: ScopeValues; + Variables: VariableResource[]; + Version: number; +} + +interface ReferenceDataItem { + Id: string; + Name: string; +} + +interface ProcessReferenceDataItem extends ReferenceDataItem { + ProcessType: ProcessType; +} + +enum ProcessType { + Deployment = "Deployment", + Runbook = "Runbook", + ProcessTemplate = "ProcessTemplate", +} + +interface ScopeValues { + Actions: ReferenceDataItem[]; + Channels: ReferenceDataItem[]; + Environments: ReferenceDataItem[]; + Machines: ReferenceDataItem[]; + Roles: ReferenceDataItem[]; + TenantTags: ReferenceDataItem[]; + Processes: ProcessReferenceDataItem[]; +} + +enum VariableSetContentType { + Variables = "Variables", + ScriptModule = "ScriptModule", +} + +type PropertyValueResource = string | SensitiveValue | null; + +interface SensitiveValue { + HasValue: boolean; + // NewValue can also be null at runtime + NewValue?: string; + Hint?: string; +} + +interface ActionTemplateParameterResource { + Id: string; + Name: string; + Label: string; + HelpText: string; + DefaultValue?: PropertyValueResource; + DisplaySettings: unknown; + AllowClear?: boolean; +} + +interface LibraryVariableSetResource { + Name: string; + SpaceId: string; + Description: string; + VariableSetId: string; + ContentType: VariableSetContentType; + Templates: ActionTemplateParameterResource[]; +} + +interface LibraryVariableSetWithVariables { + variableSet: VariableSetResource; + libraryVariableSet: LibraryVariableSetResource; +} + +interface AllVariablesForProject { + projectVariableSet: VariableSetResource | undefined; + libraryVariableSets: LibraryVariableSetWithVariables[]; + tenants: Tenant[]; + tenantVariables: TenantVariable[]; + environments: DeploymentEnvironment[]; +} + +interface GetAllVariablesParams { + projectId: string; + gitRef?: string; + spaceName: string; + spaceId: string; + ephemeralEnvironmentsAreEnabled?: boolean; +} + +export async function getAllVariables( + params: GetAllVariablesParams, + apiClient: Client +): Promise { + + const { spaceId, spaceName, gitRef, projectId } = params; + + // 1. Get the project to understand its configuration + const projectRepository = new ProjectRepository(apiClient, spaceName); + const project = await projectRepository.get(projectId); + + // 2. Load project variables (handling git persistence) + const projectVariableSet = await loadProjectVariableSet(project, gitRef, apiClient, spaceId); + + // 3. Load library variable sets + const libraryVariableSets = await loadLibraryVariableSetVariables(project.IncludedLibraryVariableSetIds, apiClient, spaceId); + + // 4. Load tenants (if user has permission) + // TODO: Add permission check logic here - isAllowed({ permission: Permission.TenantView, tenant: "*" }) + const tenants = await loadTenants(project.Id, apiClient, spaceId); + + // 5. Load tenant variables + const tenantVariables = await loadTenantVariables(project.Id, apiClient, spaceId); + + // 6. Load environments + const environments = await loadEnvironments(params.ephemeralEnvironmentsAreEnabled ?? false, apiClient, params.spaceId); + + return { + projectVariableSet, + libraryVariableSets, + tenants, + tenantVariables, + environments + }; +} + +async function loadProjectVariableSet( + project: Project, + gitRef: string | undefined, + apiClient: Client, + spaceId: string +): Promise { + + function hasVariablesInGit(persistenceSettings: unknown): boolean { + // TODO: Implement persistence settings check + // This should check if the project is configured to store variables in git + return persistenceSettings?.VersioningStrategy?.Type === 'Git' && + persistenceSettings?.Variables?.Type === 'Git'; + } + + // Check if project has git persistence + const hasGitVariables = hasVariablesInGit(project.PersistenceSettings); + + if (hasGitVariables && !gitRef) { + // TODO: Should we throw an error here for MCP? + // If we've just changed from an invalid branch, GitRef might be null. Wait until it's set. + return undefined; + } + + if (hasGitVariables) { + // For git projects, we need to get both text and sensitive variables separately + + // Retrieve the variable set stored in git for the associated gitRef + const textVariableSet = await apiClient.get( + `/api/${spaceId}/projects/${project.Id}/${gitRef}/variables` + ); + + // Sensitive variables are still stored in the database so that they can be encrypted + const sensitiveVariableSet = await apiClient.get( + `/api/${spaceId}/projects/${project.Id}/variables` + ); + + // Combine variables from both sets + return { + ...textVariableSet, + Variables: [...textVariableSet.Variables, ...sensitiveVariableSet.Variables] + }; + } else { + // For database projects, get variables directly + return await apiClient.get(`/api/${spaceId}/variables/${project.VariableSetId}`); + } +} + +async function loadLibraryVariableSetVariables( + includedLibraryVariableSetIds: string[], + apiClient: Client, + spaceId: string +): Promise { + + // Get library variable sets + const libraryVariableSets = await apiClient.get( + `/api/${spaceId}/libraryvariablesets?ids=${includedLibraryVariableSetIds.join(',')}` + ); + + // Get all variable sets for the library variable sets + const variableSetIds = libraryVariableSets.map(lvs => lvs.VariableSetId); + const allVariableSets = await apiClient.get( + `/api/${spaceId}/variables?ids=${variableSetIds.join(',')}` + ); + + // Create lookup map + const allVariableSetsMap = allVariableSets.reduce((acc: ResourcesById, resource) => { + acc[resource.Id] = resource; + return acc; + }, {}); + + // Combine library variable sets with their variable sets + return libraryVariableSets.map(lvs => ({ + variableSet: allVariableSetsMap[lvs.VariableSetId], + libraryVariableSet: lvs + })); +} + +async function loadTenants( + projectId: string, + apiClient: Client, + spaceId: string +): Promise { + // TODO: Add permission check + try { + return await apiClient.get(`/api/${spaceId}/tenants?projectId=${projectId}`); + } catch { + // If no permission, return empty array + return []; + } +} + +async function loadTenantVariables( + projectId: string, + apiClient: Client, + spaceId: string +): Promise { + const response = await apiClient.get<{TenantVariableResources: TenantVariable[]}>( + `/bff/spaces/${spaceId}/projects/${projectId}/tenantvariables` + ); + return response.TenantVariableResources; +} + +async function loadEnvironments( + ephemeralEnvironmentsAreEnabled: boolean, + apiClient: Client, + spaceId: string, + ids?: string[] +): Promise { + + if (ephemeralEnvironmentsAreEnabled) { + // Load static and parent environments only + const queryParams = new URLSearchParams({ + spaceId, + skip: '0', + take: '2147483647', // Repository.takeAll equivalent + type: 'Static,Parent' + }); + + const response = await apiClient.get<{Environments: {Items: DeploymentEnvironment[]}}>( + `/* STUB: Environments V2 endpoint - /api/${spaceId}/environments/v2?${queryParams} */` + ); + + let environments = response.Environments.Items; + if (ids) { + environments = environments.filter(env => ids.includes(env.Id)); + } + return environments; + } else { + // Load all environments and map to V2 format + const queryParams = ids ? `?ids=${ids.join(',')}` : ''; + const environments = await apiClient.get(`/api/${spaceId}/environments${queryParams}`); + + // TODO: Implement mapFromEnvironmentResourceToEnvironmentV2Resource conversion + return environments.map(env => ({ + /* STUB: Convert EnvironmentResource to EnvironmentV2Resource */ + ...env, + // Add V2-specific properties as needed + } as DeploymentEnvironment)); + } +} From b269cb79d0183e5280a02652ea8e42d05a309c40 Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Fri, 26 Sep 2025 16:01:59 +0930 Subject: [PATCH 02/11] Adds function to correctly check if variables are in git --- src/tools/getVariables.ts | 89 +++++++++------------------------------ 1 file changed, 19 insertions(+), 70 deletions(-) diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts index 1324585..f0175c3 100644 --- a/src/tools/getVariables.ts +++ b/src/tools/getVariables.ts @@ -223,9 +223,7 @@ interface LibraryVariableSetWithVariables { interface AllVariablesForProject { projectVariableSet: VariableSetResource | undefined; libraryVariableSets: LibraryVariableSetWithVariables[]; - tenants: Tenant[]; tenantVariables: TenantVariable[]; - environments: DeploymentEnvironment[]; } interface GetAllVariablesParams { @@ -253,22 +251,13 @@ export async function getAllVariables( // 3. Load library variable sets const libraryVariableSets = await loadLibraryVariableSetVariables(project.IncludedLibraryVariableSetIds, apiClient, spaceId); - // 4. Load tenants (if user has permission) - // TODO: Add permission check logic here - isAllowed({ permission: Permission.TenantView, tenant: "*" }) - const tenants = await loadTenants(project.Id, apiClient, spaceId); - - // 5. Load tenant variables + // 4. Load tenant variables const tenantVariables = await loadTenantVariables(project.Id, apiClient, spaceId); - // 6. Load environments - const environments = await loadEnvironments(params.ephemeralEnvironmentsAreEnabled ?? false, apiClient, params.spaceId); - return { projectVariableSet, libraryVariableSets, - tenants, tenantVariables, - environments }; } @@ -279,11 +268,24 @@ async function loadProjectVariableSet( spaceId: string ): Promise { - function hasVariablesInGit(persistenceSettings: unknown): boolean { - // TODO: Implement persistence settings check - // This should check if the project is configured to store variables in git - return persistenceSettings?.VersioningStrategy?.Type === 'Git' && - persistenceSettings?.Variables?.Type === 'Git'; + // This is a bit hacky, but gets around the limitations of our ts client types without having to define + // a heap of new types. + // We are expecting the type to match { ConversionState: { VariablesAreInGit: true } } + // If the variables are stored in git. + function hasVariablesInGit(value: unknown): boolean { + if (typeof value !== 'object' || value === null || !('ConversionState' in value)) { + return false; + } + + const obj = value as Record; + const conversionState = obj.ConversionState; + + return ( + typeof conversionState === 'object' && + conversionState !== null && + 'VariablesAreInGit' in conversionState && + (conversionState as Record).VariablesAreInGit === true + ); } // Check if project has git persistence @@ -349,20 +351,6 @@ async function loadLibraryVariableSetVariables( })); } -async function loadTenants( - projectId: string, - apiClient: Client, - spaceId: string -): Promise { - // TODO: Add permission check - try { - return await apiClient.get(`/api/${spaceId}/tenants?projectId=${projectId}`); - } catch { - // If no permission, return empty array - return []; - } -} - async function loadTenantVariables( projectId: string, apiClient: Client, @@ -373,42 +361,3 @@ async function loadTenantVariables( ); return response.TenantVariableResources; } - -async function loadEnvironments( - ephemeralEnvironmentsAreEnabled: boolean, - apiClient: Client, - spaceId: string, - ids?: string[] -): Promise { - - if (ephemeralEnvironmentsAreEnabled) { - // Load static and parent environments only - const queryParams = new URLSearchParams({ - spaceId, - skip: '0', - take: '2147483647', // Repository.takeAll equivalent - type: 'Static,Parent' - }); - - const response = await apiClient.get<{Environments: {Items: DeploymentEnvironment[]}}>( - `/* STUB: Environments V2 endpoint - /api/${spaceId}/environments/v2?${queryParams} */` - ); - - let environments = response.Environments.Items; - if (ids) { - environments = environments.filter(env => ids.includes(env.Id)); - } - return environments; - } else { - // Load all environments and map to V2 format - const queryParams = ids ? `?ids=${ids.join(',')}` : ''; - const environments = await apiClient.get(`/api/${spaceId}/environments${queryParams}`); - - // TODO: Implement mapFromEnvironmentResourceToEnvironmentV2Resource conversion - return environments.map(env => ({ - /* STUB: Convert EnvironmentResource to EnvironmentV2Resource */ - ...env, - // Add V2-specific properties as needed - } as DeploymentEnvironment)); - } -} From 67a2969f7556fdf11a4c1be09a8fe59d6947e62d Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Fri, 26 Sep 2025 16:13:03 +0930 Subject: [PATCH 03/11] Cleanup --- src/tools/getVariables.ts | 61 ++++++--------------------------------- 1 file changed, 9 insertions(+), 52 deletions(-) diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts index f0175c3..7ce2412 100644 --- a/src/tools/getVariables.ts +++ b/src/tools/getVariables.ts @@ -1,22 +1,20 @@ import { Client, - type DeploymentEnvironment, type Project, + type Project, ProjectRepository, resolveSpaceId, type ResourcesById, - type TenantVariable, type VersionControlledPersistenceSettings + type TenantVariable } from "@octopusdeploy/api-client"; 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 type {Tenant} from "@octopusdeploy/api-client/dist/features/tenants/tenant.js"; -export function registerGetTenantVariablesTool(server: McpServer) { +export function registerGetVariablesTool(server: McpServer) { server.tool( "get_variables", `Get all variables for a project. This tool retrieves all variables available to a project, - including project variables, library variable set variables, and tenant variables. - Results include variable names, values, and scopes. + including project variables, library variable set variables, and tenant variables. Results include variable names, values, and scopes. `, { spaceName: z.string().describe("The space name"), @@ -56,14 +54,14 @@ export function registerGetTenantVariablesTool(server: McpServer) { } registerToolDefinition({ - toolName: "get_tenant_variables", - config: { toolset: "tenants", readOnly: true }, - registerFn: registerGetTenantVariablesTool, + toolName: "get_variables", + config: { toolset: "projects", readOnly: true }, + registerFn: registerGetVariablesTool, }); -export type VariableResource = VariableResourceBase; +export type VariableResource = VariableResourceBase>; -export interface VariableResourceBase>, TVariablePromptOptions extends Readonly> { +export interface VariableResourceBase>, TVariablePromptOptions extends Readonly> { Id: string; Name: string; Value: string | null; @@ -95,43 +93,6 @@ export interface ScopeSpecificationTypes { ProcessOwner?: string; } -export interface VariablePromptOptions { - Label: string; - Description: string; - Required: boolean; - DisplaySettings: VariablePromptDisplaySettings; -} - -export enum ControlType { - SingleLineText = "SingleLineText", - MultiLineText = "MultiLineText", - Select = "Select", - Checkbox = "Checkbox", - Sensitive = "Sensitive", - StepName = "StepName", - AzureAccount = "AzureAccount", - Certificate = "Certificate", - WorkerPool = "WorkerPool", - AmazonWebServicesAccount = "AmazonWebServicesAccount", - UsernamePasswordAccount = "UsernamePasswordAccount", - GoogleCloudAccount = "GoogleCloudAccount", - GenericOidcAccount = "GenericOidcAccount", - Package = "Package", - Custom = "Custom", - TargetTags = "TargetTags", - Feed = "Feed", - Environments = "Environments", - TenantTags = "TenantTags", - Teams = "Teams", - Channels = "Channels", - Project = "Project", -} - -export interface VariablePromptDisplaySettings { - "Octopus.ControlType"?: ControlType; - "Octopus.SelectOptions"?: string; -} - export enum VariableType { String = "String", Sensitive = "Sensitive", @@ -144,10 +105,6 @@ export enum VariableType { GenericOidcAccount = "GenericOidcAccount", } -export type ReferenceType = VariableType.Certificate | VariableType.AmazonWebServicesAccount | VariableType.AzureAccount | VariableType.WorkerPool | VariableType.GoogleCloudAccount | VariableType.GenericOidcAccount; -export type VariableAccountType = VariableType.AmazonWebServicesAccount | VariableType.AzureAccount | VariableType.GoogleCloudAccount | VariableType.UsernamePasswordAccount | VariableType.GenericOidcAccount; - - interface VariableSetResource { Id: string; SpaceId: string; From c7990990da201fd7f061a76649f69bb7a325e415 Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Mon, 29 Sep 2025 08:39:29 +0930 Subject: [PATCH 04/11] Ensure tool registration occurs --- src/tools/index.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/tools/index.ts b/src/tools/index.ts index 2f47f0b..d45788e 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -31,6 +31,7 @@ import './listCertificates.js'; import './getCertificate.js'; import './listAccounts.js'; import './getAccount.js'; +import './getVariables.js'; function isToolEnabled(toolRegistration: ToolRegistration, config: ToolsetConfig): boolean { if (!toolRegistration) { From 3bdc762fe6c2b48529550fbdf6d11e88d111765f Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Mon, 29 Sep 2025 08:39:40 +0930 Subject: [PATCH 05/11] Correct types and urls --- src/tools/getVariables.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts index 7ce2412..0d242cf 100644 --- a/src/tools/getVariables.ts +++ b/src/tools/getVariables.ts @@ -9,6 +9,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 type {ResourceCollection} from "@octopusdeploy/api-client/dist/resourceCollection.js"; export function registerGetVariablesTool(server: McpServer) { server.tool( @@ -259,12 +260,12 @@ async function loadProjectVariableSet( // Retrieve the variable set stored in git for the associated gitRef const textVariableSet = await apiClient.get( - `/api/${spaceId}/projects/${project.Id}/${gitRef}/variables` + `/api/spaces/${spaceId}/projects/${project.Id}/${gitRef}/variables` ); // Sensitive variables are still stored in the database so that they can be encrypted const sensitiveVariableSet = await apiClient.get( - `/api/${spaceId}/projects/${project.Id}/variables` + `/api/spaces/${spaceId}/projects/${project.Id}/variables` ); // Combine variables from both sets @@ -274,10 +275,12 @@ async function loadProjectVariableSet( }; } else { // For database projects, get variables directly - return await apiClient.get(`/api/${spaceId}/variables/${project.VariableSetId}`); + return await apiClient.get(`/api/spaces/${spaceId}/variables/${project.VariableSetId}`); } } +// TODO: No pagination in here, nor do we return pagination details to the LLM to further explore +// Think about how to solve. async function loadLibraryVariableSetVariables( includedLibraryVariableSetIds: string[], apiClient: Client, @@ -285,14 +288,14 @@ async function loadLibraryVariableSetVariables( ): Promise { // Get library variable sets - const libraryVariableSets = await apiClient.get( - `/api/${spaceId}/libraryvariablesets?ids=${includedLibraryVariableSetIds.join(',')}` + const libraryVariableSets = await apiClient.get>( + `/api/spaces/${spaceId}/libraryvariablesets?ids=${includedLibraryVariableSetIds.join(',')}` ); // Get all variable sets for the library variable sets - const variableSetIds = libraryVariableSets.map(lvs => lvs.VariableSetId); + const variableSetIds = libraryVariableSets.Items.map(lvs => lvs.VariableSetId); const allVariableSets = await apiClient.get( - `/api/${spaceId}/variables?ids=${variableSetIds.join(',')}` + `/api/spaces/${spaceId}/variables/all?ids=${variableSetIds.join(',')}` ); // Create lookup map @@ -302,7 +305,7 @@ async function loadLibraryVariableSetVariables( }, {}); // Combine library variable sets with their variable sets - return libraryVariableSets.map(lvs => ({ + return libraryVariableSets.Items.map(lvs => ({ variableSet: allVariableSetsMap[lvs.VariableSetId], libraryVariableSet: lvs })); From 082dad2c41f37267b72232d3b3c95875a9861597 Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Mon, 29 Sep 2025 11:06:47 +0930 Subject: [PATCH 06/11] await function call --- src/tools/getVariables.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts index 0d242cf..ca8b282 100644 --- a/src/tools/getVariables.ts +++ b/src/tools/getVariables.ts @@ -32,7 +32,7 @@ export function registerGetVariablesTool(server: McpServer) { const client = await Client.create(configuration); const spaceId = await resolveSpaceId(client, spaceName); - const variables = getAllVariables({ + const variables = await getAllVariables({ projectId: projectId, spaceName: spaceName, spaceId: spaceId, From 1c6ba23e1e466bb8d5d35ff27101db9a81e2c677 Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Wed, 1 Oct 2025 11:20:30 +0930 Subject: [PATCH 07/11] Simplify variable tool payload --- src/tools/getVariables.ts | 46 +++++++++++++++++++++++++++++++++------ 1 file changed, 39 insertions(+), 7 deletions(-) diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts index ca8b282..fd9db6a 100644 --- a/src/tools/getVariables.ts +++ b/src/tools/getVariables.ts @@ -10,6 +10,7 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { registerToolDefinition } from "../types/toolConfig.js"; import type {ResourceCollection} from "@octopusdeploy/api-client/dist/resourceCollection.js"; +import { logger } from "../utils/logger.js"; export function registerGetVariablesTool(server: McpServer) { server.tool( @@ -179,9 +180,9 @@ interface LibraryVariableSetWithVariables { } interface AllVariablesForProject { - projectVariableSet: VariableSetResource | undefined; + projectVariableSet: Omit | undefined; libraryVariableSets: LibraryVariableSetWithVariables[]; - tenantVariables: TenantVariable[]; + tenantVariables: string[]; } interface GetAllVariablesParams { @@ -224,7 +225,7 @@ async function loadProjectVariableSet( gitRef: string | undefined, apiClient: Client, spaceId: string -): Promise { +): Promise | undefined> { // This is a bit hacky, but gets around the limitations of our ts client types without having to define // a heap of new types. @@ -255,6 +256,8 @@ async function loadProjectVariableSet( return undefined; } + let result: VariableSetResource; + if (hasGitVariables) { // For git projects, we need to get both text and sensitive variables separately @@ -269,14 +272,21 @@ async function loadProjectVariableSet( ); // Combine variables from both sets - return { + result = { ...textVariableSet, Variables: [...textVariableSet.Variables, ...sensitiveVariableSet.Variables] }; } else { // For database projects, get variables directly - return await apiClient.get(`/api/spaces/${spaceId}/variables/${project.VariableSetId}`); + result = await apiClient.get(`/api/spaces/${spaceId}/variables/${project.VariableSetId}`); } + + // Strip out scope values, as they are not useful and pollute context + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + delete result.ScopeValues; + + return result; } // TODO: No pagination in here, nor do we return pagination details to the LLM to further explore @@ -287,6 +297,8 @@ async function loadLibraryVariableSetVariables( spaceId: string ): Promise { + if (includedLibraryVariableSetIds.length == 0) return []; + // Get library variable sets const libraryVariableSets = await apiClient.get>( `/api/spaces/${spaceId}/libraryvariablesets?ids=${includedLibraryVariableSetIds.join(',')}` @@ -315,9 +327,29 @@ async function loadTenantVariables( projectId: string, apiClient: Client, spaceId: string -): Promise { +): Promise { const response = await apiClient.get<{TenantVariableResources: TenantVariable[]}>( `/bff/spaces/${spaceId}/projects/${projectId}/tenantvariables` ); - return response.TenantVariableResources; + + const variableNames = new Set(); + + // Extract variable names from project variables templates + // Note that this will be guaranteed to only have a single collection of ProjectVariables for the tenant + response.TenantVariableResources.forEach(tenant => { + Object.values(tenant.ProjectVariables || {}).forEach(projectVar => { + projectVar.Templates?.forEach(template => { + variableNames.add(template.Name); + }); + }); + + // Extract variable names from library variable sets templates + Object.values(tenant.LibraryVariables || {}).forEach(libraryVar => { + libraryVar.Templates?.forEach(template => { + variableNames.add(template.Name); + }); + }); + }); + + return Array.from(variableNames).sort(); } From 7bdbb03479f98d3f1328d4f722839dfb5af98393 Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Wed, 1 Oct 2025 11:42:42 +0930 Subject: [PATCH 08/11] Remove tenant variables, cleanup types --- src/tools/getVariables.ts | 136 ++++++-------------------------------- 1 file changed, 21 insertions(+), 115 deletions(-) diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts index fd9db6a..1a98c38 100644 --- a/src/tools/getVariables.ts +++ b/src/tools/getVariables.ts @@ -2,15 +2,13 @@ import { Client, type Project, ProjectRepository, - resolveSpaceId, type ResourcesById, - type TenantVariable + resolveSpaceId, type ResourcesById } from "@octopusdeploy/api-client"; 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 type {ResourceCollection} from "@octopusdeploy/api-client/dist/resourceCollection.js"; -import { logger } from "../utils/logger.js"; export function registerGetVariablesTool(server: McpServer) { server.tool( @@ -37,8 +35,7 @@ export function registerGetVariablesTool(server: McpServer) { projectId: projectId, spaceName: spaceName, spaceId: spaceId, - gitRef: gitRef, - ephemeralEnvironmentsAreEnabled: false + gitRef: gitRef }, client) return { @@ -61,17 +58,15 @@ registerToolDefinition({ registerFn: registerGetVariablesTool, }); -export type VariableResource = VariableResourceBase>; - -export interface VariableResourceBase>, TVariablePromptOptions extends Readonly> { +type VariableResource = { Id: string; Name: string; Value: string | null; Description: string | undefined; - Scope: TScopeSpecification; + Scope: ScopeSpecification; IsEditable: boolean; - Prompt: TVariablePromptOptions | null; - Type: VariableType; + Prompt: Readonly; + Type: unknown; IsSensitive: boolean; //false; // For backwards compatibility } @@ -79,13 +74,9 @@ type Arrays = { [P in keyof T]: Array; }; -type ReadonlyArrays = { - [P in keyof T]: ReadonlyArray; -}; - -export type ScopeSpecification = Arrays; +type ScopeSpecification = Arrays; -export interface ScopeSpecificationTypes { +interface ScopeSpecificationTypes { Environment?: string; Machine?: string; Role?: string; @@ -95,57 +86,15 @@ export interface ScopeSpecificationTypes { ProcessOwner?: string; } -export enum VariableType { - String = "String", - Sensitive = "Sensitive", - Certificate = "Certificate", - AmazonWebServicesAccount = "AmazonWebServicesAccount", - AzureAccount = "AzureAccount", - GoogleCloudAccount = "GoogleCloudAccount", - WorkerPool = "WorkerPool", - UsernamePasswordAccount = "UsernamePasswordAccount", - GenericOidcAccount = "GenericOidcAccount", -} - interface VariableSetResource { Id: string; SpaceId: string; OwnerId: string; - ScopeValues: ScopeValues; + ScopeValues: unknown; Variables: VariableResource[]; Version: number; } -interface ReferenceDataItem { - Id: string; - Name: string; -} - -interface ProcessReferenceDataItem extends ReferenceDataItem { - ProcessType: ProcessType; -} - -enum ProcessType { - Deployment = "Deployment", - Runbook = "Runbook", - ProcessTemplate = "ProcessTemplate", -} - -interface ScopeValues { - Actions: ReferenceDataItem[]; - Channels: ReferenceDataItem[]; - Environments: ReferenceDataItem[]; - Machines: ReferenceDataItem[]; - Roles: ReferenceDataItem[]; - TenantTags: ReferenceDataItem[]; - Processes: ProcessReferenceDataItem[]; -} - -enum VariableSetContentType { - Variables = "Variables", - ScriptModule = "ScriptModule", -} - type PropertyValueResource = string | SensitiveValue | null; interface SensitiveValue { @@ -170,19 +119,18 @@ interface LibraryVariableSetResource { SpaceId: string; Description: string; VariableSetId: string; - ContentType: VariableSetContentType; + ContentType: unknown; Templates: ActionTemplateParameterResource[]; } interface LibraryVariableSetWithVariables { - variableSet: VariableSetResource; + variableSet: VariableSetResponse; libraryVariableSet: LibraryVariableSetResource; } interface AllVariablesForProject { projectVariableSet: Omit | undefined; libraryVariableSets: LibraryVariableSetWithVariables[]; - tenantVariables: string[]; } interface GetAllVariablesParams { @@ -190,9 +138,10 @@ interface GetAllVariablesParams { gitRef?: string; spaceName: string; spaceId: string; - ephemeralEnvironmentsAreEnabled?: boolean; } +type VariableSetResponse = Omit; + export async function getAllVariables( params: GetAllVariablesParams, apiClient: Client @@ -200,23 +149,14 @@ export async function getAllVariables( const { spaceId, spaceName, gitRef, projectId } = params; - // 1. Get the project to understand its configuration const projectRepository = new ProjectRepository(apiClient, spaceName); const project = await projectRepository.get(projectId); - - // 2. Load project variables (handling git persistence) const projectVariableSet = await loadProjectVariableSet(project, gitRef, apiClient, spaceId); - - // 3. Load library variable sets const libraryVariableSets = await loadLibraryVariableSetVariables(project.IncludedLibraryVariableSetIds, apiClient, spaceId); - // 4. Load tenant variables - const tenantVariables = await loadTenantVariables(project.Id, apiClient, spaceId); - return { projectVariableSet, - libraryVariableSets, - tenantVariables, + libraryVariableSets }; } @@ -225,7 +165,7 @@ async function loadProjectVariableSet( gitRef: string | undefined, apiClient: Client, spaceId: string -): Promise | undefined> { +): Promise { // This is a bit hacky, but gets around the limitations of our ts client types without having to define // a heap of new types. @@ -251,9 +191,7 @@ async function loadProjectVariableSet( const hasGitVariables = hasVariablesInGit(project.PersistenceSettings); if (hasGitVariables && !gitRef) { - // TODO: Should we throw an error here for MCP? - // If we've just changed from an invalid branch, GitRef might be null. Wait until it's set. - return undefined; + throw new Error(`Missing gitRef for config-as-code project ${project.Name}`); } let result: VariableSetResource; @@ -281,16 +219,11 @@ async function loadProjectVariableSet( result = await apiClient.get(`/api/spaces/${spaceId}/variables/${project.VariableSetId}`); } - // Strip out scope values, as they are not useful and pollute context - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-expect-error delete result.ScopeValues; return result; } -// TODO: No pagination in here, nor do we return pagination details to the LLM to further explore -// Think about how to solve. async function loadLibraryVariableSetVariables( includedLibraryVariableSetIds: string[], apiClient: Client, @@ -310,8 +243,12 @@ async function loadLibraryVariableSetVariables( `/api/spaces/${spaceId}/variables/all?ids=${variableSetIds.join(',')}` ); + const responseVariableSets: VariableSetResponse[] = allVariableSets.map(set => { + delete set.ScopeValues; + return set; + }) // Create lookup map - const allVariableSetsMap = allVariableSets.reduce((acc: ResourcesById, resource) => { + const allVariableSetsMap = responseVariableSets.reduce((acc: ResourcesById, resource) => { acc[resource.Id] = resource; return acc; }, {}); @@ -322,34 +259,3 @@ async function loadLibraryVariableSetVariables( libraryVariableSet: lvs })); } - -async function loadTenantVariables( - projectId: string, - apiClient: Client, - spaceId: string -): Promise { - const response = await apiClient.get<{TenantVariableResources: TenantVariable[]}>( - `/bff/spaces/${spaceId}/projects/${projectId}/tenantvariables` - ); - - const variableNames = new Set(); - - // Extract variable names from project variables templates - // Note that this will be guaranteed to only have a single collection of ProjectVariables for the tenant - response.TenantVariableResources.forEach(tenant => { - Object.values(tenant.ProjectVariables || {}).forEach(projectVar => { - projectVar.Templates?.forEach(template => { - variableNames.add(template.Name); - }); - }); - - // Extract variable names from library variable sets templates - Object.values(tenant.LibraryVariables || {}).forEach(libraryVar => { - libraryVar.Templates?.forEach(template => { - variableNames.add(template.Name); - }); - }); - }); - - return Array.from(variableNames).sort(); -} From b484f58670324870267b9e49733a8144be071d31 Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Wed, 1 Oct 2025 11:45:09 +0930 Subject: [PATCH 09/11] Cleans up tool description --- src/tools/getVariables.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts index 1a98c38..bae2e67 100644 --- a/src/tools/getVariables.ts +++ b/src/tools/getVariables.ts @@ -13,9 +13,9 @@ import type {ResourceCollection} from "@octopusdeploy/api-client/dist/resourceCo export function registerGetVariablesTool(server: McpServer) { server.tool( "get_variables", - `Get all variables for a project. This tool retrieves all variables available to a project, - including project variables, library variable set variables, and tenant variables. Results include variable names, values, and scopes. - `, + `This tool gets all project and library variable set variables for a given project. + If you want to retrieve tenant variables for a given tenant, use the get_tenant_variables tool. + `, { spaceName: z.string().describe("The space name"), projectId: z.string().describe("The ID of the project to retrieve the variables for"), From 134d2c67aa51672b712fd6d55fd491e599cf656c Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Wed, 1 Oct 2025 11:59:28 +0930 Subject: [PATCH 10/11] Review tidy up --- src/tools/getVariables.ts | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts index bae2e67..c181238 100644 --- a/src/tools/getVariables.ts +++ b/src/tools/getVariables.ts @@ -194,7 +194,7 @@ async function loadProjectVariableSet( throw new Error(`Missing gitRef for config-as-code project ${project.Name}`); } - let result: VariableSetResource; + let resource: VariableSetResource; if (hasGitVariables) { // For git projects, we need to get both text and sensitive variables separately @@ -210,16 +210,17 @@ async function loadProjectVariableSet( ); // Combine variables from both sets - result = { + resource = { ...textVariableSet, Variables: [...textVariableSet.Variables, ...sensitiveVariableSet.Variables] }; } else { // For database projects, get variables directly - result = await apiClient.get(`/api/spaces/${spaceId}/variables/${project.VariableSetId}`); + resource = await apiClient.get(`/api/spaces/${spaceId}/variables/${project.VariableSetId}`); } - delete result.ScopeValues; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { ScopeValues, ...result } = resource; return result; } @@ -230,7 +231,7 @@ async function loadLibraryVariableSetVariables( spaceId: string ): Promise { - if (includedLibraryVariableSetIds.length == 0) return []; + if (includedLibraryVariableSetIds.length === 0) return []; // Get library variable sets const libraryVariableSets = await apiClient.get>( @@ -243,9 +244,10 @@ async function loadLibraryVariableSetVariables( `/api/spaces/${spaceId}/variables/all?ids=${variableSetIds.join(',')}` ); - const responseVariableSets: VariableSetResponse[] = allVariableSets.map(set => { - delete set.ScopeValues; - return set; + const responseVariableSets: VariableSetResponse[] = allVariableSets.map(resource => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { ScopeValues, ...result } = resource; + return result; }) // Create lookup map const allVariableSetsMap = responseVariableSets.reduce((acc: ResourcesById, resource) => { From 30e3924e58139b0a026f86a3f61b5a58f127ae57 Mon Sep 17 00:00:00 2001 From: Andrew Best Date: Wed, 1 Oct 2025 14:32:52 +0930 Subject: [PATCH 11/11] Address feedback --- src/tools/getVariables.ts | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts index c181238..4e8d176 100644 --- a/src/tools/getVariables.ts +++ b/src/tools/getVariables.ts @@ -14,7 +14,9 @@ export function registerGetVariablesTool(server: McpServer) { server.tool( "get_variables", `This tool gets all project and library variable set variables for a given project. - If you want to retrieve tenant variables for a given tenant, use the get_tenant_variables tool. + Projects can contain variables (specific to a project), library variable sets (shared collections of variables associated with many projects), + and tenant variables (variables related to a tenants connected to the project) + If you want to retrieve tenant variables for a tenant connected to the project, use the get_tenant_variables tool. `, { spaceName: z.string().describe("The space name"), @@ -201,12 +203,12 @@ async function loadProjectVariableSet( // Retrieve the variable set stored in git for the associated gitRef const textVariableSet = await apiClient.get( - `/api/spaces/${spaceId}/projects/${project.Id}/${gitRef}/variables` + `~/api/spaces/${spaceId}/projects/${project.Id}/${gitRef}/variables` ); // Sensitive variables are still stored in the database so that they can be encrypted const sensitiveVariableSet = await apiClient.get( - `/api/spaces/${spaceId}/projects/${project.Id}/variables` + `~/api/spaces/${spaceId}/projects/${project.Id}/variables` ); // Combine variables from both sets @@ -216,7 +218,7 @@ async function loadProjectVariableSet( }; } else { // For database projects, get variables directly - resource = await apiClient.get(`/api/spaces/${spaceId}/variables/${project.VariableSetId}`); + resource = await apiClient.get(`~/api/spaces/${spaceId}/variables/${project.VariableSetId}`); } // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -235,13 +237,13 @@ async function loadLibraryVariableSetVariables( // Get library variable sets const libraryVariableSets = await apiClient.get>( - `/api/spaces/${spaceId}/libraryvariablesets?ids=${includedLibraryVariableSetIds.join(',')}` + `~/api/spaces/${spaceId}/libraryvariablesets?ids=${includedLibraryVariableSetIds.join(',')}` ); // Get all variable sets for the library variable sets const variableSetIds = libraryVariableSets.Items.map(lvs => lvs.VariableSetId); const allVariableSets = await apiClient.get( - `/api/spaces/${spaceId}/variables/all?ids=${variableSetIds.join(',')}` + `~/api/spaces/${spaceId}/variables/all?ids=${variableSetIds.join(',')}` ); const responseVariableSets: VariableSetResponse[] = allVariableSets.map(resource => {