Skip to content

feat: Add find_interruptions tool and interruption resource#76

Merged
egorpavlikhin merged 10 commits intomainfrom
egorp/aif-374-find-interruptions
May 7, 2026
Merged

feat: Add find_interruptions tool and interruption resource#76
egorpavlikhin merged 10 commits intomainfrom
egorp/aif-374-find-interruptions

Conversation

@egorpavlikhin
Copy link
Copy Markdown
Contributor

Closes the Harness approval_instance parity gap (AIF-374). Octopus interruptions — manual interventions, guided failures, deployment approvals — are now first-class in the MCP server, matching how the Harness server exposes pending approvals.

Stacked on top of egorp/aif-356-require-confirmation (PR base).

What's new

find_interruptions tool — four modes, picked by which argument you supply:

Mode Use case
interruptionId Slim summary for a single interruption
assignedToMe "What's waiting for me?" — resolves /users/me (cached 1h per process) and filters on CanTakeResponsibility / HasResponsibility / ResponsibleUserId. The wrapper surfaces filteredAs.userId so consumers can cite which user "me" was.
regarding Native server-side filter for a related entity ID (e.g. ServerTasks-867) — the same call the Octopus UI makes from the task page
(no filter) List, with pendingOnly (default: true) plus skip / take

octopus://spaces/{spaceName}/interruptions/{id} Resource — full body for drill-in. Each slim summary's resourceUri points here. Dereferencing returns Form.Elements (control types, Markdown instructions, button options like Abort / Proceed), Form.Values, RelatedDocumentIds, and the responsibility flags. The slim summary also exposes taskResourceUri for the surrounding task and a portal publicUrl deep link.

This split lets an LLM answer "what's waiting for me?" with the cheap slim list and "what are my options?" via a targeted resource read — no overfetching.

Why these shapes

  • Form values stripped from the slim summary (formElementNames is keys only). Keeps payloads lean and avoids incidental leakage of free-text notes / submitted approvals into list responses; the full Form is available via the resource for explicit drill-in.
  • /users/me cached per-process with a 1h TTL — mirrors spaceResolver. The acceptance criterion required this; the userId is also surfaced in the response so it's auditable.

Deviations from the Linear ticket

  1. spaceName kept required, not optional. Every other find_* tool requires it; deviating would force a default-space resolver we don't have.
  2. projectId / environmentId filters dropped. The /interruptions endpoint doesn't natively support them. regarding is the supported native filter and replaces them. Per direction during planning: only expose what the endpoint actually supports.
  3. No InterruptionRepository exists in @octopusdeploy/api-client, so the tool uses raw client.get<ResourceCollection<…>>(\"~/api/{spaceId}/interruptions{?…}\", …) — same pattern as findTenants.

One bug caught during build-out

Working from the spec alone, I assumed Form.Elements was a Record<string, unknown> and used Object.keys() to extract names. The actual API response (verified against a live Octopus on localhost:8065 with a real ManualIntervention) returns it as an array of { Name, Control, IsValueRequired }. The original code would have silently emitted numeric indices (\"0\", \"1\", \"2\") for formElementNames. Fixed and pinned with a unit test that uses the real-world Paragraph / TextArea / SubmitButtonGroup shape.

ID-format validation also runs before the space resolve now, so a malformed interruptionId fails fast without a network round-trip.

Test plan

Verified locally; reviewers can re-run:

  • npm run build
  • npm run lint
  • npx vitest run — 14 new unit tests pass (10 for interruptionSummary, 4 for the resource — full body returned, ID format validated pre-network, 404 translation, dispatch URL-decodes spaceName)
  • TEST_SPACE_NAME=Default npx vitest run src/tools/__tests__/findInterruptions.integration.test.ts against a live Octopus on localhost:8065 — all 9 integration scenarios pass, including:
    • paginated wrapper shape
    • pendingOnly: false returns ≥ pendingOnly: true
    • skip / take
    • regarding filter (native pass-through)
    • assignedToMe surfaces filteredAs.userId and /users/me cache returns the same id on repeat
    • single-id lookup round-trips list → drill-in cleanly
    • non-existent space throws
    • malformed interruptionId rejected pre-network with Invalid interruption ID format
  • npm start -- --toolsets core,interruptions — confirm only find_interruptions (plus core) registers; with --toolsets core,projects the tool is filtered out
  • Smoke-test via MCP Inspector / Claude Code:
    • find_interruptions { spaceName: \"Default\", assignedToMe: true }
    • find_interruptions { spaceName: \"Default\", regarding: \"ServerTasks-867\" }
    • read_resource { uri: \"octopus://spaces/Default/interruptions/Interruptions-1\" } returns full Form

Out of scope

  • Write tools (take_responsibility, submit_interruption) — would need requireConfirmation (which exists on the base branch) and are explicitly not part of AIF-374.
  • Dedicated octopus://…/interruptions/{id}/details URI — the interruption body is small enough that a single resource is sufficient. The pattern is there if a future need arises.

egorpavlikhin and others added 8 commits May 5, 2026 16:49
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…al 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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
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) <noreply@anthropic.com>
…e 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) <noreply@anthropic.com>
Closes the Harness `approval_instance` parity gap (AIF-374). Octopus
interruptions (manual interventions, guided failures, deployment
approvals) are now first-class in the MCP server, matching how the
Harness server exposes pending approvals.

Tool modes (picked by which arg is supplied):
- interruptionId  → slim summary for a single interruption
- assignedToMe    → only interruptions the authenticated user can act on;
                    resolves /users/me (cached 1h per process) and filters
                    on CanTakeResponsibility / HasResponsibility /
                    ResponsibleUserId
- regarding       → native server-side filter for a related entity ID
                    (e.g. ServerTasks-867 — same call the Octopus UI uses)
- (none)          → list, optionally with pendingOnly (default: true) and
                    skip / take

Each slim summary includes resourceUri pointing at the new
`octopus://spaces/{spaceName}/interruptions/{id}` resource. Dereferencing
returns the full body — Form.Elements (control types, Markdown
instructions, button options like Abort / Proceed), Form.Values,
RelatedDocumentIds, and the responsibility flags — so an LLM can answer
"what's waiting for me" with the slim list and "what are my options" via
a follow-up resource read. The slim summary also exposes taskResourceUri
for the surrounding task and a portal publicUrl deep link.

Form values are deliberately stripped from the slim summary
(formElementNames is keys only) to keep payloads lean and avoid
incidental leakage of free-text notes; the full Form is available via
the resource for explicit drill-in.

Deviations from the Linear ticket:
- spaceName kept required to match every other find_* tool.
- projectId / environmentId filters dropped: the /interruptions endpoint
  doesn't natively support them. `regarding` is the supported native
  filter and replaces them.
- No InterruptionRepository in @octopusdeploy/api-client, so the tool
  uses raw client.get with the standard URL template — same pattern as
  findTenants.

Tests:
- 10 unit tests for interruptionSummary (incl. the actual array shape
  for Form.Elements observed against a live Octopus, and explicit
  no-form-values-leak guard)
- 4 unit tests for the interruption resource (full body returned, ID
  format validated pre-network, 404 translation, dispatch URL-decodes
  spaceName)
- 9 integration tests covering all four modes and error paths
Base automatically changed from egorp/aif-356-require-confirmation to main May 7, 2026 02:32
…terruptions

# Conflicts:
#	src/helpers/__tests__/requireConfirmation.test.ts
#	src/helpers/requireConfirmation.ts
#	src/tools/createRelease.ts
#	src/tools/deployRelease.ts
@egorpavlikhin
Copy link
Copy Markdown
Contributor Author

Example usage "find all interruptions assigned to me in my Octopus instance"

image

Copy link
Copy Markdown
Contributor

@akirayamamoto akirayamamoto left a comment

Choose a reason for hiding this comment

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

Reviewed by Codex.

Summary

This PR adds find_interruptions plus an interruption resource:
octopus://spaces/{spaceName}/interruptions/{interruptionId}. The shape mostly makes sense: list calls stay slim, detailed form data is behind the resource URI, and the returned task URI gives callers a clear path back to the surrounding task.

Approve from me. I do suggest fixing the assignedToMe paging behaviour before merging, because it affects the main "what is assigned to me?" workflow. The server-instructions update is smaller and can be handled in the same pass.

Suggested fixes

Fix assignedToMe paging before merge

File: src/tools/findInterruptions.ts, lines 213-234

assignedToMe is described as listing interruptions the authenticated user can act on, but the implementation fetches a single /interruptions page and then filters that page client-side. If the first server page contains no matching interruptions and a later page does, the tool returns an empty items array and silently misses actionable interruptions.

That affects the main usage shown on the PR: "find all interruptions assigned to me". Either use a server-side responsibility filter if Octopus exposes one, or page through the server result set until enough post-filtered results are collected, or until the server result set is exhausted.

Advertise interruption resources in server instructions

File: src/index.ts, lines 85-87

The PR registers octopus://spaces/{spaceName}/interruptions/{interruptionId}, but the MCP handshake instructions still list only release and task resource families. Resource-aware clients and agents that rely on the server instructions for URI discovery will not learn that interruption resources exist until after they happen to call find_interruptions.

Please add the interruption URI to the resource family list. The toolset list in the same instructions string should include interruptions too.

Verification

I retried the targeted tests after installing dependencies in the PR worktree:

npm test -- findInterruptions interruption

Result:

  • 16 tests passed.
  • 7 tests failed.
  • 2 test files passed.
  • 1 test file failed.

The failures were all live integration successful-path tests in src/tools/__tests__/findInterruptions.integration.test.ts. They failed with a 401 from Octopus:

The API key you provided was not valid.

The unit/resource tests passed:

  • src/tools/__tests__/findInterruptions.test.ts
  • src/resources/__tests__/interruption.test.ts

…urce

Two PR review fixes:

1) assignedToMe paging (correctness fix)
   The previous implementation fetched a single /interruptions page and
   post-filtered. If the first server page contained no interruptions
   for the authenticated user, the tool returned an empty list and
   silently missed actionable interruptions on later pages — exactly
   the headline use case ("what's waiting for me").

   Octopus has no responsibleUserId query parameter, so post-filtering
   is the only correctness-preserving option. The handler now scans
   the result set 100 records at a time up to a safety cap of 500
   (almost never reachable under pendingOnly: true; surfaces a hint
   in filteredAs when reached so the LLM knows to narrow the query).

   Caller skip/take are applied to the post-filter set, since "skip
   the first N matches" is what users actually mean. The wrapper now
   exposes:
     filteredAs.serverTotalScanned    — unfiltered records inspected
     filteredAs.serverTotalAvailable  — unfiltered server total
     filteredAs.scanComplete          — true if exhausted
     filteredAs.scanIncompleteHint    — present only when capped

2) Server instructions (discovery fix)
   The MCP handshake instructions advertised only release and task
   resource families. Resource-aware clients that rely on the
   instructions for URI discovery wouldn't learn about
   octopus://spaces/{spaceName}/interruptions/{id} until they
   happened to call find_interruptions. Added the family to the list
   and added "interruptions" to the toolset list.

Tests (6 new, all using mocked api-client):
  - "scans subsequent pages when the first page contains zero matches
    (the original bug)" — canary test for the regression
  - safety cap enforced; scanIncompleteHint surfaces when reached
  - short page treated as exhaustion
  - skip/take applied to post-filter set, not unfiltered
  - empty result returns [], not error
  - non-assignedToMe path still passes server pagination through
    unchanged

Integration test updated to assert on the new filteredAs scan metadata
shape; full suite 103 passing locally (up from 97), live integration
against localhost:8065 all 9 pass.
@egorpavlikhin egorpavlikhin merged commit 5051105 into main May 7, 2026
1 check passed
@egorpavlikhin egorpavlikhin deleted the egorp/aif-374-find-interruptions branch May 7, 2026 06:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants