Skip to content
Merged
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
265 changes: 265 additions & 0 deletions src/tools/getVariables.ts
Original file line number Diff line number Diff line change
@@ -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<unknown>;
Type: unknown;
IsSensitive: boolean; //false; // For backwards compatibility
}

type Arrays<T> = {
[P in keyof T]: Array<T[P]>;
};

type ScopeSpecification = Arrays<ScopeSpecificationTypes>;

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<VariableSetResource, "ScopeValues"> | undefined;
libraryVariableSets: LibraryVariableSetWithVariables[];
}

interface GetAllVariablesParams {
projectId: string;
gitRef?: string;
spaceName: string;
spaceId: string;
}

type VariableSetResponse = Omit<VariableSetResource, "ScopeValues">;

export async function getAllVariables(
params: GetAllVariablesParams,
apiClient: Client
): Promise<AllVariablesForProject> {

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<VariableSetResponse | 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.
// 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<string, unknown>;
const conversionState = obj.ConversionState;

return (
typeof conversionState === 'object' &&
conversionState !== null &&
'VariablesAreInGit' in conversionState &&
(conversionState as Record<string, unknown>).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<VariableSetResource>(
`~/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<VariableSetResource>(
`~/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<VariableSetResource>(`~/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<LibraryVariableSetWithVariables[]> {

if (includedLibraryVariableSetIds.length === 0) return [];

// Get library variable sets
const libraryVariableSets = await apiClient.get<ResourceCollection<LibraryVariableSetResource>>(
`~/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<VariableSetResource[]>(
`~/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<VariableSetResponse>, 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
Comment on lines +262 to +263
Copy link
Contributor

Choose a reason for hiding this comment

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

Should we add an explanation for the model as to what variable sets and library variable sets are and their relationship with each other or is it already well understood from the response structure?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm not sure if the model really needs to know about that, unless we want it to be able to modify them afterwards. Its more about evaluating the variables available to a project.

}));
}
1 change: 1 addition & 0 deletions src/tools/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down