diff --git a/src/tools/getVariables.ts b/src/tools/getVariables.ts new file mode 100644 index 0000000..4e8d176 --- /dev/null +++ b/src/tools/getVariables.ts @@ -0,0 +1,265 @@ +import { + Client, + type Project, + ProjectRepository, + 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"; + +export function registerGetVariablesTool(server: McpServer) { + server.tool( + "get_variables", + `This tool gets all project and library variable set variables for a given project. + 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"), + 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 = await getAllVariables({ + projectId: projectId, + spaceName: spaceName, + spaceId: spaceId, + gitRef: gitRef + }, client) + + return { + content: [ + { + type: "text", + text: JSON.stringify({ + variables + }), + }, + ], + }; + } + ); +} + +registerToolDefinition({ + toolName: "get_variables", + config: { toolset: "projects", readOnly: true }, + registerFn: registerGetVariablesTool, +}); + +type VariableResource = { + Id: string; + Name: string; + Value: string | null; + Description: string | undefined; + Scope: ScopeSpecification; + IsEditable: boolean; + Prompt: Readonly; + Type: unknown; + IsSensitive: boolean; //false; // For backwards compatibility +} + +type Arrays = { + [P in keyof T]: Array; +}; + +type ScopeSpecification = Arrays; + +interface ScopeSpecificationTypes { + Environment?: string; + Machine?: string; + Role?: string; + Action?: string; + Channel?: string; + TenantTag?: string; + ProcessOwner?: string; +} + +interface VariableSetResource { + Id: string; + SpaceId: string; + OwnerId: string; + ScopeValues: unknown; + Variables: VariableResource[]; + Version: number; +} + +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: unknown; + Templates: ActionTemplateParameterResource[]; +} + +interface LibraryVariableSetWithVariables { + variableSet: VariableSetResponse; + libraryVariableSet: LibraryVariableSetResource; +} + +interface AllVariablesForProject { + projectVariableSet: Omit | undefined; + libraryVariableSets: LibraryVariableSetWithVariables[]; +} + +interface GetAllVariablesParams { + projectId: string; + gitRef?: string; + spaceName: string; + spaceId: string; +} + +type VariableSetResponse = Omit; + +export async function getAllVariables( + params: GetAllVariablesParams, + apiClient: Client +): Promise { + + const { spaceId, spaceName, gitRef, projectId } = params; + + const projectRepository = new ProjectRepository(apiClient, spaceName); + const project = await projectRepository.get(projectId); + const projectVariableSet = await loadProjectVariableSet(project, gitRef, apiClient, spaceId); + const libraryVariableSets = await loadLibraryVariableSetVariables(project.IncludedLibraryVariableSetIds, apiClient, spaceId); + + return { + projectVariableSet, + libraryVariableSets + }; +} + +async function loadProjectVariableSet( + project: Project, + gitRef: string | undefined, + apiClient: Client, + spaceId: string +): 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. + // 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 + const hasGitVariables = hasVariablesInGit(project.PersistenceSettings); + + if (hasGitVariables && !gitRef) { + throw new Error(`Missing gitRef for config-as-code project ${project.Name}`); + } + + let resource: VariableSetResource; + + 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/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` + ); + + // Combine variables from both sets + resource = { + ...textVariableSet, + Variables: [...textVariableSet.Variables, ...sensitiveVariableSet.Variables] + }; + } else { + // For database projects, get variables directly + resource = await apiClient.get(`~/api/spaces/${spaceId}/variables/${project.VariableSetId}`); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { ScopeValues, ...result } = resource; + + return result; +} + +async function loadLibraryVariableSetVariables( + includedLibraryVariableSetIds: string[], + apiClient: Client, + spaceId: string +): Promise { + + if (includedLibraryVariableSetIds.length === 0) return []; + + // Get library variable sets + const libraryVariableSets = await apiClient.get>( + `~/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(',')}` + ); + + 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) => { + acc[resource.Id] = resource; + return acc; + }, {}); + + // Combine library variable sets with their variable sets + return libraryVariableSets.Items.map(lvs => ({ + variableSet: allVariableSetsMap[lvs.VariableSetId], + libraryVariableSet: lvs + })); +} 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) {