From dad368fae79b1f1ad242be29a9c7e61cbcc8ae21 Mon Sep 17 00:00:00 2001 From: Egor Pavlikhin Date: Tue, 5 May 2026 16:49:13 +1000 Subject: [PATCH 1/9] feat!: Replace get_task_* tools with task resource templates Task data now flows through three space-scoped resource templates instead of three dedicated tools. Bulky activity logs and step timings only travel when the agent explicitly fetches the URI, so per-call response weight drops dramatically on the common deployment-investigation path. URIs (registered into the existing release/dispatch registry): - octopus://spaces/{spaceName}/tasks/{taskId} -> ServerTask metadata (JSON) - octopus://spaces/{spaceName}/tasks/{taskId}/details -> ServerTaskDetails (JSON) - octopus://spaces/{spaceName}/tasks/{taskId}/log -> raw activity log (text/plain) BREAKING CHANGE: removes get_task_by_id, get_task_details, and get_task_raw. Callers should switch to resources/read on the URI above (or the existing read_resource tool backstop on clients without native resource support). get_task_from_url is unchanged; its consolidation is tracked separately under the from_url issue. Discoverability: - Adds a top-level instructions string on the McpServer so dynamic-discovery clients learn the resourceUri pattern and the read_resource backstop at initialize time, before any tool list is fetched. - Tightens read_resource's tool description with concrete URI examples and cross-references to the tools that emit URIs. References on existing tools updated to point at resource URIs: - get_deployment_from_url's nextSteps now returns taskResourceUri / taskLogResourceUri instead of suggesting the deleted get_task_details. - deploy_release's post-deploy helpText points at the task resource URI. - get_task_from_url's error and description references updated. - README and docs/working-with-urls.md workflows rewritten to dereference URIs via resources/read or read_resource. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 15 +- docs/working-with-urls.md | 55 +++--- src/index.ts | 27 ++- src/resources/__tests__/task.test.ts | 181 ++++++++++++++++++ src/resources/index.ts | 1 + src/resources/task.ts | 115 +++++++++++ .../getDeploymentFromUrl.integration.test.ts | 7 +- src/tools/deployRelease.ts | 2 +- src/tools/getDeploymentFromUrl.ts | 14 +- src/tools/getTaskById.ts | 69 ------- src/tools/getTaskDetails.ts | 93 --------- src/tools/getTaskFromUrl.ts | 7 +- src/tools/getTaskRaw.ts | 68 ------- src/tools/index.ts | 3 - src/tools/readResource.ts | 15 +- 15 files changed, 379 insertions(+), 293 deletions(-) create mode 100644 src/resources/__tests__/task.test.ts create mode 100644 src/resources/task.ts delete mode 100644 src/tools/getTaskById.ts delete mode 100644 src/tools/getTaskDetails.ts delete mode 100644 src/tools/getTaskRaw.ts diff --git a/README.md b/README.md index 3e05d0e..aa46d07 100644 --- a/README.md +++ b/README.md @@ -249,10 +249,11 @@ npx -y @octopusdeploy/mcp-server --no-read-only --server-url https://your-octopu **Deployment investigation workflow:** ``` 1. get_deployment_from_url with deployment URL - → Returns deployment context + taskIdForLogs + → Returns deployment context + taskResourceUri / taskLogResourceUri -2. get_task_details with spaceName and taskId - → Returns execution logs for troubleshooting +2. Fetch the task resource via resources/read (or read_resource) + octopus://spaces/{spaceName}/tasks/{taskId}/details → structured activity tree + octopus://spaces/{spaceName}/tasks/{taskId}/log → raw plain-text log ``` **Task investigation** (direct task URL): @@ -290,9 +291,11 @@ See [Working with URLs](docs/working-with-urls.md) for detailed workflows, examp - `list_releases_for_project`: List all releases for a specific project ### Tasks -- `get_task_by_id`: Get details for a specific server task by its ID -- `get_task_details`: Get detailed information for a specific server task -- `get_task_raw`: Get raw details for a specific server task +Tasks are exposed as MCP Resources rather than tools. Use `resources/read` (or the `read_resource` backstop tool) with one of: + +- `octopus://spaces/{spaceName}/tasks/{taskId}` — lightweight metadata (state, timing, completion flags) +- `octopus://spaces/{spaceName}/tasks/{taskId}/details` — full ServerTaskDetails (Progress, ActivityLogs tree, etc.) +- `octopus://spaces/{spaceName}/tasks/{taskId}/log` — raw plain-text task log ### Tenants - `find_tenants`: Find tenants in a space (can get a specific tenant by ID or list/search tenants with filters) diff --git a/docs/working-with-urls.md b/docs/working-with-urls.md index 0bdab32..f9bdd52 100644 --- a/docs/working-with-urls.md +++ b/docs/working-with-urls.md @@ -10,8 +10,8 @@ The Octopus MCP Server provides powerful URL-based tools that allow you to inves User: "Why did this deployment fail? https://your-octopus.com/app#/Spaces-1/projects/my-app/deployments/releases/1.0.0/deployments/Deployments-123" AI: I'll investigate the deployment failure for you. -[Step 1: Uses get_deployment_from_url to get deployment details and taskId] -[Step 2: Uses get_task_details with the taskId to get execution logs] +[Step 1: Uses get_deployment_from_url to get deployment details and the task resource URI] +[Step 2: Reads octopus://spaces/{spaceName}/tasks/{taskId}/details (or /log) for execution data] [Analyzes the task logs and identifies the root cause] ``` @@ -107,8 +107,9 @@ Understanding how Octopus resources relate to each other is crucial for effectiv "taskIdForLogs": "ServerTasks-456", "resolvedSpaceName": "Production", "nextSteps": { - "suggestedTool": "get_task_details", - "useTaskId": "ServerTasks-456" + "useTaskId": "ServerTasks-456", + "taskResourceUri": "octopus://spaces/Production/tasks/ServerTasks-456/details", + "taskLogResourceUri": "octopus://spaces/Production/tasks/ServerTasks-456/log" } } ``` @@ -157,16 +158,16 @@ Understanding how Octopus resources relate to each other is crucial for effectiv 1. list_spaces → find space name 2. list_deployments → find deployment 3. Extract TaskId from deployment -4. get_task_details → view logs +4. Fetch task details → view logs ``` -**New approach (2 tool calls):** +**New approach (2 calls):** ``` Step 1: get_deployment_from_url with deployment URL - → Returns deployment details + taskIdForLogs + → Returns deployment details + taskResourceUri / taskLogResourceUri -Step 2: get_task_details with spaceName and taskIdForLogs - → Returns task execution logs +Step 2: resources/read on the returned URI (or read_resource as a backstop) + → Returns task execution data (structured tree or raw log) ``` **Example:** @@ -178,11 +179,10 @@ Step 1: get_deployment_from_url ✓ Extracts Space ID (Spaces-1) and resolves to "Production" ✓ Extracts Deployment ID (Deployments-789) ✓ Fetches deployment details - ✓ Returns: Environment "Production", Release "2.1.0", taskIdForLogs: "ServerTasks-456" + ✓ Returns: Environment "Production", Release "2.1.0", taskResourceUri: "octopus://spaces/Production/tasks/ServerTasks-456/details" -Step 2: get_task_details - ✓ Uses spaceName="Production" and taskId="ServerTasks-456" - ✓ Fetches task execution logs +Step 2: read the task resource (resources/read or read_resource) + ✓ Fetches task execution logs from octopus://spaces/Production/tasks/ServerTasks-456/details ✓ Analyzes failure: "Connection timeout to database server" ``` @@ -225,12 +225,12 @@ AI uses get_deployment_from_url: ``` Step 1: get_deployment_from_url Purpose: Get deployment context - Returns: Environment, release, project, taskIdForLogs + Returns: Environment, release, project, taskResourceUri (octopus:// URI) -Step 2: get_task_details +Step 2: resources/read (or read_resource backstop) Purpose: Get execution logs and diagnose issues - Input: spaceName and taskId from Step 1 - Returns: Task state, logs, error messages + Input: the taskResourceUri from Step 1 + Returns: Task state, structured activity tree, or raw log ``` This separation provides: @@ -251,8 +251,8 @@ get_task_from_url with deployment URL **Right:** ``` get_deployment_from_url with deployment URL -→ Get taskIdForLogs -→ Use get_task_details with spaceName and taskId +→ Returns taskResourceUri (octopus://...) +→ Read the task resource via resources/read or read_resource ``` ### ❌ Assuming Deployment URLs Contain Task IDs @@ -265,17 +265,16 @@ Parse deployment URL to extract TaskId directly **Right:** ``` -Step 1: get_deployment_from_url → returns taskIdForLogs -Step 2: get_task_details with the taskId → get logs +Step 1: get_deployment_from_url → returns taskResourceUri +Step 2: read_resource({ uri: taskResourceUri }) → get logs ``` ### ❌ Using Space IDs Instead of Space Names **Wrong:** ``` -get_task_details({ - spaceName: "Spaces-1", // ❌ This is a space ID - taskId: "ServerTasks-456" +read_resource({ + uri: "octopus://spaces/Spaces-1/tasks/ServerTasks-456/details" // ❌ Spaces-1 is the ID, not the name }) ``` @@ -345,8 +344,8 @@ Each tool accepts only its matching URL type. This makes behavior predictable an When investigating deployment failures: ``` -1. get_deployment_from_url → Get context + taskId -2. get_task_details → Get execution logs +1. get_deployment_from_url → Get context + taskResourceUri +2. resources/read (or read_resource) on the URI → Get execution logs ``` This provides both context and details in a structured way. @@ -367,8 +366,8 @@ Don't manually extract IDs or resolve spaces. The URL tools handle: The tools provide actionable error messages: ``` "Could not extract task ID from URL. URL must contain a task identifier (ServerTasks-XXXXX). -If you have a deployment URL, use get_deployment_from_url first to get the task ID, -then use get_task_details to view task logs." +If you have a deployment URL, use get_deployment_from_url first to resolve the task ID, +then fetch the octopus://spaces/{spaceName}/tasks/{taskId}/details resource (or call read_resource with that URI) to view task logs." ``` ## Environment Variables for Testing diff --git a/src/index.ts b/src/index.ts index 6f68fef..9fac097 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,11 +59,28 @@ program const options = program.opts(); -const server = new McpServer({ - name: "Octopus Deploy", - description: "Official Octopus Deploy MCP server.", - version: SEMVER_VERSION, -}); +const SERVER_INSTRUCTIONS = ` +The official Octopus Deploy MCP server. Tools are grouped into toolsets (core, releases, deployments, tasks, tenants, kubernetes, machines, certificates) and you can filter them via --toolsets. Writes are gated behind --no-read-only. + +Resource URIs and how to dereference them: +- Many tools return slim summaries plus an 'octopus://...' URI in fields like 'resourceUri', 'taskResourceUri', or 'taskLogResourceUri' instead of inlining heavy payloads (release notes, packaged versions, task activity logs, etc.). To fetch the full body, dereference the URI. +- Resource-aware clients (Claude Code, MCP Inspector): call the standard 'resources/read' primitive with the URI. +- Clients without native resources/read (Claude.ai web, several IDE integrations): call the 'read_resource' tool with { uri }. It returns the same body as resources/read. Always available, regardless of toolset filter. +- The 'read_resource' tool is the universal bridge from any URI returned by any tool — if you see an 'octopus://' string in a response and don't know what to do with it, call read_resource with it. + +Currently exposed resource families: releases ('octopus://spaces/{spaceName}/releases/{releaseId}') and tasks ('octopus://spaces/{spaceName}/tasks/{taskId}', '/details', '/log'). More resource families will be added over time. +`.trim(); + +const server = new McpServer( + { + name: "Octopus Deploy", + description: "Official Octopus Deploy MCP server.", + version: SEMVER_VERSION, + }, + { + instructions: SERVER_INSTRUCTIONS, + }, +); const toolsetConfig = createToolsetConfig(options.toolsets, options.readOnly); registerTools(server, toolsetConfig); diff --git a/src/resources/__tests__/task.test.ts b/src/resources/__tests__/task.test.ts new file mode 100644 index 0000000..1120878 --- /dev/null +++ b/src/resources/__tests__/task.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; + +const getById = vi.fn(); +const getDetails = vi.fn(); +const getRaw = vi.fn(); + +vi.mock("../../helpers/getClientConfigurationFromEnvironment.js", () => ({ + getClientConfigurationFromEnvironment: () => ({ + instanceURL: "https://octopus.example", + apiKey: "API-TEST", + }), +})); + +vi.mock("@octopusdeploy/api-client", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + Client: { create: vi.fn(async () => ({})) }, + SpaceServerTaskRepository: vi.fn(function () { + return { getById, getDetails, getRaw }; + }), + }; +}); + +import { + RESOURCE_REGISTRY, + type ResourceDescriptor, +} from "../../types/resourceConfig.js"; +import { dispatchOctopusUri } from "../dispatch.js"; +import "../task.js"; + +function descriptorByName(name: string): ResourceDescriptor { + const descriptor = RESOURCE_REGISTRY.find((d) => d.name === name); + if (!descriptor) { + throw new Error( + `Resource descriptor '${name}' is not registered. Did the side-effect import run?`, + ); + } + return descriptor; +} + +describe("task resources", () => { + beforeEach(() => { + getById.mockReset(); + getDetails.mockReset(); + getRaw.mockReset(); + }); + + describe("octopus://spaces/{spaceName}/tasks/{taskId}", () => { + const descriptor = () => descriptorByName("task"); + + it("returns task summary as JSON with Links stripped", async () => { + getById.mockResolvedValueOnce({ + Id: "ServerTasks-42", + Name: "Deploy", + State: "Success", + Links: { Self: "/api/tasks/ServerTasks-42" }, + }); + + const payload = await descriptor().read({ + spaceName: "Default", + taskId: "ServerTasks-42", + }); + + expect(payload.mimeType).toBe("application/json"); + const body = JSON.parse(payload.text); + expect(body).toEqual({ + Id: "ServerTasks-42", + Name: "Deploy", + State: "Success", + }); + expect(body.Links).toBeUndefined(); + expect(getById).toHaveBeenCalledWith("ServerTasks-42"); + }); + + it("rejects an invalid taskId before any API call", async () => { + await expect( + descriptor().read({ + spaceName: "Default", + taskId: "not-a-task-id", + }), + ).rejects.toThrow(/Invalid task ID format/); + + expect(getById).not.toHaveBeenCalled(); + }); + + it("translates 404 from the api-client into a friendly error", async () => { + getById.mockRejectedValueOnce( + new Error("Resource not found (404)"), + ); + + await expect( + descriptor().read({ + spaceName: "Default", + taskId: "ServerTasks-99", + }), + ).rejects.toThrow(/not found in space 'Default'/); + }); + }); + + describe("octopus://spaces/{spaceName}/tasks/{taskId}/details", () => { + const descriptor = () => descriptorByName("task-details"); + + it("returns ServerTaskDetails as JSON with Links stripped", async () => { + getDetails.mockResolvedValueOnce({ + Task: { Id: "ServerTasks-42", State: "Success" }, + Progress: { ProgressPercentage: 100, EstimatedTimeRemaining: "" }, + PhysicalLogSize: 1234, + ActivityLogs: [], + Links: { Self: "/api/tasks/ServerTasks-42/details" }, + }); + + const payload = await descriptor().read({ + spaceName: "Default", + taskId: "ServerTasks-42", + }); + + expect(payload.mimeType).toBe("application/json"); + const body = JSON.parse(payload.text); + expect(body.Task).toEqual({ Id: "ServerTasks-42", State: "Success" }); + expect(body.PhysicalLogSize).toBe(1234); + expect(body.Links).toBeUndefined(); + expect(getDetails).toHaveBeenCalledWith("ServerTasks-42"); + }); + }); + + describe("octopus://spaces/{spaceName}/tasks/{taskId}/log", () => { + const descriptor = () => descriptorByName("task-log"); + + it("returns the raw log as text/plain without JSON wrapping", async () => { + const rawLog = "12:00:00 Info | Step 1\n12:00:01 Error | Boom\n"; + getRaw.mockResolvedValueOnce(rawLog); + + const payload = await descriptor().read({ + spaceName: "Default", + taskId: "ServerTasks-42", + }); + + expect(payload.mimeType).toBe("text/plain"); + expect(payload.text).toBe(rawLog); + expect(getRaw).toHaveBeenCalledWith("ServerTasks-42"); + }); + }); + + describe("dispatch integration", () => { + it("routes URIs to the matching task descriptor and URL-decodes spaceName", async () => { + getDetails.mockResolvedValueOnce({ + Task: { Id: "ServerTasks-7", State: "Executing" }, + }); + + const payload = await dispatchOctopusUri( + "octopus://spaces/AI%20Foundations/tasks/ServerTasks-7/details", + ); + + expect(payload).not.toBeNull(); + expect(payload!.mimeType).toBe("application/json"); + // SpaceServerTaskRepository should have been constructed with the + // decoded space name; verify via the captured call to getDetails. + expect(getDetails).toHaveBeenCalledWith("ServerTasks-7"); + }); + + it("dispatch and direct read produce identical payloads", async () => { + getById.mockResolvedValue({ + Id: "ServerTasks-1", + State: "Success", + Links: { Self: "ignored" }, + }); + + const direct = await descriptorByName("task").read({ + spaceName: "Default", + taskId: "ServerTasks-1", + }); + const dispatched = await dispatchOctopusUri( + "octopus://spaces/Default/tasks/ServerTasks-1", + ); + + expect(dispatched).toEqual(direct); + }); + }); +}); diff --git a/src/resources/index.ts b/src/resources/index.ts index da57810..9f2b9d1 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -9,6 +9,7 @@ import { flatten } from "./dispatch.js"; // Side-effect imports populate RESOURCE_REGISTRY. import "./release.js"; +import "./task.js"; function isToolsetEnabled(toolset: Toolset, config: ToolsetConfig): boolean { const enabled: Toolset[] = diff --git a/src/resources/task.ts b/src/resources/task.ts new file mode 100644 index 0000000..ea62f56 --- /dev/null +++ b/src/resources/task.ts @@ -0,0 +1,115 @@ +import { Client, SpaceServerTaskRepository } from "@octopusdeploy/api-client"; +import { registerResourceDescriptor } from "../types/resourceConfig.js"; +import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; +import { + validateEntityId, + handleOctopusApiError, + ENTITY_PREFIXES, +} from "../helpers/errorHandling.js"; +import { stripLinks } from "../helpers/stripLinks.js"; + +const TASK_HELP_TEXT = + "Use find_releases or list_deployments to find tasks via their parent entity, or get_deployment_from_url / get_task_from_url for URL-based lookups."; + +registerResourceDescriptor({ + name: "task", + uriTemplate: "octopus://spaces/{spaceName}/tasks/{taskId}", + toolset: "tasks", + title: "Octopus task summary", + description: + "Lightweight task metadata: state, timing, completion flags, and arguments. Cheap to fetch — use this for polling or status checks. For step timings and embedded log entries, use the /details URI; for the flat plain-text log, use the /log URI.", + mimeType: "application/json", + read: async ({ spaceName, taskId }) => { + validateEntityId(taskId, "task", ENTITY_PREFIXES.task); + + try { + const client = await Client.create( + getClientConfigurationFromEnvironment(), + ); + const task = await new SpaceServerTaskRepository(client, spaceName).getById( + taskId, + ); + + return { + mimeType: "application/json", + text: JSON.stringify(stripLinks(task)), + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: "task", + entityId: taskId, + spaceName, + helpText: TASK_HELP_TEXT, + }); + } + }, +}); + +registerResourceDescriptor({ + name: "task-details", + uriTemplate: "octopus://spaces/{spaceName}/tasks/{taskId}/details", + toolset: "tasks", + title: "Octopus task details (structured activity tree)", + description: + "Full ServerTaskDetails payload: the task summary plus Progress, PhysicalLogSize, and the hierarchical ActivityLogs tree (each step's children, status, and embedded log entries). Heavier than the /task summary — fetch when you need step-by-step timings or programmatic access to log entries.", + mimeType: "application/json", + read: async ({ spaceName, taskId }) => { + validateEntityId(taskId, "task", ENTITY_PREFIXES.task); + + try { + const client = await Client.create( + getClientConfigurationFromEnvironment(), + ); + const details = await new SpaceServerTaskRepository( + client, + spaceName, + ).getDetails(taskId); + + return { + mimeType: "application/json", + text: JSON.stringify(stripLinks(details)), + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: "task", + entityId: taskId, + spaceName, + helpText: TASK_HELP_TEXT, + }); + } + }, +}); + +registerResourceDescriptor({ + name: "task-log", + uriTemplate: "octopus://spaces/{spaceName}/tasks/{taskId}/log", + toolset: "tasks", + title: "Octopus task raw activity log", + description: + "Raw plain-text task log as displayed in the Octopus portal. Use when you need to grep / read the log linearly. For programmatic access to individual log entries with categories and timestamps, use the /details URI instead.", + mimeType: "text/plain", + read: async ({ spaceName, taskId }) => { + validateEntityId(taskId, "task", ENTITY_PREFIXES.task); + + try { + const client = await Client.create( + getClientConfigurationFromEnvironment(), + ); + const log = await new SpaceServerTaskRepository(client, spaceName).getRaw( + taskId, + ); + + return { + mimeType: "text/plain", + text: log, + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: "task", + entityId: taskId, + spaceName, + helpText: TASK_HELP_TEXT, + }); + } + }, +}); diff --git a/src/tools/__tests__/getDeploymentFromUrl.integration.test.ts b/src/tools/__tests__/getDeploymentFromUrl.integration.test.ts index 4feaced..79330b5 100644 --- a/src/tools/__tests__/getDeploymentFromUrl.integration.test.ts +++ b/src/tools/__tests__/getDeploymentFromUrl.integration.test.ts @@ -45,7 +45,12 @@ describe('getDeploymentFromUrl Integration Tests', () => { // Verify next steps guidance expect(result.nextSteps).toBeDefined(); - expect(result.nextSteps.suggestedTool).toBe('get_task_details'); + expect(result.nextSteps.taskResourceUri).toMatch( + /^octopus:\/\/spaces\/.+\/tasks\/ServerTasks-\d+\/details$/, + ); + expect(result.nextSteps.taskLogResourceUri).toMatch( + /^octopus:\/\/spaces\/.+\/tasks\/ServerTasks-\d+\/log$/, + ); }, testConfig.timeout); }); diff --git a/src/tools/deployRelease.ts b/src/tools/deployRelease.ts index 0e8fd89..7942237 100644 --- a/src/tools/deployRelease.ts +++ b/src/tools/deployRelease.ts @@ -215,7 +215,7 @@ The tool automatically determines which deployment type to use based on the para deploymentId: task.DeploymentId, })), message: `Successfully created ${tasks.length} deployment(s) for release ${releaseVersion}`, - helpText: `Use get_task_by_id with the taskId values to monitor deployment progress. Use list_deployments to view deployment details. Use find_releases to verify the release exists.`, + helpText: `Fetch the octopus://spaces/{spaceName}/tasks/{taskId} resource (or call read_resource with that URI) to monitor deployment progress; use the /details or /log suffix for the structured activity tree or raw log. Use list_deployments to view deployment details. Use find_releases to verify the release exists.`, }, null, 2, diff --git a/src/tools/getDeploymentFromUrl.ts b/src/tools/getDeploymentFromUrl.ts index 4c09848..378afc4 100644 --- a/src/tools/getDeploymentFromUrl.ts +++ b/src/tools/getDeploymentFromUrl.ts @@ -116,13 +116,10 @@ export async function getDeploymentFromUrl(client: Client, params: GetDeployment resourceType: urlParts.resourceType, }, nextSteps: { - description: "To view task logs and execution details for this deployment", + description: "To view task logs and execution details for this deployment, fetch the corresponding task resource. Resource-aware clients can call resources/read directly; otherwise use the read_resource tool.", useTaskId: deployment.TaskId, - suggestedTool: "get_task_details", - suggestedParams: { - spaceName, - taskId: deployment.TaskId, - } + taskResourceUri: `octopus://spaces/${encodeURIComponent(spaceName)}/tasks/${encodeURIComponent(deployment.TaskId)}/details`, + taskLogResourceUri: `octopus://spaces/${encodeURIComponent(spaceName)}/tasks/${encodeURIComponent(deployment.TaskId)}/log`, } }; } @@ -137,13 +134,14 @@ https://your-octopus.com/app#/Spaces-1/projects/my-app/deployments/releases/1.0. Returns: - Full deployment details (environment, release, project, created time) -- taskIdForLogs: Use this with get_task_details to view execution logs +- taskIdForLogs: the ServerTasks- ID for this deployment +- taskResourceUri / taskLogResourceUri: octopus:// URIs to fetch the task body or raw log via resources/read (or read_resource on clients without native resource support) - Public URL for web portal access Recommended workflow for investigating deployment issues: 1. Call get_deployment_from_url with the deployment URL 2. Review deployment context (environment, release version, etc.) -3. Use the returned taskIdForLogs with get_task_details to view execution logs and diagnose issues +3. Fetch the returned taskResourceUri (or taskLogResourceUri) to view execution details / raw log Handles space ID to space name resolution automatically.`, { diff --git a/src/tools/getTaskById.ts b/src/tools/getTaskById.ts deleted file mode 100644 index 1219505..0000000 --- a/src/tools/getTaskById.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { Client, SpaceServerTaskRepository } from '@octopusdeploy/api-client'; -import { z } from 'zod'; -import { getClientConfigurationFromEnvironment } from '../helpers/getClientConfigurationFromEnvironment.js'; -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { registerToolDefinition } from '../types/toolConfig.js'; -import { tasksDescription } from '../types/taskTypes.js'; -import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from '../helpers/errorHandling.js'; - -export interface GetTaskByIdParams { - spaceName: string; - taskId: string; -} - -export async function getTaskById(client: Client, params: GetTaskByIdParams) { - const { spaceName, taskId } = params; - - validateEntityId(taskId, 'task', ENTITY_PREFIXES.task); - - const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); - const response = await serverTaskRepository.getById(taskId); - return response; -} - -export function registerGetTaskByIdTool(server: McpServer) { - server.tool( - 'get_task_by_id', - `Get details for a specific server task by its ID. ${tasksDescription}`, - { spaceName: z.string(), taskId: z.string() }, - { - title: 'Get details for a specific server task by its ID', - readOnlyHint: true, - }, - async (args) => { - const { spaceName, taskId } = args as GetTaskByIdParams; - - validateEntityId(taskId, 'task', ENTITY_PREFIXES.task); - - try { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); - - const response = await serverTaskRepository.getById(taskId); - - return { - content: [ - { - type: "text", - text: JSON.stringify(response), - }, - ], - }; - } catch (error) { - handleOctopusApiError(error, { - entityType: 'task', - entityId: taskId, - spaceName, - helpText: "Use list_deployments or list_releases to find valid task IDs." - }); - } - } - ); -} - -registerToolDefinition({ - toolName: "get_task_by_id", - config: { toolset: "tasks", readOnly: true }, - registerFn: registerGetTaskByIdTool, -}); \ No newline at end of file diff --git a/src/tools/getTaskDetails.ts b/src/tools/getTaskDetails.ts deleted file mode 100644 index 22f6935..0000000 --- a/src/tools/getTaskDetails.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { Client, SpaceServerTaskRepository } from "@octopusdeploy/api-client"; -import { z } from "zod"; -import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; -import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { registerToolDefinition } from "../types/toolConfig.js"; -import { tasksDescription } from "../types/taskTypes.js"; -import { - validateEntityId, - handleOctopusApiError, - ENTITY_PREFIXES, -} from "../helpers/errorHandling.js"; - -export interface GetTaskDetailsParams { - spaceName: string; - taskId: string; -} - -export async function getTaskDetails( - client: Client, - params: GetTaskDetailsParams, -) { - const { spaceName, taskId } = params; - - validateEntityId(taskId, "task", ENTITY_PREFIXES.task); - - const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); - - try { - const response = await serverTaskRepository.getDetails(taskId); - return response; - } catch (error) { - if (error instanceof Error && error.message.includes("not found")) { - throw new Error( - `Task ${taskId} not found in space "${spaceName}". ` + - `\n\nCommon causes:\n` + - `1. If you extracted this task ID from a deployment URL, note that deployment URLs do not contain task IDs.\n` + - ` - Instead, use get_deployment_from_url or get_task_from_url to automatically resolve the correct task ID\n` + - `2. The task may exist in a different space\n` + - `3. The task may have been deleted or archived\n` + - `4. You may not have permission to view this task\n\n` + - `Tip: Use get_task_from_url if you have an Octopus Deploy URL instead of manually extracting IDs.`, - ); - } - throw error; - } -} - -export function registerGetTaskDetailsTool(server: McpServer) { - server.tool( - "get_task_details", - `Get detailed information for a specific server task by its ID. ${tasksDescription}`, - { spaceName: z.string(), taskId: z.string() }, - { - title: "Get detailed information for a specific server task by its ID", - readOnlyHint: true, - }, - async (args) => { - const { spaceName, taskId } = args as GetTaskDetailsParams; - - validateEntityId(taskId, "task", ENTITY_PREFIXES.task); - - try { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - - const response = await getTaskDetails(client, { spaceName, taskId }); - - return { - content: [ - { - type: "text", - text: JSON.stringify(response), - }, - ], - }; - } catch (error) { - handleOctopusApiError(error, { - entityType: "task", - entityId: taskId, - spaceName, - helpText: - "Use list_deployments or list_releases to find valid task IDs.", - }); - } - }, - ); -} - -registerToolDefinition({ - toolName: "get_task_details", - config: { toolset: "tasks", readOnly: true }, - registerFn: registerGetTaskDetailsTool, -}); diff --git a/src/tools/getTaskFromUrl.ts b/src/tools/getTaskFromUrl.ts index 81115ff..6f85e7e 100644 --- a/src/tools/getTaskFromUrl.ts +++ b/src/tools/getTaskFromUrl.ts @@ -33,8 +33,8 @@ export async function getTaskFromUrl(client: Client, params: GetTaskFromUrlParam throw new Error( `Could not extract task ID from URL. ` + `URL must contain a task identifier (ServerTasks-XXXXX). ` + - `If you have a deployment URL, use get_deployment_from_url first to get the task ID, ` + - `then use get_task_details to view task logs.` + `If you have a deployment URL, use get_deployment_from_url first to resolve the task ID, ` + + `then fetch the octopus://spaces/{spaceName}/tasks/{taskId}/details resource (or call read_resource with that URI) to view task logs.` ); } @@ -72,8 +72,7 @@ Key features: For deployment URLs: If you have a deployment URL, use this workflow: 1. Call get_deployment_from_url with the deployment URL -2. Extract the taskId from the response -3. Call get_task_details with spaceName and taskId to view logs +2. Use the returned taskResourceUri / taskLogResourceUri (or fetch octopus://spaces/{spaceName}/tasks/{taskId}/details via resources/read or read_resource) ${tasksDescription}`, { diff --git a/src/tools/getTaskRaw.ts b/src/tools/getTaskRaw.ts deleted file mode 100644 index c2b579e..0000000 --- a/src/tools/getTaskRaw.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { Client, SpaceServerTaskRepository } from '@octopusdeploy/api-client'; -import { z } from 'zod'; -import { getClientConfigurationFromEnvironment } from '../helpers/getClientConfigurationFromEnvironment.js'; -import { type McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; -import { registerToolDefinition } from '../types/toolConfig.js'; -import { validateEntityId, handleOctopusApiError, ENTITY_PREFIXES } from '../helpers/errorHandling.js'; - -export interface GetTaskRawParams { - spaceName: string; - taskId: string; -} - -export async function getTaskRaw(client: Client, params: GetTaskRawParams) { - const { spaceName, taskId } = params; - - validateEntityId(taskId, 'task', ENTITY_PREFIXES.task); - - const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); - const response = await serverTaskRepository.getRaw(taskId); - return response; -} - -export function registerGetTaskRawTool(server: McpServer) { - server.tool( - 'get_task_raw', - 'Get raw details for a specific server task by its ID', - { spaceName: z.string(), taskId: z.string() }, - { - title: 'Get raw details for a specific server task by its ID', - readOnlyHint: true, - }, - async (args) => { - const { spaceName, taskId } = args as GetTaskRawParams; - - validateEntityId(taskId, 'task', ENTITY_PREFIXES.task); - - try { - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const serverTaskRepository = new SpaceServerTaskRepository(client, spaceName); - - const response = await serverTaskRepository.getRaw(taskId); - - return { - content: [ - { - type: "text", - text: response, - }, - ], - }; - } catch (error) { - handleOctopusApiError(error, { - entityType: 'task', - entityId: taskId, - spaceName, - helpText: "Use list_deployments or list_releases to find valid task IDs." - }); - } - } - ); -} - -registerToolDefinition({ - toolName: "get_task_raw", - config: { toolset: "tasks", readOnly: true }, - registerFn: registerGetTaskRawTool, -}); \ No newline at end of file diff --git a/src/tools/index.ts b/src/tools/index.ts index 254ae99..57a8814 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -11,9 +11,6 @@ import "./listSpaces.js"; import "./listProjects.js"; import "./listEnvironments.js"; import "./listDeployments.js"; -import "./getTaskById.js"; -import "./getTaskDetails.js"; -import "./getTaskRaw.js"; import "./getTenantVariables.js"; import "./getMissingTenantVariables.js"; import "./getKubernetesLiveStatus.js"; diff --git a/src/tools/readResource.ts b/src/tools/readResource.ts index 5d3945b..fd9d937 100644 --- a/src/tools/readResource.ts +++ b/src/tools/readResource.ts @@ -8,19 +8,20 @@ export function registerReadResourceTool(server: McpServer) { "read_resource", { title: "Read an Octopus resource by URI", - description: `Dereference an octopus:// resource URI and return its body. + description: `Universal fetch for any 'octopus://' URI returned by any other tool. Use this whenever you see fields like 'resourceUri', 'taskResourceUri', or 'taskLogResourceUri' in a response and need the full body. - Backstop for MCP clients that do not natively support the resources/read primitive. - Resource-aware clients should call resources/read directly instead of this tool. +How to use: +- Pass the URI string verbatim. Examples: 'octopus://spaces/Default/releases/Releases-42', 'octopus://spaces/Default/tasks/ServerTasks-7/details', 'octopus://spaces/Default/tasks/ServerTasks-7/log'. +- The response 'mimeType' tells you how to interpret 'text': 'application/json' → parse as JSON; 'text/plain' → use as-is. - Pass any octopus:// URI returned by another tool (typically the resourceUri field). - The response 'mimeType' tells you how to interpret 'text': usually 'application/json' - (parse it) or 'text/markdown' (display as-is).`, +This tool is the backstop for clients that do not natively implement the MCP 'resources/read' primitive. Clients that DO support resources/read (Claude Code, MCP Inspector) can call it directly and skip this tool. Either path returns byte-identical bodies. + +Tools that return resource URIs include: find_releases, get_deployment_from_url, get_task_from_url, and others. When in doubt, call read_resource on any 'octopus://' string you encounter.`, inputSchema: { uri: z .string() .describe( - "An octopus:// resource URI returned in the resourceUri field of a tool response.", + "Any 'octopus://...' URI returned by another tool (e.g. in the resourceUri, taskResourceUri, or taskLogResourceUri field).", ), }, annotations: { readOnlyHint: true }, From e23d1b378962e18231e2e87c63a76a74e8a990f6 Mon Sep 17 00:00:00 2001 From: Egor Pavlikhin Date: Tue, 5 May 2026 18:30:36 +1000 Subject: [PATCH 2/9] feat: Add grep_task_log tool for searching task activity logs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The /log resource returns the entire activity log as plain text, which is fine for short tasks but expensive for long-running deployments. When the agent already knows what it is looking for (an error string, a step name, a regex), grep_task_log returns only matching lines instead. Parameter shape mirrors GNU grep so the schema is self-explanatory to any LLM that knows grep: pattern (regex by default; fixedString:true for literal text) caseInsensitive (-i) invertMatch (-v) fixedString (-F) beforeContext (-B) afterContext (-A) maxCount (-m) The response includes totalMatches across the whole log (so the caller sees the true count even when truncated), 1-indexed line numbers, optional before/after context arrays, and the fullLogResourceUri for fall-through to the resource when grep was too narrow. Implementation reuses SpaceServerTaskRepository.getRaw — no new HTTP code. Pure-function grepLines() exported for unit tests; 12 tests cover each flag, context-window edge cases, regex error handling, and totalMatches accounting under maxCount. Server-level instructions and README updated so dynamic-discovery clients discover the tool alongside the /log resource. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 6 +- src/index.ts | 4 + src/tools/__tests__/grepTaskLog.test.ts | 167 +++++++++++++++ src/tools/grepTaskLog.ts | 263 ++++++++++++++++++++++++ src/tools/index.ts | 3 + 5 files changed, 442 insertions(+), 1 deletion(-) create mode 100644 src/tools/__tests__/grepTaskLog.test.ts create mode 100644 src/tools/grepTaskLog.ts diff --git a/README.md b/README.md index aa46d07..a353fb2 100644 --- a/README.md +++ b/README.md @@ -291,12 +291,16 @@ See [Working with URLs](docs/working-with-urls.md) for detailed workflows, examp - `list_releases_for_project`: List all releases for a specific project ### Tasks -Tasks are exposed as MCP Resources rather than tools. Use `resources/read` (or the `read_resource` backstop tool) with one of: +Task data is primarily exposed as MCP Resources rather than tools. Use `resources/read` (or the `read_resource` backstop tool) with one of: - `octopus://spaces/{spaceName}/tasks/{taskId}` — lightweight metadata (state, timing, completion flags) - `octopus://spaces/{spaceName}/tasks/{taskId}/details` — full ServerTaskDetails (Progress, ActivityLogs tree, etc.) - `octopus://spaces/{spaceName}/tasks/{taskId}/log` — raw plain-text task log +The one task-related Tool is for searching: + +- `grep_task_log`: Search a task's activity log without fetching the full body. Parameters mirror GNU grep (`pattern`, `caseInsensitive`, `invertMatch`, `fixedString`, `beforeContext`, `afterContext`, `maxCount`). Returns matching lines with line numbers and optional context windows plus a `totalMatches` count. + ### Tenants - `find_tenants`: Find tenants in a space (can get a specific tenant by ID or list/search tenants with filters) - `get_tenant_variables`: Get tenant variables by type (all, common, or project) diff --git a/src/index.ts b/src/index.ts index 9fac097..6076e8e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -69,6 +69,10 @@ Resource URIs and how to dereference them: - The 'read_resource' tool is the universal bridge from any URI returned by any tool — if you see an 'octopus://' string in a response and don't know what to do with it, call read_resource with it. Currently exposed resource families: releases ('octopus://spaces/{spaceName}/releases/{releaseId}') and tasks ('octopus://spaces/{spaceName}/tasks/{taskId}', '/details', '/log'). More resource families will be added over time. + +Searching task logs without fetching the whole thing: +- The /log resource returns the entire activity log as plain text — fine for short tasks but expensive for long-running deployments. +- When you know what you are looking for (an error string, a step name, a regex), call the 'grep_task_log' tool instead. Its parameters mirror GNU grep (pattern, caseInsensitive, invertMatch, fixedString, beforeContext, afterContext, maxCount). It returns only matching lines with optional context windows plus a totalMatches count, so you can scan a multi-megabyte log without inhaling it. `.trim(); const server = new McpServer( diff --git a/src/tools/__tests__/grepTaskLog.test.ts b/src/tools/__tests__/grepTaskLog.test.ts new file mode 100644 index 0000000..8a23179 --- /dev/null +++ b/src/tools/__tests__/grepTaskLog.test.ts @@ -0,0 +1,167 @@ +import { describe, it, expect } from "vitest"; +import { grepLines } from "../grepTaskLog.js"; + +const SAMPLE_LOG = [ + "2026-05-05T12:00:00 Info | Step 1 starting", + "2026-05-05T12:00:01 Info | Acquiring packages", + "2026-05-05T12:00:02 Warn | Package version mismatch detected", + "2026-05-05T12:00:03 Info | Step 1 finished", + "2026-05-05T12:00:04 Info | Step 2 starting", + "2026-05-05T12:00:05 Error | Connection timeout to database", + "2026-05-05T12:00:06 Error | Step 2 failed: see above", + "2026-05-05T12:00:07 Info | Cleanup", + "", +].join("\n"); + +const baseParams = { + spaceName: "Default", + taskId: "ServerTasks-1", + pattern: "", +}; + +describe("grepLines", () => { + it("returns line-numbered matches for a literal regex pattern", () => { + const result = grepLines(SAMPLE_LOG, { ...baseParams, pattern: "Error" }); + + expect(result.totalMatches).toBe(2); + expect(result.matches.map((m) => m.lineNumber)).toEqual([6, 7]); + expect(result.matches[0].line).toContain("Connection timeout"); + }); + + it("treats pattern as a regex by default", () => { + const result = grepLines(SAMPLE_LOG, { + ...baseParams, + pattern: "Step \\d finished", + }); + + expect(result.totalMatches).toBe(1); + expect(result.matches[0].lineNumber).toBe(4); + }); + + it("caseInsensitive flag matches grep -i", () => { + const sensitive = grepLines(SAMPLE_LOG, { + ...baseParams, + pattern: "error", + }); + const insensitive = grepLines(SAMPLE_LOG, { + ...baseParams, + pattern: "error", + caseInsensitive: true, + }); + + expect(sensitive.totalMatches).toBe(0); + expect(insensitive.totalMatches).toBe(2); + }); + + it("invertMatch flag matches grep -v", () => { + const result = grepLines(SAMPLE_LOG, { + ...baseParams, + pattern: "Info", + invertMatch: true, + }); + + // 8 non-empty lines total, 5 are Info → 3 non-Info matches + expect(result.totalMatches).toBe(3); + expect(result.matches.map((m) => m.lineNumber)).toEqual([3, 6, 7]); + }); + + it("fixedString flag escapes regex metacharacters (grep -F)", () => { + const log = "alpha (1)\nbeta (2)\ngamma (3)\n"; + + // Without -F, "(1)" is a regex group capturing the literal "1" — matches only that line. + const regex = grepLines(log, { + ...baseParams, + pattern: "(1)", + }); + expect(regex.totalMatches).toBe(1); + + // With -F, the parentheses are literal. + const fixed = grepLines(log, { + ...baseParams, + pattern: "(1)", + fixedString: true, + }); + expect(fixed.totalMatches).toBe(1); + expect(fixed.matches[0].line).toBe("alpha (1)"); + + // And -F should not throw on input that would be invalid regex. + expect(() => + grepLines(log, { ...baseParams, pattern: "(unbalanced", fixedString: true }), + ).not.toThrow(); + }); + + it("invalid regex pattern throws a friendly error suggesting fixedString", () => { + expect(() => + grepLines("anything\n", { ...baseParams, pattern: "(unbalanced" }), + ).toThrow(/fixedString:true/); + }); + + it("beforeContext and afterContext mirror grep -B / -A", () => { + const result = grepLines(SAMPLE_LOG, { + ...baseParams, + pattern: "Connection timeout", + beforeContext: 2, + afterContext: 1, + }); + + expect(result.matches).toHaveLength(1); + const [match] = result.matches; + expect(match.lineNumber).toBe(6); + expect(match.before?.map((c) => c.lineNumber)).toEqual([4, 5]); + expect(match.after?.map((c) => c.lineNumber)).toEqual([7]); + }); + + it("context windows clip at log boundaries", () => { + const result = grepLines(SAMPLE_LOG, { + ...baseParams, + pattern: "Step 1 starting", + beforeContext: 3, + }); + + expect(result.matches[0].lineNumber).toBe(1); + expect(result.matches[0].before).toEqual([]); + }); + + it("maxCount caps returned matches but totalMatches reflects the true count", () => { + const result = grepLines(SAMPLE_LOG, { + ...baseParams, + pattern: "Info", + maxCount: 2, + }); + + expect(result.totalMatches).toBe(5); + expect(result.matches).toHaveLength(2); + expect(result.matches.map((m) => m.lineNumber)).toEqual([1, 2]); + }); + + it("totalLines excludes the trailing empty element from a final newline", () => { + const result = grepLines("one\ntwo\nthree\n", { + ...baseParams, + pattern: ".", + }); + + expect(result.totalLines).toBe(3); + expect(result.totalMatches).toBe(3); + }); + + it("handles a log with no trailing newline", () => { + const result = grepLines("one\ntwo\nthree", { + ...baseParams, + pattern: "two", + }); + + expect(result.totalLines).toBe(3); + expect(result.totalMatches).toBe(1); + expect(result.matches[0].lineNumber).toBe(2); + }); + + it("returns empty matches array when nothing matches", () => { + const result = grepLines(SAMPLE_LOG, { + ...baseParams, + pattern: "no-such-string", + }); + + expect(result.totalMatches).toBe(0); + expect(result.matches).toEqual([]); + }); +}); diff --git a/src/tools/grepTaskLog.ts b/src/tools/grepTaskLog.ts new file mode 100644 index 0000000..32db7b3 --- /dev/null +++ b/src/tools/grepTaskLog.ts @@ -0,0 +1,263 @@ +import { Client, SpaceServerTaskRepository } from "@octopusdeploy/api-client"; +import { z } from "zod"; +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { registerToolDefinition } from "../types/toolConfig.js"; +import { getClientConfigurationFromEnvironment } from "../helpers/getClientConfigurationFromEnvironment.js"; +import { + validateEntityId, + handleOctopusApiError, + ENTITY_PREFIXES, +} from "../helpers/errorHandling.js"; + +export interface GrepTaskLogParams { + spaceName: string; + taskId: string; + pattern: string; + caseInsensitive?: boolean; + invertMatch?: boolean; + fixedString?: boolean; + beforeContext?: number; + afterContext?: number; + maxCount?: number; +} + +export interface ContextLine { + lineNumber: number; + line: string; +} + +export interface GrepMatch { + lineNumber: number; + line: string; + before?: ContextLine[]; + after?: ContextLine[]; +} + +export interface GrepTaskLogResult { + spaceName: string; + taskId: string; + pattern: string; + totalLines: number; + totalMatches: number; + returnedMatches: number; + truncated: boolean; + matches: GrepMatch[]; + fullLogResourceUri: string; +} + +const MAX_CONTEXT = 50; +const MAX_COUNT_HARD_CAP = 500; + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +function compilePattern( + pattern: string, + caseInsensitive: boolean, + fixedString: boolean, +): RegExp { + const source = fixedString ? escapeRegExp(pattern) : pattern; + const flags = caseInsensitive ? "i" : ""; + try { + return new RegExp(source, flags); + } catch (error) { + throw new Error( + `Invalid pattern: ${error instanceof Error ? error.message : String(error)}. ` + + "Set fixedString:true to treat the pattern as a literal substring instead of a regex.", + ); + } +} + +/** + * Pure-function grep implementation. Exported for unit tests. + * + * Mirrors GNU grep's line-by-line semantics: each line is tested independently, + * matching lines are emitted with optional symmetric context windows. Overlapping + * context between adjacent matches is NOT deduplicated — each match carries its + * own complete context window so the consumer can reason about each match in + * isolation. This is a deliberate departure from GNU grep's `--`-separated + * output but it is the right shape for a JSON tool response. + */ +export function grepLines( + rawLog: string, + params: GrepTaskLogParams, +): { totalLines: number; totalMatches: number; matches: GrepMatch[] } { + const { + pattern, + caseInsensitive = false, + invertMatch = false, + fixedString = false, + beforeContext = 0, + afterContext = 0, + maxCount = 100, + } = params; + + const lines = rawLog.split("\n"); + // Drop the trailing empty element produced by a final newline. + if (lines.length > 0 && lines[lines.length - 1] === "") lines.pop(); + + const regex = compilePattern(pattern, caseInsensitive, fixedString); + + const matches: GrepMatch[] = []; + let totalMatches = 0; + + for (let i = 0; i < lines.length; i++) { + const isMatch = regex.test(lines[i]) !== invertMatch; + if (!isMatch) continue; + + totalMatches++; + if (matches.length >= maxCount) continue; + + const match: GrepMatch = { + lineNumber: i + 1, + line: lines[i], + }; + + if (beforeContext > 0) { + const start = Math.max(0, i - beforeContext); + match.before = lines.slice(start, i).map((line, idx) => ({ + lineNumber: start + idx + 1, + line, + })); + } + + if (afterContext > 0) { + const end = Math.min(lines.length, i + 1 + afterContext); + match.after = lines.slice(i + 1, end).map((line, idx) => ({ + lineNumber: i + 2 + idx, + line, + })); + } + + matches.push(match); + } + + return { totalLines: lines.length, totalMatches, matches }; +} + +const inputSchema = { + spaceName: z + .string() + .describe("Octopus space name. Case-sensitive."), + taskId: z + .string() + .describe("ServerTasks-XXXX ID. Use find_releases or list_deployments to discover task IDs from their parent entities."), + pattern: z + .string() + .min(1) + .describe( + "Regex (default) or literal substring (when fixedString=true). Anchors and groups behave as in JavaScript RegExp. Tested against each log line independently — the same model as `grep`.", + ), + caseInsensitive: z + .boolean() + .default(false) + .describe("Equivalent to grep -i. Default false."), + invertMatch: z + .boolean() + .default(false) + .describe("Equivalent to grep -v: return lines that do NOT match. Default false."), + fixedString: z + .boolean() + .default(false) + .describe("Equivalent to grep -F: treat pattern as a literal substring, not a regex. Use this when grepping for text containing regex metacharacters. Default false."), + beforeContext: z + .number() + .int() + .min(0) + .max(MAX_CONTEXT) + .default(0) + .describe(`Equivalent to grep -B: lines of preceding context to include with each match. Capped at ${MAX_CONTEXT}.`), + afterContext: z + .number() + .int() + .min(0) + .max(MAX_CONTEXT) + .default(0) + .describe(`Equivalent to grep -A: lines of trailing context to include with each match. Capped at ${MAX_CONTEXT}.`), + maxCount: z + .number() + .int() + .min(1) + .max(MAX_COUNT_HARD_CAP) + .default(100) + .describe(`Equivalent to grep -m: stop returning matches after this many. totalMatches in the response still reflects the true count across the whole log. Hard cap ${MAX_COUNT_HARD_CAP}.`), +}; + +export function registerGrepTaskLogTool(server: McpServer) { + server.registerTool( + "grep_task_log", + { + title: "Grep an Octopus task activity log", + description: `Search a server task's raw activity log with grep-style semantics. Returns only matching lines (with optional symmetric context windows) instead of forcing the agent to fetch the full log via the octopus://spaces/{spaceName}/tasks/{taskId}/log resource. + +Use this when you know what to look for (a specific error string, a step name, a pattern). Use the /log resource only when you need to read the entire log linearly. + +Parameter conventions mirror GNU grep so the schema is self-explanatory: +- pattern (regex by default; set fixedString:true for literal text) +- caseInsensitive (-i) +- invertMatch (-v) +- fixedString (-F) +- beforeContext (-B) +- afterContext (-A) +- maxCount (-m) + +Response includes totalMatches (true count across the whole log), the matched lines with 1-indexed lineNumber, optional before/after context arrays, and the fullLogResourceUri so a caller can fetch the entire log if grep was too narrow.`, + inputSchema, + annotations: { readOnlyHint: true }, + }, + async (args) => { + const params = args as GrepTaskLogParams; + const { spaceName, taskId } = params; + + validateEntityId(taskId, "task", ENTITY_PREFIXES.task); + + try { + const client = await Client.create( + getClientConfigurationFromEnvironment(), + ); + const rawLog = await new SpaceServerTaskRepository( + client, + spaceName, + ).getRaw(taskId); + + const { totalLines, totalMatches, matches } = grepLines(rawLog, params); + + const result: GrepTaskLogResult = { + spaceName, + taskId, + pattern: params.pattern, + totalLines, + totalMatches, + returnedMatches: matches.length, + truncated: totalMatches > matches.length, + matches, + fullLogResourceUri: `octopus://spaces/${encodeURIComponent(spaceName)}/tasks/${encodeURIComponent(taskId)}/log`, + }; + + return { + content: [ + { + type: "text", + text: JSON.stringify(result), + }, + ], + }; + } catch (error) { + handleOctopusApiError(error, { + entityType: "task", + entityId: taskId, + spaceName, + helpText: + "Use find_releases or list_deployments to discover task IDs via their parent entity. Use get_task_from_url to resolve a task ID from an Octopus portal URL.", + }); + } + }, + ); +} + +registerToolDefinition({ + toolName: "grep_task_log", + config: { toolset: "tasks", readOnly: true }, + registerFn: registerGrepTaskLogTool, +}); diff --git a/src/tools/index.ts b/src/tools/index.ts index 57a8814..7d6f554 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -32,6 +32,9 @@ import "./findAccounts.js"; import "./createRelease.js"; import "./deployRelease.js"; +// Task log search +import "./grepTaskLog.js"; + // Resource backstop for clients without native MCP resource support import "./readResource.js"; function isToolEnabled( From 67b51acb12a8401d73ba1ae1bf6bb4d63038f2c1 Mon Sep 17 00:00:00 2001 From: Egor Pavlikhin Date: Tue, 5 May 2026 18:35:25 +1000 Subject: [PATCH 3/9] refactor: Remove /log resource template; grep_task_log is the canonical path The octopus://spaces/{spaceName}/tasks/{taskId}/log resource is deliberately removed. Reasoning: - Activity logs can be multi-megabyte. Exposing them as an addressable resource invites agents to fetch the entire body when grep_task_log returns only the matching lines (and a totalMatches count) at a tiny fraction of the context cost. - The "one canonical way to fetch any given shape" rule from the proposed architecture: a single primitive per use case is easier to discover correctly than two paths with subtly different cost profiles. - The /tasks/{id}/details resource already covers structured access (the ActivityLogs[] tree with categories, timings, and embedded entries) for callers that need programmatic traversal rather than text search. Cross-references swept: - get_deployment_from_url's nextSteps payload now returns a grepTaskLogHint object (pre-filled tool name, spaceName, taskId) instead of a taskLogResourceUri field. Integration test updated. - getTaskFromUrl, deployRelease, README, docs/working-with-urls.md and the server-level instructions all rewritten to point at grep_task_log for log search and the /details URI for structured traversal. - read_resource's description explicitly notes there is no /log URI and redirects callers to grep_task_log. - grepTaskLog response field renamed fullLogResourceUri -> taskDetailsResourceUri (pointing at /details) since the previous URI no longer exists. - task.test.ts replaces the /log descriptor test with two guard tests: (1) no descriptor named "task-log" is registered, (2) dispatching a /log URI returns null. Re-introducing the resource would fail both. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 20 +++++---- docs/working-with-urls.md | 17 +++++--- src/index.ts | 12 +++--- src/resources/__tests__/task.test.ts | 27 ++++++------ src/resources/task.ts | 42 ++++--------------- .../getDeploymentFromUrl.integration.test.ts | 5 ++- src/tools/deployRelease.ts | 2 +- src/tools/getDeploymentFromUrl.ts | 14 +++++-- src/tools/getTaskFromUrl.ts | 4 +- src/tools/grepTaskLog.ts | 14 ++++--- src/tools/readResource.ts | 12 +++--- 11 files changed, 84 insertions(+), 85 deletions(-) diff --git a/README.md b/README.md index a353fb2..b4c3876 100644 --- a/README.md +++ b/README.md @@ -249,11 +249,14 @@ npx -y @octopusdeploy/mcp-server --no-read-only --server-url https://your-octopu **Deployment investigation workflow:** ``` 1. get_deployment_from_url with deployment URL - → Returns deployment context + taskResourceUri / taskLogResourceUri + → Returns deployment context + taskResourceUri + grepTaskLogHint -2. Fetch the task resource via resources/read (or read_resource) - octopus://spaces/{spaceName}/tasks/{taskId}/details → structured activity tree - octopus://spaces/{spaceName}/tasks/{taskId}/log → raw plain-text log +2a. Fetch the structured activity tree via resources/read (or read_resource) + octopus://spaces/{spaceName}/tasks/{taskId}/details + +2b. Or call grep_task_log with the taskId to search the raw log without + fetching the full body: + grep_task_log({ spaceName, taskId, pattern: "error|fail", caseInsensitive: true }) ``` **Task investigation** (direct task URL): @@ -291,15 +294,16 @@ See [Working with URLs](docs/working-with-urls.md) for detailed workflows, examp - `list_releases_for_project`: List all releases for a specific project ### Tasks -Task data is primarily exposed as MCP Resources rather than tools. Use `resources/read` (or the `read_resource` backstop tool) with one of: +Task data is primarily exposed as MCP Resources. Use `resources/read` (or the `read_resource` backstop tool) with one of: - `octopus://spaces/{spaceName}/tasks/{taskId}` — lightweight metadata (state, timing, completion flags) - `octopus://spaces/{spaceName}/tasks/{taskId}/details` — full ServerTaskDetails (Progress, ActivityLogs tree, etc.) -- `octopus://spaces/{spaceName}/tasks/{taskId}/log` — raw plain-text task log -The one task-related Tool is for searching: +For log search, use the `grep_task_log` tool rather than a `/log` resource: + +- `grep_task_log`: Search a task's activity log without fetching the full body. Parameters mirror GNU grep (`pattern`, `caseInsensitive`, `invertMatch`, `fixedString`, `beforeContext`, `afterContext`, `maxCount`). Returns matching lines with 1-indexed `lineNumber`, optional before/after context arrays, and a `totalMatches` count across the whole log. -- `grep_task_log`: Search a task's activity log without fetching the full body. Parameters mirror GNU grep (`pattern`, `caseInsensitive`, `invertMatch`, `fixedString`, `beforeContext`, `afterContext`, `maxCount`). Returns matching lines with line numbers and optional context windows plus a `totalMatches` count. +There is intentionally no `/log` resource: activity logs can be multi-megabyte, and an addressable resource would tempt callers to fetch the entire body when grep is almost always the right primitive. ### Tenants - `find_tenants`: Find tenants in a space (can get a specific tenant by ID or list/search tenants with filters) diff --git a/docs/working-with-urls.md b/docs/working-with-urls.md index f9bdd52..c46a4c8 100644 --- a/docs/working-with-urls.md +++ b/docs/working-with-urls.md @@ -11,7 +11,7 @@ User: "Why did this deployment fail? https://your-octopus.com/app#/Spaces-1/proj AI: I'll investigate the deployment failure for you. [Step 1: Uses get_deployment_from_url to get deployment details and the task resource URI] -[Step 2: Reads octopus://spaces/{spaceName}/tasks/{taskId}/details (or /log) for execution data] +[Step 2: Reads octopus://spaces/{spaceName}/tasks/{taskId}/details for execution data, or calls grep_task_log to search for a specific error string] [Analyzes the task logs and identifies the root cause] ``` @@ -109,7 +109,11 @@ Understanding how Octopus resources relate to each other is crucial for effectiv "nextSteps": { "useTaskId": "ServerTasks-456", "taskResourceUri": "octopus://spaces/Production/tasks/ServerTasks-456/details", - "taskLogResourceUri": "octopus://spaces/Production/tasks/ServerTasks-456/log" + "grepTaskLogHint": { + "tool": "grep_task_log", + "spaceName": "Production", + "taskId": "ServerTasks-456" + } } } ``` @@ -164,10 +168,13 @@ Understanding how Octopus resources relate to each other is crucial for effectiv **New approach (2 calls):** ``` Step 1: get_deployment_from_url with deployment URL - → Returns deployment details + taskResourceUri / taskLogResourceUri + → Returns deployment details + taskResourceUri + grepTaskLogHint -Step 2: resources/read on the returned URI (or read_resource as a backstop) - → Returns task execution data (structured tree or raw log) +Step 2a: resources/read on taskResourceUri (or read_resource backstop) + → Returns the structured ActivityLogs tree + OR +Step 2b: grep_task_log with the taskId and a pattern + → Returns only matching log lines (mirrors GNU grep flags) ``` **Example:** diff --git a/src/index.ts b/src/index.ts index 6076e8e..5a188c8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -63,16 +63,18 @@ const SERVER_INSTRUCTIONS = ` The official Octopus Deploy MCP server. Tools are grouped into toolsets (core, releases, deployments, tasks, tenants, kubernetes, machines, certificates) and you can filter them via --toolsets. Writes are gated behind --no-read-only. Resource URIs and how to dereference them: -- Many tools return slim summaries plus an 'octopus://...' URI in fields like 'resourceUri', 'taskResourceUri', or 'taskLogResourceUri' instead of inlining heavy payloads (release notes, packaged versions, task activity logs, etc.). To fetch the full body, dereference the URI. +- Many tools return slim summaries plus an 'octopus://...' URI in fields like 'resourceUri' or 'taskResourceUri' instead of inlining heavy payloads (release notes, packaged versions, structured task activity trees, etc.). To fetch the full body, dereference the URI. - Resource-aware clients (Claude Code, MCP Inspector): call the standard 'resources/read' primitive with the URI. - Clients without native resources/read (Claude.ai web, several IDE integrations): call the 'read_resource' tool with { uri }. It returns the same body as resources/read. Always available, regardless of toolset filter. - The 'read_resource' tool is the universal bridge from any URI returned by any tool — if you see an 'octopus://' string in a response and don't know what to do with it, call read_resource with it. -Currently exposed resource families: releases ('octopus://spaces/{spaceName}/releases/{releaseId}') and tasks ('octopus://spaces/{spaceName}/tasks/{taskId}', '/details', '/log'). More resource families will be added over time. +Currently exposed resource families: +- releases: 'octopus://spaces/{spaceName}/releases/{releaseId}' +- tasks: 'octopus://spaces/{spaceName}/tasks/{taskId}' (metadata) and '/details' (structured ActivityLogs tree) -Searching task logs without fetching the whole thing: -- The /log resource returns the entire activity log as plain text — fine for short tasks but expensive for long-running deployments. -- When you know what you are looking for (an error string, a step name, a regex), call the 'grep_task_log' tool instead. Its parameters mirror GNU grep (pattern, caseInsensitive, invertMatch, fixedString, beforeContext, afterContext, maxCount). It returns only matching lines with optional context windows plus a totalMatches count, so you can scan a multi-megabyte log without inhaling it. +There is intentionally NO 'octopus://.../tasks/{id}/log' resource. Activity logs can be multi-megabyte; an addressable resource would tempt you to fetch the entire body when you almost always want only the matching lines. To search a task log, call the 'grep_task_log' tool — its parameters mirror GNU grep (pattern, caseInsensitive, invertMatch, fixedString, beforeContext, afterContext, maxCount) and it returns matching lines with totalMatches count and optional context windows. For step hierarchy / categories / timing, fetch the /details resource instead. + +More resource families will be added over time. `.trim(); const server = new McpServer( diff --git a/src/resources/__tests__/task.test.ts b/src/resources/__tests__/task.test.ts index 1120878..22a0cbf 100644 --- a/src/resources/__tests__/task.test.ts +++ b/src/resources/__tests__/task.test.ts @@ -125,21 +125,20 @@ describe("task resources", () => { }); }); - describe("octopus://spaces/{spaceName}/tasks/{taskId}/log", () => { - const descriptor = () => descriptorByName("task-log"); - - it("returns the raw log as text/plain without JSON wrapping", async () => { - const rawLog = "12:00:00 Info | Step 1\n12:00:01 Error | Boom\n"; - getRaw.mockResolvedValueOnce(rawLog); - - const payload = await descriptor().read({ - spaceName: "Default", - taskId: "ServerTasks-42", - }); + describe("no /log resource template", () => { + // Activity logs are deliberately not exposed as a resource: agents would be + // tempted to fetch the full multi-MB body when grep_task_log is the better + // primitive. This test guards against accidentally re-introducing the URI. + it("does not register a task-log descriptor", () => { + const found = RESOURCE_REGISTRY.find((d) => d.name === "task-log"); + expect(found).toBeUndefined(); + }); - expect(payload.mimeType).toBe("text/plain"); - expect(payload.text).toBe(rawLog); - expect(getRaw).toHaveBeenCalledWith("ServerTasks-42"); + it("dispatching a /log URI returns null", async () => { + const payload = await dispatchOctopusUri( + "octopus://spaces/Default/tasks/ServerTasks-42/log", + ); + expect(payload).toBeNull(); }); }); diff --git a/src/resources/task.ts b/src/resources/task.ts index ea62f56..651738c 100644 --- a/src/resources/task.ts +++ b/src/resources/task.ts @@ -17,7 +17,7 @@ registerResourceDescriptor({ toolset: "tasks", title: "Octopus task summary", description: - "Lightweight task metadata: state, timing, completion flags, and arguments. Cheap to fetch — use this for polling or status checks. For step timings and embedded log entries, use the /details URI; for the flat plain-text log, use the /log URI.", + "Lightweight task metadata: state, timing, completion flags, and arguments. Cheap to fetch — use this for polling or status checks. For step timings and embedded log entries use the /details URI; to search the raw activity log call the grep_task_log tool.", mimeType: "application/json", read: async ({ spaceName, taskId }) => { validateEntityId(taskId, "task", ENTITY_PREFIXES.task); @@ -80,36 +80,10 @@ registerResourceDescriptor({ }, }); -registerResourceDescriptor({ - name: "task-log", - uriTemplate: "octopus://spaces/{spaceName}/tasks/{taskId}/log", - toolset: "tasks", - title: "Octopus task raw activity log", - description: - "Raw plain-text task log as displayed in the Octopus portal. Use when you need to grep / read the log linearly. For programmatic access to individual log entries with categories and timestamps, use the /details URI instead.", - mimeType: "text/plain", - read: async ({ spaceName, taskId }) => { - validateEntityId(taskId, "task", ENTITY_PREFIXES.task); - - try { - const client = await Client.create( - getClientConfigurationFromEnvironment(), - ); - const log = await new SpaceServerTaskRepository(client, spaceName).getRaw( - taskId, - ); - - return { - mimeType: "text/plain", - text: log, - }; - } catch (error) { - handleOctopusApiError(error, { - entityType: "task", - entityId: taskId, - spaceName, - helpText: TASK_HELP_TEXT, - }); - } - }, -}); +// NOTE: There is intentionally no `octopus://spaces/{spaceName}/tasks/{taskId}/log` +// resource. Activity logs can be multi-megabyte; exposing them as an addressable +// resource invites agents to fetch the whole body when they only need a few +// matching lines. Use the `grep_task_log` tool instead — its parameters mirror +// GNU grep and it returns only matching slices with totalMatches/context. +// For structured log entries with categories and timestamps, use the /details +// URI above (it embeds ActivityLogs[] inline). diff --git a/src/tools/__tests__/getDeploymentFromUrl.integration.test.ts b/src/tools/__tests__/getDeploymentFromUrl.integration.test.ts index 79330b5..831aabd 100644 --- a/src/tools/__tests__/getDeploymentFromUrl.integration.test.ts +++ b/src/tools/__tests__/getDeploymentFromUrl.integration.test.ts @@ -48,8 +48,9 @@ describe('getDeploymentFromUrl Integration Tests', () => { expect(result.nextSteps.taskResourceUri).toMatch( /^octopus:\/\/spaces\/.+\/tasks\/ServerTasks-\d+\/details$/, ); - expect(result.nextSteps.taskLogResourceUri).toMatch( - /^octopus:\/\/spaces\/.+\/tasks\/ServerTasks-\d+\/log$/, + expect(result.nextSteps.grepTaskLogHint.tool).toBe("grep_task_log"); + expect(result.nextSteps.grepTaskLogHint.taskId).toMatch( + /^ServerTasks-\d+$/, ); }, testConfig.timeout); }); diff --git a/src/tools/deployRelease.ts b/src/tools/deployRelease.ts index 7942237..868b061 100644 --- a/src/tools/deployRelease.ts +++ b/src/tools/deployRelease.ts @@ -215,7 +215,7 @@ The tool automatically determines which deployment type to use based on the para deploymentId: task.DeploymentId, })), message: `Successfully created ${tasks.length} deployment(s) for release ${releaseVersion}`, - helpText: `Fetch the octopus://spaces/{spaceName}/tasks/{taskId} resource (or call read_resource with that URI) to monitor deployment progress; use the /details or /log suffix for the structured activity tree or raw log. Use list_deployments to view deployment details. Use find_releases to verify the release exists.`, + helpText: `Fetch octopus://spaces/{spaceName}/tasks/{taskId} (or /details for the structured activity tree) via resources/read or read_resource to monitor deployment progress. To search the raw log for a specific error or step, call grep_task_log with the taskId. Use list_deployments for high-level deployment listings.`, }, null, 2, diff --git a/src/tools/getDeploymentFromUrl.ts b/src/tools/getDeploymentFromUrl.ts index 378afc4..f40e95b 100644 --- a/src/tools/getDeploymentFromUrl.ts +++ b/src/tools/getDeploymentFromUrl.ts @@ -116,10 +116,14 @@ export async function getDeploymentFromUrl(client: Client, params: GetDeployment resourceType: urlParts.resourceType, }, nextSteps: { - description: "To view task logs and execution details for this deployment, fetch the corresponding task resource. Resource-aware clients can call resources/read directly; otherwise use the read_resource tool.", + description: "To inspect this deployment's task: fetch the taskResourceUri for the structured activity tree (steps, timings, embedded log entries), or call grep_task_log with this taskId to search the raw log without inhaling the full body.", useTaskId: deployment.TaskId, taskResourceUri: `octopus://spaces/${encodeURIComponent(spaceName)}/tasks/${encodeURIComponent(deployment.TaskId)}/details`, - taskLogResourceUri: `octopus://spaces/${encodeURIComponent(spaceName)}/tasks/${encodeURIComponent(deployment.TaskId)}/log`, + grepTaskLogHint: { + tool: "grep_task_log", + spaceName, + taskId: deployment.TaskId, + }, } }; } @@ -135,13 +139,15 @@ https://your-octopus.com/app#/Spaces-1/projects/my-app/deployments/releases/1.0. Returns: - Full deployment details (environment, release, project, created time) - taskIdForLogs: the ServerTasks- ID for this deployment -- taskResourceUri / taskLogResourceUri: octopus:// URIs to fetch the task body or raw log via resources/read (or read_resource on clients without native resource support) +- taskResourceUri: octopus:// URI for the structured activity tree (resources/read or read_resource) +- grepTaskLogHint: pre-filled arguments for the grep_task_log tool — call it with a pattern to search the raw log without fetching the whole thing - Public URL for web portal access Recommended workflow for investigating deployment issues: 1. Call get_deployment_from_url with the deployment URL 2. Review deployment context (environment, release version, etc.) -3. Fetch the returned taskResourceUri (or taskLogResourceUri) to view execution details / raw log +3a. Fetch the taskResourceUri for the structured activity tree (step timings, embedded log entries by category), OR +3b. Call grep_task_log with the taskId to search the raw log for a specific error / pattern Handles space ID to space name resolution automatically.`, { diff --git a/src/tools/getTaskFromUrl.ts b/src/tools/getTaskFromUrl.ts index 6f85e7e..5ce0bf3 100644 --- a/src/tools/getTaskFromUrl.ts +++ b/src/tools/getTaskFromUrl.ts @@ -34,7 +34,7 @@ export async function getTaskFromUrl(client: Client, params: GetTaskFromUrlParam `Could not extract task ID from URL. ` + `URL must contain a task identifier (ServerTasks-XXXXX). ` + `If you have a deployment URL, use get_deployment_from_url first to resolve the task ID, ` + - `then fetch the octopus://spaces/{spaceName}/tasks/{taskId}/details resource (or call read_resource with that URI) to view task logs.` + `then fetch octopus://spaces/{spaceName}/tasks/{taskId}/details for the structured activity tree, or call grep_task_log to search the raw log.` ); } @@ -72,7 +72,7 @@ Key features: For deployment URLs: If you have a deployment URL, use this workflow: 1. Call get_deployment_from_url with the deployment URL -2. Use the returned taskResourceUri / taskLogResourceUri (or fetch octopus://spaces/{spaceName}/tasks/{taskId}/details via resources/read or read_resource) +2. Use the returned taskResourceUri (structured tree) or call grep_task_log with the returned taskId to search the raw log ${tasksDescription}`, { diff --git a/src/tools/grepTaskLog.ts b/src/tools/grepTaskLog.ts index 32db7b3..df42dcd 100644 --- a/src/tools/grepTaskLog.ts +++ b/src/tools/grepTaskLog.ts @@ -42,7 +42,11 @@ export interface GrepTaskLogResult { returnedMatches: number; truncated: boolean; matches: GrepMatch[]; - fullLogResourceUri: string; + /** + * URI for the structured ActivityLogs tree if the agent needs more than + * grep can express (e.g. step hierarchy, category filtering, timing). + */ + taskDetailsResourceUri: string; } const MAX_CONTEXT = 50; @@ -189,9 +193,9 @@ export function registerGrepTaskLogTool(server: McpServer) { "grep_task_log", { title: "Grep an Octopus task activity log", - description: `Search a server task's raw activity log with grep-style semantics. Returns only matching lines (with optional symmetric context windows) instead of forcing the agent to fetch the full log via the octopus://spaces/{spaceName}/tasks/{taskId}/log resource. + description: `Search a server task's activity log with grep-style semantics. Returns only matching lines (with optional symmetric context windows). This is the canonical way to inspect task logs — there is no full-log resource URI, because exposing one would tempt callers to inhale multi-megabyte bodies when grep is almost always the better primitive. -Use this when you know what to look for (a specific error string, a step name, a pattern). Use the /log resource only when you need to read the entire log linearly. +Use this when you know what to look for (a specific error string, a step name, a pattern). For structured access to the activity tree (step hierarchy, categories, timing) use the octopus://spaces/{spaceName}/tasks/{taskId}/details resource instead. Parameter conventions mirror GNU grep so the schema is self-explanatory: - pattern (regex by default; set fixedString:true for literal text) @@ -202,7 +206,7 @@ Parameter conventions mirror GNU grep so the schema is self-explanatory: - afterContext (-A) - maxCount (-m) -Response includes totalMatches (true count across the whole log), the matched lines with 1-indexed lineNumber, optional before/after context arrays, and the fullLogResourceUri so a caller can fetch the entire log if grep was too narrow.`, +Response includes totalMatches (true count across the whole log), totalLines, the matched lines with 1-indexed lineNumber, optional before/after context arrays, and a taskDetailsResourceUri for the structured fall-through.`, inputSchema, annotations: { readOnlyHint: true }, }, @@ -232,7 +236,7 @@ Response includes totalMatches (true count across the whole log), the matched li returnedMatches: matches.length, truncated: totalMatches > matches.length, matches, - fullLogResourceUri: `octopus://spaces/${encodeURIComponent(spaceName)}/tasks/${encodeURIComponent(taskId)}/log`, + taskDetailsResourceUri: `octopus://spaces/${encodeURIComponent(spaceName)}/tasks/${encodeURIComponent(taskId)}/details`, }; return { diff --git a/src/tools/readResource.ts b/src/tools/readResource.ts index fd9d937..04966e2 100644 --- a/src/tools/readResource.ts +++ b/src/tools/readResource.ts @@ -8,20 +8,22 @@ export function registerReadResourceTool(server: McpServer) { "read_resource", { title: "Read an Octopus resource by URI", - description: `Universal fetch for any 'octopus://' URI returned by any other tool. Use this whenever you see fields like 'resourceUri', 'taskResourceUri', or 'taskLogResourceUri' in a response and need the full body. + description: `Universal fetch for any 'octopus://' URI returned by any other tool. Use this whenever you see fields like 'resourceUri' or 'taskResourceUri' in a response and need the full body. How to use: -- Pass the URI string verbatim. Examples: 'octopus://spaces/Default/releases/Releases-42', 'octopus://spaces/Default/tasks/ServerTasks-7/details', 'octopus://spaces/Default/tasks/ServerTasks-7/log'. -- The response 'mimeType' tells you how to interpret 'text': 'application/json' → parse as JSON; 'text/plain' → use as-is. +- Pass the URI string verbatim. Examples: 'octopus://spaces/Default/releases/Releases-42', 'octopus://spaces/Default/tasks/ServerTasks-7', 'octopus://spaces/Default/tasks/ServerTasks-7/details'. +- The response 'mimeType' tells you how to interpret 'text': 'application/json' → parse as JSON. This tool is the backstop for clients that do not natively implement the MCP 'resources/read' primitive. Clients that DO support resources/read (Claude Code, MCP Inspector) can call it directly and skip this tool. Either path returns byte-identical bodies. -Tools that return resource URIs include: find_releases, get_deployment_from_url, get_task_from_url, and others. When in doubt, call read_resource on any 'octopus://' string you encounter.`, +Tools that return resource URIs include: find_releases, get_deployment_from_url, get_task_from_url, and others. When in doubt, call read_resource on any 'octopus://' string you encounter. + +Note: there is intentionally no octopus://...tasks/{id}/log resource. Call the grep_task_log tool to search task logs without inhaling the full body.`, inputSchema: { uri: z .string() .describe( - "Any 'octopus://...' URI returned by another tool (e.g. in the resourceUri, taskResourceUri, or taskLogResourceUri field).", + "Any 'octopus://...' URI returned by another tool (e.g. in the resourceUri or taskResourceUri field).", ), }, annotations: { readOnlyHint: true }, From fad287df274edabd17a99c41b2fcbad1e63d0d3f Mon Sep 17 00:00:00 2001 From: Egor Pavlikhin Date: Tue, 5 May 2026 18:40:24 +1000 Subject: [PATCH 4/9] feat: Advertise the connected Octopus URL in MCP instructions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The server-level `instructions` string sent at MCP handshake now includes the configured Octopus server URL on its first line. Lets agents tell the user (or themselves, when reasoning about scope) which Octopus instance they are talking to without an extra round trip. Resolution mirrors getClientConfigurationFromEnvironment's precedence: --server-url flag wins, then OCTOPUS_SERVER_URL env var. If neither is set, the instructions show a placeholder pointing at the right configuration entry points instead of failing silently — the actual connection still fails at runServer time with the existing error path. Pulled the CLI_SERVER_URL assignment earlier in src/index.ts so the instructions are constructed after the resolved URL is known. Removed the previous duplicate assignment further down. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/index.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/src/index.ts b/src/index.ts index 5a188c8..74a93b6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,8 +59,22 @@ program const options = program.opts(); +// Resolve the Octopus server URL up front so the MCP `instructions` string +// can advertise which instance the client is connected to. Mirrors the +// precedence used by getClientConfigurationFromEnvironment (CLI flag wins +// over OCTOPUS_SERVER_URL env var). +if (options.serverUrl) { + process.env.CLI_SERVER_URL = options.serverUrl; +} +const configuredServerUrl = + process.env.CLI_SERVER_URL || + process.env.OCTOPUS_SERVER_URL || + "(not configured — set OCTOPUS_SERVER_URL or pass --server-url)"; + const SERVER_INSTRUCTIONS = ` -The official Octopus Deploy MCP server. Tools are grouped into toolsets (core, releases, deployments, tasks, tenants, kubernetes, machines, certificates) and you can filter them via --toolsets. Writes are gated behind --no-read-only. +The official Octopus Deploy MCP server, currently connected to: ${configuredServerUrl} + +Tools are grouped into toolsets (core, releases, deployments, tasks, tenants, kubernetes, machines, certificates) and you can filter them via --toolsets. Writes are gated behind --no-read-only. Resource URIs and how to dereference them: - Many tools return slim summaries plus an 'octopus://...' URI in fields like 'resourceUri' or 'taskResourceUri' instead of inlining heavy payloads (release notes, packaged versions, structured task activity trees, etc.). To fetch the full body, dereference the URI. @@ -108,9 +122,7 @@ if (options.logFile) { logger.setLogLevel(logger.parseLogLevel(options.logLevel)); logger.setQuietMode(options.quiet); -if (options.serverUrl) { - process.env.CLI_SERVER_URL = options.serverUrl; -} +// CLI_SERVER_URL is set earlier so the MCP instructions string can reference it. // Set up initialization callback to capture client info server.server.oninitialized = () => { From 0c6fd78b4566ff2cdffb757c1a2bfee80249c767 Mon Sep 17 00:00:00 2001 From: Egor Pavlikhin Date: Wed, 6 May 2026 16:13:18 +1000 Subject: [PATCH 5/9] feat: Add requireConfirmation elicitation helper for write gating Single shared helper that any write tool can call before performing a mutation. Negotiates against client capabilities and degrades gracefully: 1. OCTOPUS_SKIP_ELICITATION=true env var bypasses the gate (automation/CI). 2. Client advertises elicitation capability -> SDK emits elicitation/create and the helper resolves accept/decline/cancel from the user response. 3. Client without elicitation -> helper falls back to a confirm: boolean arg the tool surfaces in its own input schema. Returns a discriminated ConfirmationResult so callers can tell an explicit user "no" (declined / cancelled) apart from "the user was never asked" (confirmationRequired). Tools surface the latter as isError: true so the LLM stops and asks the user instead of treating it as a real cancellation. Wires the helper into create_release and deploy_release, and documents the pattern in CLAUDE.md alongside the existing MCP SDK guidance. Refs AIF-356. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 53 +++++ .../__tests__/requireConfirmation.test.ts | 181 ++++++++++++++++++ src/helpers/requireConfirmation.ts | 98 ++++++++++ src/tools/createRelease.ts | 62 ++++++ src/tools/deployRelease.ts | 61 ++++++ 5 files changed, 455 insertions(+) create mode 100644 src/helpers/__tests__/requireConfirmation.test.ts create mode 100644 src/helpers/requireConfirmation.ts diff --git a/CLAUDE.md b/CLAUDE.md index 15d619c..69e1b34 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -69,6 +69,59 @@ 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: + +```typescript +const confirmation = await requireConfirmation(server, { + message: `Deploy release ${version} to ${environment}?`, + fallbackConfirm: args.confirm, +}); +if (!confirmation.confirmed) { + if (confirmation.reason === "confirmationRequired") { + // Client does not support elicitation AND no `confirm` arg was passed. + // The user has not been asked. Tell the LLM to ask them. + return { + content: [{ type: "text", text: JSON.stringify( + { success: false, confirmationRequired: true, message: "Ask the user before retrying with confirm: true." }, + null, 2, + )}], + isError: true, + }; + } + // reason is "declined" or "cancelled" — the user genuinely said no. + return { + content: [{ type: "text", text: JSON.stringify( + { success: false, cancelled: true, reason: confirmation.reason, message: "..." }, + null, 2, + )}], + }; +} +``` + +`reason` values: `accepted` / `envSkip` / `fallbackConfirm` (confirmed); `declined` / `cancelled` / `confirmationRequired` (not confirmed). `confirmationRequired` is the one to flag 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..5ac5ac5 --- /dev/null +++ b/src/helpers/__tests__/requireConfirmation.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { requireConfirmation } 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("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(); + }); + }); +}); diff --git a/src/helpers/requireConfirmation.ts b/src/helpers/requireConfirmation.ts new file mode 100644 index 0000000..843bd2f --- /dev/null +++ b/src/helpers/requireConfirmation.ts @@ -0,0 +1,98 @@ +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; +} + +/** + * 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: opts.message, + // 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" }; +} diff --git a/src/tools/createRelease.ts b/src/tools/createRelease.ts index c6e0cda..bc0cb79 100644 --- a/src/tools/createRelease.ts +++ b/src/tools/createRelease.ts @@ -4,6 +4,7 @@ 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 } from "../helpers/requireConfirmation.js"; export function registerCreateReleaseTool(server: McpServer) { server.tool( @@ -58,6 +59,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,8 +84,63 @@ This tool creates a new release for a project. The space name and project name a ignoreChannelRules, packagePrerelease, customFields, + confirm, }) => { try { + 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 confirmation = await requireConfirmation(server, { + message: `${summary}?`, + fallbackConfirm: confirm, + }); + if (!confirmation.confirmed) { + if (confirmation.reason === "confirmationRequired") { + return { + content: [ + { + type: "text", + 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 release creation 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, + }; + } + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + success: false, + cancelled: true, + reason: confirmation.reason, + message: "Release creation cancelled by user.", + }, + null, + 2, + ), + }, + ], + }; + } + const configuration = getClientConfigurationFromEnvironment(); const client = await Client.create(configuration); const releaseRepository = new ReleaseRepository(client, spaceName); diff --git a/src/tools/deployRelease.ts b/src/tools/deployRelease.ts index 868b061..38b9488 100644 --- a/src/tools/deployRelease.ts +++ b/src/tools/deployRelease.ts @@ -4,6 +4,7 @@ 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 } from "../helpers/requireConfirmation.js"; export function registerDeployReleaseTool(server: McpServer) { server.tool( @@ -86,6 +87,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 +117,7 @@ The tool automatically determines which deployment type to use based on the para variables, deploymentFreezeOverrideReason, deploymentFreezeNames, + confirm, }) => { try { // Validate environment names @@ -130,6 +138,59 @@ The tool automatically determines which deployment type to use based on the para ); } + 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}?`; + + const confirmation = await requireConfirmation(server, { + message: confirmMessage, + fallbackConfirm: confirm, + }); + if (!confirmation.confirmed) { + if (confirmation.reason === "confirmationRequired") { + return { + content: [ + { + type: "text", + 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 deployment 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, + }; + } + return { + content: [ + { + type: "text", + text: JSON.stringify( + { + success: false, + cancelled: true, + reason: confirmation.reason, + message: "Deployment cancelled by user.", + }, + null, + 2, + ), + }, + ], + }; + } + const configuration = getClientConfigurationFromEnvironment(); const client = await Client.create(configuration); const deploymentRepository = new DeploymentRepository( From e156e6a0624451bb6677bfd10709f87f5ec5fdc9 Mon Sep 17 00:00:00 2001 From: Egor Pavlikhin Date: Wed, 6 May 2026 16:23:26 +1000 Subject: [PATCH 6/9] refactor: Extract unconfirmedResponse builder so write tools share the prose The confirmationRequired / cancelled response shapes were duplicated verbatim in createRelease and deployRelease, with only the action noun ("release creation" vs "deployment") differing. As more write tools get gated, this would multiply. Move the response shapes into requireConfirmation.ts as a single unconfirmedResponse(result, { action }) builder. The builder picks the right shape (isError: true for confirmationRequired, soft cancellation for declined/cancelled) and capitalizes the action for the cancelled message. Tools collapse to two lines: if (!confirmation.confirmed) { return unconfirmedResponse(confirmation, { action: "deployment" }); } Adds 6 builder tests covering both branches and the capitalization rule. Updates CLAUDE.md to show the simpler pattern. Co-Authored-By: Claude Opus 4.7 (1M context) --- CLAUDE.md | 28 ++----- .../__tests__/requireConfirmation.test.ts | 77 ++++++++++++++++++- src/helpers/requireConfirmation.ts | 72 +++++++++++++++++ src/tools/createRelease.ts | 45 ++--------- src/tools/deployRelease.ts | 43 ++--------- 5 files changed, 168 insertions(+), 97 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 69e1b34..63c98b8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -89,36 +89,24 @@ confirm: z ), ``` -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: +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) { - if (confirmation.reason === "confirmationRequired") { - // Client does not support elicitation AND no `confirm` arg was passed. - // The user has not been asked. Tell the LLM to ask them. - return { - content: [{ type: "text", text: JSON.stringify( - { success: false, confirmationRequired: true, message: "Ask the user before retrying with confirm: true." }, - null, 2, - )}], - isError: true, - }; - } - // reason is "declined" or "cancelled" — the user genuinely said no. - return { - content: [{ type: "text", text: JSON.stringify( - { success: false, cancelled: true, reason: confirmation.reason, message: "..." }, - null, 2, - )}], - }; + return unconfirmedResponse(confirmation, { action: "deployment" }); } ``` -`reason` values: `accepted` / `envSkip` / `fallbackConfirm` (confirmed); `declined` / `cancelled` / `confirmationRequired` (not confirmed). `confirmationRequired` is the one to flag with `isError: true` — it means the gate is unreachable for this client+args combination, not that the user objected. +`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. diff --git a/src/helpers/__tests__/requireConfirmation.test.ts b/src/helpers/__tests__/requireConfirmation.test.ts index 5ac5ac5..288828a 100644 --- a/src/helpers/__tests__/requireConfirmation.test.ts +++ b/src/helpers/__tests__/requireConfirmation.test.ts @@ -1,6 +1,9 @@ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { type McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; -import { requireConfirmation } from "../requireConfirmation.js"; +import { + requireConfirmation, + unconfirmedResponse, +} from "../requireConfirmation.js"; interface ServerStub { getClientCapabilities: ReturnType; @@ -179,3 +182,75 @@ describe("requireConfirmation", () => { }); }); }); + +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 index 843bd2f..0ebf0d5 100644 --- a/src/helpers/requireConfirmation.ts +++ b/src/helpers/requireConfirmation.ts @@ -96,3 +96,75 @@ export async function requireConfirmation( } 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 bc0cb79..ffd4d9e 100644 --- a/src/tools/createRelease.ts +++ b/src/tools/createRelease.ts @@ -4,7 +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 } from "../helpers/requireConfirmation.js"; +import { + requireConfirmation, + unconfirmedResponse, +} from "../helpers/requireConfirmation.js"; export function registerCreateReleaseTool(server: McpServer) { server.tool( @@ -102,43 +105,9 @@ This tool creates a new release for a project. The space name and project name a fallbackConfirm: confirm, }); if (!confirmation.confirmed) { - if (confirmation.reason === "confirmationRequired") { - return { - content: [ - { - type: "text", - 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 release creation 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, - }; - } - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - success: false, - cancelled: true, - reason: confirmation.reason, - message: "Release creation cancelled by user.", - }, - null, - 2, - ), - }, - ], - }; + return unconfirmedResponse(confirmation, { + action: "release creation", + }); } const configuration = getClientConfigurationFromEnvironment(); diff --git a/src/tools/deployRelease.ts b/src/tools/deployRelease.ts index 38b9488..ecfafb4 100644 --- a/src/tools/deployRelease.ts +++ b/src/tools/deployRelease.ts @@ -4,7 +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 } from "../helpers/requireConfirmation.js"; +import { + requireConfirmation, + unconfirmedResponse, +} from "../helpers/requireConfirmation.js"; export function registerDeployReleaseTool(server: McpServer) { server.tool( @@ -152,43 +155,7 @@ The tool automatically determines which deployment type to use based on the para fallbackConfirm: confirm, }); if (!confirmation.confirmed) { - if (confirmation.reason === "confirmationRequired") { - return { - content: [ - { - type: "text", - 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 deployment 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, - }; - } - return { - content: [ - { - type: "text", - text: JSON.stringify( - { - success: false, - cancelled: true, - reason: confirmation.reason, - message: "Deployment cancelled by user.", - }, - null, - 2, - ), - }, - ], - }; + return unconfirmedResponse(confirmation, { action: "deployment" }); } const configuration = getClientConfigurationFromEnvironment(); From aaa0cc1267881b98c3ab432b5e3d2d30a6061442 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:35:25 +0000 Subject: [PATCH 7/9] feat: Render full source/target command in confirmation prompts requireConfirmation now accepts an optional change: { source, target } payload. When supplied, the helper renders both sides as JSON and appends them to the elicitation message so the user sees the exact command that will run, including modifiers (scheduled run time, skipped steps, machine filters, prompted variables, deployment-freeze overrides) that don't fit the prose summary. For create operations the caller passes source: {} and the constructed command as target; for future modify operations source/target carry the before/after states. Rendered as text inside `message` rather than as a `requestedSchema`, so the format is identical across clients regardless of which elicitation modes they advertise. create_release and deploy_release now build the API command body before the confirmation gate so they can pass it through as target. --- .../__tests__/requireConfirmation.test.ts | 63 ++++++++++++++++ src/helpers/requireConfirmation.ts | 41 ++++++++++- src/tools/createRelease.ts | 29 ++++---- src/tools/deployRelease.ts | 71 +++++++++---------- 4 files changed, 151 insertions(+), 53 deletions(-) diff --git a/src/helpers/__tests__/requireConfirmation.test.ts b/src/helpers/__tests__/requireConfirmation.test.ts index 288828a..8457baf 100644 --- a/src/helpers/__tests__/requireConfirmation.test.ts +++ b/src/helpers/__tests__/requireConfirmation.test.ts @@ -112,6 +112,69 @@ describe("requireConfirmation", () => { }); }); + it("appends rendered source/target JSON to the message when change is provided", 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?", + "", + "source:", + "{}", + "target:", + JSON.stringify( + { + ProjectName: "MyProject", + ReleaseVersion: "1.2.3", + EnvironmentNames: ["Production"], + SkipStepNames: ["Notify"], + }, + null, + 2, + ), + ].join("\n"), + ); + expect(call.requestedSchema).toEqual({ + type: "object", + properties: {}, + }); + }); + + it("renders modify-style payloads with non-empty source", async () => { + stub.elicitInput.mockResolvedValue({ action: "accept" }); + await requireConfirmation(makeServer(stub), { + message: "Update environment Production?", + change: { + source: { Description: "Old", AllowDynamicInfrastructure: false }, + target: { Description: "New", AllowDynamicInfrastructure: true }, + }, + }); + const call = stub.elicitInput.mock.calls[0][0]; + expect(call.message).toContain('"Description": "Old"'); + expect(call.message).toContain('"Description": "New"'); + expect(call.message).toContain("source:"); + expect(call.message).toContain("target:"); + }); + + it("does not append source/target 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), { diff --git a/src/helpers/requireConfirmation.ts b/src/helpers/requireConfirmation.ts index 0ebf0d5..cd4baff 100644 --- a/src/helpers/requireConfirmation.ts +++ b/src/helpers/requireConfirmation.ts @@ -17,6 +17,45 @@ export interface RequireConfirmationOptions { * so it asks the user before retrying) */ fallbackConfirm?: boolean; + /** + * Optional structured before/after view of the operation. Rendered as JSON + * and appended to `message` so the user sees the exact command that will be + * executed before approving — including modifiers like scheduled run time, + * skipped steps, machine filters, prompted variables, deployment-freeze + * overrides, etc. that don't fit the prose summary. + * + * - Create operations: `{ source: {}, target: }`. + * - Modify operations: `source` is the current state; `target` is the + * proposed state. Callers may pre-filter both sides to only the changed + * fields so the prompt isn't dominated by unchanged values. + * + * 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 { + return [ + "source:", + JSON.stringify(change.source, null, 2), + "target:", + JSON.stringify(change.target, null, 2), + ].join("\n"); +} + +function buildConfirmationMessage( + message: string, + change?: RequireConfirmationOptions["change"], +): string { + return change ? `${message}\n\n${renderChange(change)}` : message; } /** @@ -73,7 +112,7 @@ export async function requireConfirmation( if (capabilities?.elicitation) { const result = await server.server.elicitInput({ mode: "form", - message: opts.message, + message: buildConfirmationMessage(opts.message, opts.change), // Empty properties → most clients render as a plain Accept/Decline prompt. requestedSchema: { type: "object", properties: {} }, }); diff --git a/src/tools/createRelease.ts b/src/tools/createRelease.ts index ffd4d9e..8da0bcc 100644 --- a/src/tools/createRelease.ts +++ b/src/tools/createRelease.ts @@ -100,20 +100,6 @@ This tool creates a new release for a project. The space name and project name a .filter(Boolean) .join(" "); - const confirmation = await requireConfirmation(server, { - message: `${summary}?`, - fallbackConfirm: confirm, - }); - 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 command = { spaceName: spaceName, ProjectName: projectName, @@ -134,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 ecfafb4..c1212d9 100644 --- a/src/tools/deployRelease.ts +++ b/src/tools/deployRelease.ts @@ -150,21 +150,6 @@ The tool automatically determines which deployment type to use based on the para `Deploy release ${releaseVersion} of ${projectName} to ` + `[${environmentNames.join(", ")}]${tenantSummary} in space ${spaceName}?`; - const confirmation = await requireConfirmation(server, { - message: confirmMessage, - fallbackConfirm: confirm, - }); - if (!confirmation.confirmed) { - return unconfirmedResponse(confirmation, { action: "deployment" }); - } - - const configuration = getClientConfigurationFromEnvironment(); - const client = await Client.create(configuration); - const deploymentRepository = new DeploymentRepository( - client, - spaceName, - ); - // Build common parameters const commonParams = { spaceName: spaceName, @@ -199,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 || []; From b73d7a5afe77912820eaef1102a1d4ccd992a286 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 01:53:34 +0000 Subject: [PATCH 8/9] refactor: Render confirmation change as a key-level diff `renderChange` now produces a `+` / `-` diff over the union of source/target keys instead of dumping both objects in full. Unchanged keys are skipped so the prompt stays focused on what's actually changing; create operations (empty source) render every target field as `+`, which is the diff representation of a creation. Multi-line JSON values (arrays, nested objects) are prefix-marked on each line so the marker stays consistent. Order follows source first then target-only keys to keep related fields adjacent. --- .../__tests__/requireConfirmation.test.ts | 80 ++++++++++++++----- src/helpers/requireConfirmation.ts | 61 ++++++++++---- 2 files changed, 105 insertions(+), 36 deletions(-) diff --git a/src/helpers/__tests__/requireConfirmation.test.ts b/src/helpers/__tests__/requireConfirmation.test.ts index 8457baf..d1cf173 100644 --- a/src/helpers/__tests__/requireConfirmation.test.ts +++ b/src/helpers/__tests__/requireConfirmation.test.ts @@ -112,7 +112,7 @@ describe("requireConfirmation", () => { }); }); - it("appends rendered source/target JSON to the message when change is provided", async () => { + it("renders create-style payloads (empty source) as all `+` additions", async () => { stub.elicitInput.mockResolvedValue({ action: "accept" }); await requireConfirmation(makeServer(stub), { message: "Deploy release 1.2.3 to Production?", @@ -131,19 +131,14 @@ describe("requireConfirmation", () => { [ "Deploy release 1.2.3 to Production?", "", - "source:", - "{}", - "target:", - JSON.stringify( - { - ProjectName: "MyProject", - ReleaseVersion: "1.2.3", - EnvironmentNames: ["Production"], - SkipStepNames: ["Notify"], - }, - null, - 2, - ), + '+ "ProjectName": "MyProject"', + '+ "ReleaseVersion": "1.2.3"', + '+ "EnvironmentNames": [', + '+ "Production"', + "+ ]", + '+ "SkipStepNames": [', + '+ "Notify"', + "+ ]", ].join("\n"), ); expect(call.requestedSchema).toEqual({ @@ -152,23 +147,64 @@ describe("requireConfirmation", () => { }); }); - it("renders modify-style payloads with non-empty source", async () => { + it("renders modify-style payloads as a key-level diff, omitting unchanged keys", async () => { stub.elicitInput.mockResolvedValue({ action: "accept" }); await requireConfirmation(makeServer(stub), { message: "Update environment Production?", change: { - source: { Description: "Old", AllowDynamicInfrastructure: false }, - target: { Description: "New", AllowDynamicInfrastructure: true }, + 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).toContain('"Description": "Old"'); - expect(call.message).toContain('"Description": "New"'); - expect(call.message).toContain("source:"); - expect(call.message).toContain("target:"); + expect(call.message).toBe( + ["Update?", "", '- "Removed": "gone"', '+ "Added": "new"'].join("\n"), + ); + }); + + it("renders '(no changes)' 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?", "", "(no changes)"].join("\n")); }); - it("does not append source/target when change is omitted", async () => { + 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]; diff --git a/src/helpers/requireConfirmation.ts b/src/helpers/requireConfirmation.ts index cd4baff..194a452 100644 --- a/src/helpers/requireConfirmation.ts +++ b/src/helpers/requireConfirmation.ts @@ -18,16 +18,16 @@ export interface RequireConfirmationOptions { */ fallbackConfirm?: boolean; /** - * Optional structured before/after view of the operation. Rendered as JSON - * and appended to `message` so the user sees the exact command that will be - * executed before approving — including modifiers like scheduled run time, - * skipped steps, machine filters, prompted variables, deployment-freeze - * overrides, etc. that don't fit the prose summary. + * 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: `{ source: {}, target: }`. + * - 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. Callers may pre-filter both sides to only the changed - * fields so the prompt isn't dominated by unchanged values. + * 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 @@ -39,16 +39,49 @@ export interface RequireConfirmationOptions { }; } +function prefixLines(text: string, prefix: string): string { + return text + .split("\n") + .map((line) => `${prefix}${line}`) + .join("\n"); +} + function renderChange(change: { source: Record; target: Record; }): string { - return [ - "source:", - JSON.stringify(change.source, null, 2), - "target:", - JSON.stringify(change.target, null, 2), - ].join("\n"); + // 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); + } + + const lines: string[] = []; + 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) { + lines.push(prefixLines(`${JSON.stringify(key)}: ${sourceJson}`, "- ")); + } + if (inTarget) { + lines.push(prefixLines(`${JSON.stringify(key)}: ${targetJson}`, "+ ")); + } + } + + return lines.length === 0 ? "(no changes)" : lines.join("\n"); } function buildConfirmationMessage( From 0401c80b378f4506816c340e74ab510ec6d4fa0c Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 7 May 2026 02:29:22 +0000 Subject: [PATCH 9/9] refactor: Wrap confirmation diff in JSON braces with unified-diff markers Previous output was a bare list of `+`/`-` prefixed lines, which read as a debug dump rather than a JSON diff. Now `renderChange` produces a git-unified-diff-shaped JSON object: outer `{` / `}` braces, two-space indent inside, marker (`+` or `-`) prefixed onto each line, commas between entries (no trailing comma on the last). Multi-line values (arrays, nested objects) get the marker on every line so the structure stays clear. Empty diffs render as `{}` for visual consistency. --- .../__tests__/requireConfirmation.test.ts | 45 ++++++++++++------- src/helpers/requireConfirmation.ts | 45 ++++++++++++------- 2 files changed, 58 insertions(+), 32 deletions(-) diff --git a/src/helpers/__tests__/requireConfirmation.test.ts b/src/helpers/__tests__/requireConfirmation.test.ts index d1cf173..e3d1ebf 100644 --- a/src/helpers/__tests__/requireConfirmation.test.ts +++ b/src/helpers/__tests__/requireConfirmation.test.ts @@ -112,7 +112,7 @@ describe("requireConfirmation", () => { }); }); - it("renders create-style payloads (empty source) as all `+` additions", async () => { + 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?", @@ -131,14 +131,16 @@ describe("requireConfirmation", () => { [ "Deploy release 1.2.3 to Production?", "", - '+ "ProjectName": "MyProject"', - '+ "ReleaseVersion": "1.2.3"', - '+ "EnvironmentNames": [', - '+ "Production"', - "+ ]", - '+ "SkipStepNames": [', - '+ "Notify"', - "+ ]", + "{", + '+ "ProjectName": "MyProject",', + '+ "ReleaseVersion": "1.2.3",', + '+ "EnvironmentNames": [', + '+ "Production"', + "+ ],", + '+ "SkipStepNames": [', + '+ "Notify"', + "+ ]", + "}", ].join("\n"), ); expect(call.requestedSchema).toEqual({ @@ -147,7 +149,7 @@ describe("requireConfirmation", () => { }); }); - it("renders modify-style payloads as a key-level diff, omitting unchanged keys", async () => { + 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?", @@ -169,10 +171,12 @@ describe("requireConfirmation", () => { [ "Update environment Production?", "", - '- "Description": "Old"', - '+ "Description": "New"', - '- "AllowDynamicInfrastructure": false', - '+ "AllowDynamicInfrastructure": true', + "{", + '- "Description": "Old",', + '+ "Description": "New",', + '- "AllowDynamicInfrastructure": false,', + '+ "AllowDynamicInfrastructure": true', + "}", ].join("\n"), ); // Unchanged keys are not surfaced. @@ -190,18 +194,25 @@ describe("requireConfirmation", () => { }); const call = stub.elicitInput.mock.calls[0][0]; expect(call.message).toBe( - ["Update?", "", '- "Removed": "gone"', '+ "Added": "new"'].join("\n"), + [ + "Update?", + "", + "{", + '- "Removed": "gone",', + '+ "Added": "new"', + "}", + ].join("\n"), ); }); - it("renders '(no changes)' when source and target are equal", async () => { + 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?", "", "(no changes)"].join("\n")); + expect(call.message).toBe(["Update?", "", "{}"].join("\n")); }); it("does not append a diff when change is omitted", async () => { diff --git a/src/helpers/requireConfirmation.ts b/src/helpers/requireConfirmation.ts index 194a452..4b11bb4 100644 --- a/src/helpers/requireConfirmation.ts +++ b/src/helpers/requireConfirmation.ts @@ -39,13 +39,6 @@ export interface RequireConfirmationOptions { }; } -function prefixLines(text: string, prefix: string): string { - return text - .split("\n") - .map((line) => `${prefix}${line}`) - .join("\n"); -} - function renderChange(change: { source: Record; target: Record; @@ -62,7 +55,21 @@ function renderChange(change: { if (!seen.has(k)) keys.push(k); } - const lines: string[] = []; + // 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; @@ -73,15 +80,23 @@ function renderChange(change: { ? JSON.stringify(change.target[key], null, 2) : undefined; if (sourceJson === targetJson) continue; - if (inSource) { - lines.push(prefixLines(`${JSON.stringify(key)}: ${sourceJson}`, "- ")); - } - if (inTarget) { - lines.push(prefixLines(`${JSON.stringify(key)}: ${targetJson}`, "+ ")); - } + if (inSource) entries.push(buildEntry("-", key, change.source[key])); + if (inTarget) entries.push(buildEntry("+", key, change.target[key])); } - return lines.length === 0 ? "(no changes)" : lines.join("\n"); + 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(