diff --git a/CLAUDE.md b/CLAUDE.md index 15d619c..63c98b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,47 @@ Use `superRefine` (or a discriminated union) to encode cross-field invariants When migrating existing tools from `tool()` to `registerTool()`: the description and annotations move into the config object as named fields (`description`, `annotations`); the input schema becomes `inputSchema`. The handler signature is unchanged. +#### Write gating: use `requireConfirmation` + +Tools that mutate state (deploy, create, run, submit, delete, non-GET `execute`) must call `requireConfirmation` from `src/helpers/requireConfirmation.ts` before performing the write, and return early when `confirmed` is false. The helper handles three branches transparently: + +1. `OCTOPUS_SKIP_ELICITATION=true` env var → bypass (automation/CI). Strict string equality with `"true"`. +2. Client advertises elicitation capability → SDK emits `elicitation/create` and the helper returns `{ confirmed: result.action === "accept" }`. `decline` and `cancel` both map to `false`. +3. Client does not advertise elicitation → helper falls back to the `confirm` arg the tool surfaced in its own input schema. + +Branch 3 requires every write tool's input schema to include a `confirm` field: + +```typescript +confirm: z + .boolean() + .optional() + .describe( + "Required only when the MCP client does not support elicitation. " + + "Set to true to confirm the write; otherwise the tool aborts.", + ), +``` + +Pass it through as `fallbackConfirm`. The helper returns a discriminated `ConfirmationResult` so callers can distinguish "user said no" from "user was never asked" — surface the latter as a hard error so the LLM stops and asks the user instead of treating the response as a real cancellation. The `unconfirmedResponse` builder produces the standard tool response for both branches; pass it the result and a lowercase noun phrase for the gated action: + +```typescript +import { + requireConfirmation, + unconfirmedResponse, +} from "../helpers/requireConfirmation.js"; + +const confirmation = await requireConfirmation(server, { + message: `Deploy release ${version} to ${environment}?`, + fallbackConfirm: args.confirm, +}); +if (!confirmation.confirmed) { + return unconfirmedResponse(confirmation, { action: "deployment" }); +} +``` + +`reason` values: `accepted` / `envSkip` / `fallbackConfirm` (confirmed); `declined` / `cancelled` / `confirmationRequired` (not confirmed). `confirmationRequired` is the one `unconfirmedResponse` flags with `isError: true` — it means the gate is unreachable for this client+args combination, not that the user objected. + +The handler closure captures `server` from the outer `register*Tool(server)` function — handlers do not receive `server` as an argument from the SDK. Place the gate after argument validation but before any expensive work (API client construction, network calls), so users don't spend an elicitation round-trip on a call that would have failed validation anyway. + ### Resource System Resources are addressable bodies fetched by URI (e.g. `octopus://spaces/Default/releases/Releases-1`). Each Resource is a single descriptor record in `RESOURCE_REGISTRY` with a URI template, mimeType, toolset, and an async `read` callback. Both the SDK Resource Template registration and the `read_resource` Tool backstop iterate the same registry, so adding a new resource type is one record — no edits to a central dispatcher. diff --git a/src/helpers/__tests__/requireConfirmation.test.ts b/src/helpers/__tests__/requireConfirmation.test.ts new file mode 100644 index 0000000..e3d1ebf --- /dev/null +++ b/src/helpers/__tests__/requireConfirmation.test.ts @@ -0,0 +1,366 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { + requireConfirmation, + unconfirmedResponse, +} from "../requireConfirmation.js"; + +interface ServerStub { + getClientCapabilities: ReturnType; + elicitInput: ReturnType; +} + +function makeServer(stub: ServerStub): McpServer { + return { server: stub } as unknown as McpServer; +} + +describe("requireConfirmation", () => { + let stub: ServerStub; + + beforeEach(() => { + stub = { + getClientCapabilities: vi.fn(), + elicitInput: vi.fn(), + }; + }); + + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("when OCTOPUS_SKIP_ELICITATION is set to 'true'", () => { + beforeEach(() => { + vi.stubEnv("OCTOPUS_SKIP_ELICITATION", "true"); + }); + + it("returns confirmed: true with reason 'envSkip'", async () => { + const result = await requireConfirmation(makeServer(stub), { + message: "ignored", + }); + expect(result).toEqual({ confirmed: true, reason: "envSkip" }); + }); + + it("short-circuits without consulting capabilities or elicitInput", async () => { + await requireConfirmation(makeServer(stub), { message: "ignored" }); + expect(stub.getClientCapabilities).not.toHaveBeenCalled(); + expect(stub.elicitInput).not.toHaveBeenCalled(); + }); + + it("ignores fallbackConfirm", async () => { + const result = await requireConfirmation(makeServer(stub), { + message: "ignored", + fallbackConfirm: false, + }); + expect(result).toEqual({ confirmed: true, reason: "envSkip" }); + }); + }); + + describe("when OCTOPUS_SKIP_ELICITATION is set to a non-'true' string", () => { + it.each(["1", "yes", "TRUE", "True", " true ", ""])( + "does not skip when value is %j", + async (value) => { + vi.stubEnv("OCTOPUS_SKIP_ELICITATION", value); + stub.getClientCapabilities.mockReturnValue(undefined); + const result = await requireConfirmation(makeServer(stub), { + message: "x", + fallbackConfirm: false, + }); + expect(result).toEqual({ confirmed: false, reason: "declined" }); + expect(stub.getClientCapabilities).toHaveBeenCalledOnce(); + }, + ); + }); + + describe("when client advertises elicitation capability", () => { + beforeEach(() => { + stub.getClientCapabilities.mockReturnValue({ elicitation: {} }); + }); + + it("returns confirmed: true with reason 'accepted' on action 'accept'", async () => { + stub.elicitInput.mockResolvedValue({ action: "accept" }); + const result = await requireConfirmation(makeServer(stub), { + message: "Do the thing?", + }); + expect(result).toEqual({ confirmed: true, reason: "accepted" }); + }); + + it("returns confirmed: false with reason 'declined' on action 'decline'", async () => { + stub.elicitInput.mockResolvedValue({ action: "decline" }); + const result = await requireConfirmation(makeServer(stub), { + message: "Do the thing?", + }); + expect(result).toEqual({ confirmed: false, reason: "declined" }); + }); + + it("returns confirmed: false with reason 'cancelled' on action 'cancel'", async () => { + stub.elicitInput.mockResolvedValue({ action: "cancel" }); + const result = await requireConfirmation(makeServer(stub), { + message: "Do the thing?", + }); + expect(result).toEqual({ confirmed: false, reason: "cancelled" }); + }); + + it("calls elicitInput with the supplied message and an empty-properties schema", async () => { + stub.elicitInput.mockResolvedValue({ action: "accept" }); + await requireConfirmation(makeServer(stub), { + message: "Deploy release 1.2.3 to Production?", + }); + expect(stub.elicitInput).toHaveBeenCalledWith({ + mode: "form", + message: "Deploy release 1.2.3 to Production?", + requestedSchema: { type: "object", properties: {} }, + }); + }); + + it("renders create-style payloads (empty source) as a JSON object of `+` additions", async () => { + stub.elicitInput.mockResolvedValue({ action: "accept" }); + await requireConfirmation(makeServer(stub), { + message: "Deploy release 1.2.3 to Production?", + change: { + source: {}, + target: { + ProjectName: "MyProject", + ReleaseVersion: "1.2.3", + EnvironmentNames: ["Production"], + SkipStepNames: ["Notify"], + }, + }, + }); + const call = stub.elicitInput.mock.calls[0][0]; + expect(call.message).toBe( + [ + "Deploy release 1.2.3 to Production?", + "", + "{", + '+ "ProjectName": "MyProject",', + '+ "ReleaseVersion": "1.2.3",', + '+ "EnvironmentNames": [', + '+ "Production"', + "+ ],", + '+ "SkipStepNames": [', + '+ "Notify"', + "+ ]", + "}", + ].join("\n"), + ); + expect(call.requestedSchema).toEqual({ + type: "object", + properties: {}, + }); + }); + + it("renders modify-style payloads as a JSON diff, omitting unchanged keys", async () => { + stub.elicitInput.mockResolvedValue({ action: "accept" }); + await requireConfirmation(makeServer(stub), { + message: "Update environment Production?", + change: { + source: { + Name: "Production", + Description: "Old", + AllowDynamicInfrastructure: false, + }, + target: { + Name: "Production", + Description: "New", + AllowDynamicInfrastructure: true, + }, + }, + }); + const call = stub.elicitInput.mock.calls[0][0]; + expect(call.message).toBe( + [ + "Update environment Production?", + "", + "{", + '- "Description": "Old",', + '+ "Description": "New",', + '- "AllowDynamicInfrastructure": false,', + '+ "AllowDynamicInfrastructure": true', + "}", + ].join("\n"), + ); + // Unchanged keys are not surfaced. + expect(call.message).not.toContain('"Name"'); + }); + + it("renders source-only keys as `-` removals and target-only keys as `+` additions", async () => { + stub.elicitInput.mockResolvedValue({ action: "accept" }); + await requireConfirmation(makeServer(stub), { + message: "Update?", + change: { + source: { Removed: "gone" }, + target: { Added: "new" }, + }, + }); + const call = stub.elicitInput.mock.calls[0][0]; + expect(call.message).toBe( + [ + "Update?", + "", + "{", + '- "Removed": "gone",', + '+ "Added": "new"', + "}", + ].join("\n"), + ); + }); + + it("renders an empty JSON object when source and target are equal", async () => { + stub.elicitInput.mockResolvedValue({ action: "accept" }); + await requireConfirmation(makeServer(stub), { + message: "Update?", + change: { source: { a: 1 }, target: { a: 1 } }, + }); + const call = stub.elicitInput.mock.calls[0][0]; + expect(call.message).toBe(["Update?", "", "{}"].join("\n")); + }); + + it("does not append a diff when change is omitted", async () => { + stub.elicitInput.mockResolvedValue({ action: "accept" }); + await requireConfirmation(makeServer(stub), { message: "Plain prompt?" }); + const call = stub.elicitInput.mock.calls[0][0]; + expect(call.message).toBe("Plain prompt?"); + }); + + it("ignores fallbackConfirm when elicitation is used", async () => { + stub.elicitInput.mockResolvedValue({ action: "decline" }); + const result = await requireConfirmation(makeServer(stub), { + message: "x", + fallbackConfirm: true, + }); + expect(result).toEqual({ confirmed: false, reason: "declined" }); + }); + + it("propagates errors from elicitInput", async () => { + stub.elicitInput.mockRejectedValue(new Error("transport failure")); + await expect( + requireConfirmation(makeServer(stub), { message: "x" }), + ).rejects.toThrow("transport failure"); + }); + }); + + describe("when client does not advertise elicitation capability", () => { + it.each([ + ["undefined capabilities", undefined], + ["empty capabilities object", {}], + ["capabilities without elicitation", { sampling: {} }], + ])( + "treats fallbackConfirm: true as 'fallbackConfirm' when %s", + async (_label, capabilities) => { + stub.getClientCapabilities.mockReturnValue(capabilities); + const result = await requireConfirmation(makeServer(stub), { + message: "x", + fallbackConfirm: true, + }); + expect(result).toEqual({ confirmed: true, reason: "fallbackConfirm" }); + expect(stub.elicitInput).not.toHaveBeenCalled(); + }, + ); + + it("treats fallbackConfirm: false as 'declined'", async () => { + stub.getClientCapabilities.mockReturnValue(undefined); + const result = await requireConfirmation(makeServer(stub), { + message: "x", + fallbackConfirm: false, + }); + expect(result).toEqual({ confirmed: false, reason: "declined" }); + }); + + it("treats omitted fallbackConfirm as 'confirmationRequired'", async () => { + stub.getClientCapabilities.mockReturnValue(undefined); + const result = await requireConfirmation(makeServer(stub), { + message: "x", + }); + expect(result).toEqual({ + confirmed: false, + reason: "confirmationRequired", + }); + }); + + it("does not call elicitInput on any fallback branch", async () => { + stub.getClientCapabilities.mockReturnValue(undefined); + await requireConfirmation(makeServer(stub), { + message: "x", + fallbackConfirm: true, + }); + await requireConfirmation(makeServer(stub), { + message: "x", + fallbackConfirm: false, + }); + await requireConfirmation(makeServer(stub), { message: "x" }); + expect(stub.elicitInput).not.toHaveBeenCalled(); + }); + }); +}); + +describe("unconfirmedResponse", () => { + function parsePayload(response: { content: Array<{ text: string }> }): { + success: boolean; + confirmationRequired?: boolean; + cancelled?: boolean; + reason?: string; + message: string; + } { + return JSON.parse(response.content[0].text); + } + + describe("when reason is 'confirmationRequired'", () => { + it("returns isError: true", () => { + const response = unconfirmedResponse( + { confirmed: false, reason: "confirmationRequired" }, + { action: "release creation" }, + ); + expect(response.isError).toBe(true); + }); + + it("payload sets confirmationRequired: true and embeds the action", () => { + const response = unconfirmedResponse( + { confirmed: false, reason: "confirmationRequired" }, + { action: "release creation" }, + ); + const payload = parsePayload(response); + expect(payload.success).toBe(false); + expect(payload.confirmationRequired).toBe(true); + expect(payload.message).toContain("release creation"); + expect(payload.message).toContain("user has NOT been asked"); + }); + + it("does not set the cancelled / reason fields", () => { + const response = unconfirmedResponse( + { confirmed: false, reason: "confirmationRequired" }, + { action: "deployment" }, + ); + const payload = parsePayload(response); + expect(payload.cancelled).toBeUndefined(); + expect(payload.reason).toBeUndefined(); + }); + }); + + describe("when reason is 'declined' or 'cancelled'", () => { + it.each(["declined", "cancelled"] as const)( + "returns soft cancellation shape with reason %s", + (reason) => { + const response = unconfirmedResponse( + { confirmed: false, reason }, + { action: "release creation" }, + ); + expect(response.isError).toBeUndefined(); + const payload = parsePayload(response); + expect(payload.success).toBe(false); + expect(payload.cancelled).toBe(true); + expect(payload.reason).toBe(reason); + expect(payload.confirmationRequired).toBeUndefined(); + }, + ); + + it("capitalizes the action for the cancelled message", () => { + const response = unconfirmedResponse( + { confirmed: false, reason: "declined" }, + { action: "deployment" }, + ); + expect(parsePayload(response).message).toBe( + "Deployment cancelled by user.", + ); + }); + }); +}); diff --git a/src/helpers/requireConfirmation.ts b/src/helpers/requireConfirmation.ts new file mode 100644 index 0000000..4b11bb4 --- /dev/null +++ b/src/helpers/requireConfirmation.ts @@ -0,0 +1,257 @@ +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { env } from "process"; + +export interface RequireConfirmationOptions { + /** Human-readable summary of what will happen, shown to the user. */ + message: string; + /** + * Yes/no value from the calling tool's own args. Used only when the client + * does not advertise elicitation capability. Tool input schemas should add + * `confirm: z.boolean().optional()` and pass it through here. + * + * Resolution when the elicitation capability is absent: + * - `true` → confirmed (the LLM asserts the user approved out-of-band) + * - `false` → declined (the LLM asserts the user said no out-of-band) + * - `undefined`→ confirmationRequired (the user hasn't been asked yet — the + * caller should report this back to the LLM as a hard error + * so it asks the user before retrying) + */ + fallbackConfirm?: boolean; + /** + * Optional structured before/after view of the operation. Rendered as a + * key-level diff (with `+` / `-` markers) and appended to `message`, so the + * user sees exactly what's changing — including modifiers like scheduled + * run time, skipped steps, machine filters, prompted variables, and + * deployment-freeze overrides that don't fit the prose summary. + * + * - Create operations: pass `{ source: {}, target: }`. Every + * target field renders as a `+` line (everything is being added). + * - Modify operations: `source` is the current state; `target` is the + * proposed state. Only keys whose values differ appear in the output. + * + * Kept inside `message` rather than surfaced as a `requestedSchema` so the + * rendering is identical across clients regardless of which elicitation + * modes they support. + */ + change?: { + source: Record; + target: Record; + }; +} + +function renderChange(change: { + source: Record; + target: Record; +}): string { + // Preserve source order first, then append target-only keys, so related + // fields stay together rather than getting alphabetised apart. + const seen = new Set(); + const keys: string[] = []; + for (const k of Object.keys(change.source)) { + keys.push(k); + seen.add(k); + } + for (const k of Object.keys(change.target)) { + if (!seen.has(k)) keys.push(k); + } + + // Each diff entry is the lines of one removed (-) or added (+) JSON property, + // already indented two spaces to sit inside the wrapping `{ }`. The marker is + // prefixed onto each line at render time, mimicking git's unified-diff format + // so the result reads as a JSON object with diff annotations. + type Entry = { marker: "+" | "-"; lines: string[] }; + const entries: Entry[] = []; + const buildEntry = (marker: "+" | "-", key: string, value: unknown): Entry => { + const valueLines = JSON.stringify(value, null, 2).split("\n"); + const lines: string[] = [` ${JSON.stringify(key)}: ${valueLines[0]}`]; + for (let i = 1; i < valueLines.length; i++) { + lines.push(` ${valueLines[i]}`); + } + return { marker, lines }; + }; + + for (const key of keys) { + const inSource = key in change.source; + const inTarget = key in change.target; + const sourceJson = inSource + ? JSON.stringify(change.source[key], null, 2) + : undefined; + const targetJson = inTarget + ? JSON.stringify(change.target[key], null, 2) + : undefined; + if (sourceJson === targetJson) continue; + if (inSource) entries.push(buildEntry("-", key, change.source[key])); + if (inTarget) entries.push(buildEntry("+", key, change.target[key])); + } + + if (entries.length === 0) return "{}"; + + const out: string[] = ["{"]; + entries.forEach((entry, idx) => { + const isLastEntry = idx === entries.length - 1; + entry.lines.forEach((line, lineIdx) => { + const isLastLineOfEntry = lineIdx === entry.lines.length - 1; + const suffix = isLastLineOfEntry && !isLastEntry ? "," : ""; + out.push(`${entry.marker}${line}${suffix}`); + }); + }); + out.push("}"); + return out.join("\n"); +} + +function buildConfirmationMessage( + message: string, + change?: RequireConfirmationOptions["change"], +): string { + return change ? `${message}\n\n${renderChange(change)}` : message; +} + +/** + * Why the helper resolved the way it did. Tools branch on this so the LLM + * (and any humans reading logs) can tell an explicit user "no" apart from a + * confirmation that was never reachable in the first place. + */ +export type ConfirmationReason = + /** OCTOPUS_SKIP_ELICITATION=true bypass. */ + | "envSkip" + /** User clicked Accept on the elicitation prompt. */ + | "accepted" + /** Caller passed fallbackConfirm: true (no elicitation capability). */ + | "fallbackConfirm" + /** User clicked Decline on the elicitation prompt, or fallbackConfirm was explicitly false. */ + | "declined" + /** User dismissed the elicitation prompt without choosing. */ + | "cancelled" + /** + * Client does not advertise elicitation capability AND fallbackConfirm was + * not provided. The user has NOT been asked. Tools should surface this as a + * hard error and tell the LLM to ask the user before retrying. + */ + | "confirmationRequired"; + +export type ConfirmationResult = + | { confirmed: true; reason: "envSkip" | "accepted" | "fallbackConfirm" } + | { + confirmed: false; + reason: "declined" | "cancelled" | "confirmationRequired"; + }; + +/** + * Gate a write/destructive tool call on explicit user confirmation. + * + * Resolution order: + * 1. `OCTOPUS_SKIP_ELICITATION=true` env var → bypass (automation/CI). + * 2. Client advertises elicitation capability → SDK emits `elicitation/create` + * and we map `result.action` to accepted/declined/cancelled. + * 3. Client does not advertise elicitation → fall back to the `confirm` arg + * the tool surfaced in its own input schema. Distinguishes between + * explicit `false` (declined) and missing (confirmationRequired) so the + * caller can surface the latter as a hard error. + */ +export async function requireConfirmation( + server: McpServer, + opts: RequireConfirmationOptions, +): Promise { + if (env["OCTOPUS_SKIP_ELICITATION"] === "true") { + return { confirmed: true, reason: "envSkip" }; + } + + const capabilities = server.server.getClientCapabilities(); + if (capabilities?.elicitation) { + const result = await server.server.elicitInput({ + mode: "form", + message: buildConfirmationMessage(opts.message, opts.change), + // Empty properties → most clients render as a plain Accept/Decline prompt. + requestedSchema: { type: "object", properties: {} }, + }); + switch (result.action) { + case "accept": + return { confirmed: true, reason: "accepted" }; + case "decline": + return { confirmed: false, reason: "declined" }; + case "cancel": + default: + return { confirmed: false, reason: "cancelled" }; + } + } + + if (opts.fallbackConfirm === true) { + return { confirmed: true, reason: "fallbackConfirm" }; + } + if (opts.fallbackConfirm === false) { + return { confirmed: false, reason: "declined" }; + } + return { confirmed: false, reason: "confirmationRequired" }; +} + +export interface UnconfirmedResponseOptions { + /** + * Lowercase noun phrase describing the gated action — e.g. "release + * creation", "deployment", "runbook run". Embedded mid-sentence in the + * `confirmationRequired` message and capitalized for the cancelled message. + */ + action: string; +} + +/** + * Build the standard tool response for a non-confirmed gate result. + * + * - `confirmationRequired` → `isError: true` with directive prose telling the + * LLM to ask the user before retrying with `confirm: true`. This is the + * "user was never asked" branch — distinct from a real cancellation, and + * marked as an error so the LLM doesn't paper over it. + * - `declined` / `cancelled` → soft cancellation shape with the original + * reason preserved for telemetry. + * + * Centralized here so every gated tool produces identical responses; the only + * thing a caller varies is the `action` noun. Return type is inferred so it + * stays compatible with the SDK's tool-handler return shape (which carries an + * `[key: string]: unknown` index signature we don't want to redeclare). + */ +export function unconfirmedResponse( + result: Extract, + opts: UnconfirmedResponseOptions, +) { + if (result.reason === "confirmationRequired") { + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + success: false, + confirmationRequired: true, + message: + `This MCP client does not support elicitation, so the server cannot prompt the user to confirm this ${opts.action} directly. ` + + `The user has NOT been asked. Stop and ask the user explicitly whether to proceed; if they approve, retry the call with confirm: true. ` + + `Do not pass confirm: true without their explicit approval.`, + }, + null, + 2, + ), + }, + ], + isError: true, + }; + } + + const capitalized = + opts.action.charAt(0).toUpperCase() + opts.action.slice(1); + return { + content: [ + { + type: "text" as const, + text: JSON.stringify( + { + success: false, + cancelled: true, + reason: result.reason, + message: `${capitalized} cancelled by user.`, + }, + null, + 2, + ), + }, + ], + }; +} diff --git a/src/tools/createRelease.ts b/src/tools/createRelease.ts index c6e0cda..8da0bcc 100644 --- a/src/tools/createRelease.ts +++ b/src/tools/createRelease.ts @@ -4,6 +4,10 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { registerToolDefinition } from "../types/toolConfig.js"; import { handleOctopusApiError } from "../helpers/errorHandling.js"; +import { + requireConfirmation, + unconfirmedResponse, +} from "../helpers/requireConfirmation.js"; export function registerCreateReleaseTool(server: McpServer) { server.tool( @@ -58,6 +62,12 @@ This tool creates a new release for a project. The space name and project name a .record(z.string()) .optional() .describe("Custom field values as key-value pairs"), + confirm: z + .boolean() + .optional() + .describe( + "Required only when the MCP client does not support elicitation. Set to true to confirm release creation; otherwise the tool aborts.", + ), }, { title: "Create a new release in Octopus Deploy", @@ -77,11 +87,18 @@ This tool creates a new release for a project. The space name and project name a ignoreChannelRules, packagePrerelease, customFields, + confirm, }) => { try { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const releaseRepository = new ReleaseRepository(client, spaceName); + const summary = [ + `Create release for project ${projectName}`, + releaseVersion ? `version ${releaseVersion}` : null, + channelName ? `on channel ${channelName}` : null, + gitRef ? `from ${gitRef}` : null, + `in space ${spaceName}`, + ] + .filter(Boolean) + .join(" "); const command = { spaceName: spaceName, @@ -103,6 +120,21 @@ This tool creates a new release for a project. The space name and project name a ...(customFields && { CustomFields: customFields }), }; + const confirmation = await requireConfirmation(server, { + message: `${summary}?`, + fallbackConfirm: confirm, + change: { source: {}, target: command }, + }); + if (!confirmation.confirmed) { + return unconfirmedResponse(confirmation, { + action: "release creation", + }); + } + + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const releaseRepository = new ReleaseRepository(client, spaceName); + const response = await releaseRepository.create(command); let versionControlReference: { GitRef?: string; GitCommit?: string } | undefined; diff --git a/src/tools/deployRelease.ts b/src/tools/deployRelease.ts index 868b061..c1212d9 100644 --- a/src/tools/deployRelease.ts +++ b/src/tools/deployRelease.ts @@ -4,6 +4,10 @@ import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; import { registerToolDefinition } from "../types/toolConfig.js"; import { handleOctopusApiError } from "../helpers/errorHandling.js"; +import { + requireConfirmation, + unconfirmedResponse, +} from "../helpers/requireConfirmation.js"; export function registerDeployReleaseTool(server: McpServer) { server.tool( @@ -86,6 +90,12 @@ The tool automatically determines which deployment type to use based on the para .array(z.string()) .optional() .describe("Names of deployment freezes to override"), + confirm: z + .boolean() + .optional() + .describe( + "Required only when the MCP client does not support elicitation. Set to true to confirm deployment; otherwise the tool aborts.", + ), }, { title: "Deploy a release to environments in Octopus Deploy", @@ -110,6 +120,7 @@ The tool automatically determines which deployment type to use based on the para variables, deploymentFreezeOverrideReason, deploymentFreezeNames, + confirm, }) => { try { // Validate environment names @@ -130,12 +141,14 @@ The tool automatically determines which deployment type to use based on the para ); } - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const deploymentRepository = new DeploymentRepository( - client, - spaceName, - ); + const tenantSummary = isTenanted + ? ` for tenants [${(tenants ?? []).join(", ")}${ + tenantTags?.length ? `; tags: ${tenantTags.join(", ")}` : "" + }]` + : ""; + const confirmMessage = + `Deploy release ${releaseVersion} of ${projectName} to ` + + `[${environmentNames.join(", ")}]${tenantSummary} in space ${spaceName}?`; // Build common parameters const commonParams = { @@ -171,32 +184,42 @@ The tool automatically determines which deployment type to use based on the para }), }; - let response; - let deploymentType; + const tenantedCommand = { + ...commonParams, + ReleaseVersion: releaseVersion, + EnvironmentName: environmentNames[0], + Tenants: tenants || [], + TenantTags: tenantTags || [], + }; + const untenantedCommand = { + ...commonParams, + ReleaseVersion: releaseVersion, + EnvironmentNames: environmentNames, + }; - if (isTenanted) { - // Tenanted deployment - deploymentType = "tenanted"; - const command = { - ...commonParams, - ReleaseVersion: releaseVersion, - EnvironmentName: environmentNames[0], - Tenants: tenants || [], - TenantTags: tenantTags || [], - }; + const confirmation = await requireConfirmation(server, { + message: confirmMessage, + fallbackConfirm: confirm, + change: { + source: {}, + target: isTenanted ? tenantedCommand : untenantedCommand, + }, + }); + if (!confirmation.confirmed) { + return unconfirmedResponse(confirmation, { action: "deployment" }); + } - response = await deploymentRepository.createTenanted(command); - } else { - // Untenanted deployment - deploymentType = "untenanted"; - const command = { - ...commonParams, - ReleaseVersion: releaseVersion, - EnvironmentNames: environmentNames, - }; + const configuration = getClientConfigurationFromEnvironment(); + const client = await Client.create(configuration); + const deploymentRepository = new DeploymentRepository( + client, + spaceName, + ); - response = await deploymentRepository.create(command); - } + const deploymentType = isTenanted ? "tenanted" : "untenanted"; + const response = isTenanted + ? await deploymentRepository.createTenanted(tenantedCommand) + : await deploymentRepository.create(untenantedCommand); // Format the response const tasks = response.DeploymentServerTasks || [];