Skip to content

fix(mcp): forward host cancellation signal to plugin tool.execute#82426

Closed
Feelw00 wants to merge 2 commits into
openclaw:mainfrom
Feelw00:fix/mcp-plugin-tools-forward-cancel-signal
Closed

fix(mcp): forward host cancellation signal to plugin tool.execute#82426
Feelw00 wants to merge 2 commits into
openclaw:mainfrom
Feelw00:fix/mcp-plugin-tools-forward-cancel-signal

Conversation

@Feelw00

@Feelw00 Feelw00 commented May 16, 2026

Copy link
Copy Markdown
Contributor

Summary

  • Problem: createToolsMcpServer registers setRequestHandler(CallToolRequestSchema, async (request) => handlers.callTool(request.params)) (src/mcp/tools-stdio-server.ts:17), discarding the SDK's extra: RequestHandlerExtra argument that carries the per-request AbortSignal. createPluginToolsMcpHandlers.callTool (src/mcp/plugin-tools-handlers.ts:45) has no signal parameter either, so tool.execute(toolCallId, params) is always called with an undefined 4th argument.
  • Why it matters: Every MCP host driving the plugin-tools / openclaw-tools stdio server (Claude Code SDK, Codex, ACPX bridge) loses its cancel path. The host UI shows AbortError, but the in-flight tool.execute keeps running until it returns naturally or is SIGKILLed, skipping graceful release of external resources (DB transactions, browser sessions, network sockets, vector-DB scans).
  • What changed: setRequestHandler callback now accepts (request, extra) and forwards extra.signal. callTool gains an optional signal?: AbortSignal and threads it into the existing 4th-position signal? on AnyAgentTool.execute (src/agents/tools/common.ts:22-28). 3 hunks across 2 source files.
  • What did NOT change (scope boundary): No new abstraction, no new state, no shutdown / void server.close() drain change (SDK Protocol._onclose already aborts _requestHandlerAbortControllers and protocol.js:369-372 skips the late response). No tool implementations were modified — signal-aware tools (those that already accept the optional 4th parameter) start honoring host cancellation; tools that ignore the signal continue to behave as before.

Change Type (select all)

  • Bug fix
  • Feature
  • Refactor required for the fix
  • Docs
  • Security hardening
  • Chore/infra

Scope (select all touched areas)

  • Gateway / orchestration
  • Skills / tool execution
  • Auth / tokens
  • Memory / storage
  • Integrations
  • API / contracts
  • UI / DX
  • CI/CD / infra

Linked Issue/PR

Real behavior proof (required for external PRs)

  • Behavior or issue addressed: Without this patch, the standalone MCP plugin-tools server (dist/mcp/plugin-tools-serve.js) wires setRequestHandler(CallToolRequestSchema, async (request) => handlers.callTool(request.params)) and discards the second argument extra: RequestHandlerExtra. When an MCP host issues notifications/cancelled (e.g. via Client.callTool's RequestOptions.signal), the SDK aborts the per-request AbortController, but the request handler never observes that signal, so the in-flight tool.execute keeps running with no cancellation channel. With this patch, extra.signal flows through callTool into tool.execute's 4th argument, so host cancellation actually aborts the running tool.
  • Real environment tested: macOS (darwin arm64), Node.js v23.9.0, OpenClaw worktree built via the pnpm install + build commands shown in the steps below from base sha 3b663ad1c1 and head sha aeaa6e902e. End-to-end probe spawns the production bundle dist/mcp/plugin-tools-serve.js as a child node process, drives it through real @modelcontextprotocol/sdk StdioClientTransport from the parent node process, and uses a probe tool injected via createPluginToolsMcpServer({tools:[probe]}) so the production wiring (tools-stdio-server.ts:17 setRequestHandler callback + plugin-tools-handlers.ts callTool) is exercised end-to-end over real stdio framing. No external services (LLM/OAuth/network) — purely in-process MCP transport.
  • Exact steps or command run after this patch: install + build each worktree (base sha 3b663ad1c1 and head sha aeaa6e902e), then run a node parent process that spawns a node child loading dist/mcp/plugin-tools-serve.js. The parent calls client.callTool({name:'probe-cancel'}, undefined, {signal: ctrl.signal}) and ctrl.abort() 100 ms later. The SDK emits notifications/cancelled over stdio, the server-side SDK Protocol fires _requestHandlerAbortControllers.abort(), and the probe tool writes {signalDefined, signalIsAbortSignal, abortObserved, signalAbortedAtEnd} to a sideband JSON file before returning. The parent reads that file and reports it as the measurement payload below.
  • Evidence after fix: live terminal capture of the wire-level measurement from the node parent process. Both base and head builds were produced with pnpm install --frozen-lockfile && pnpm build and the probe binary spawned was the freshly built dist/mcp/plugin-tools-serve.js in each worktree. The console output below is the actual stdout of the parent node process for each build.

Base sha 3b663ad1c1 (without this patch) — console output of the parent node process:

metric (parent stdout) value
executeInvoked 1
signalDefined false
signalIsAbortSignal false
abortObserved false
signalAbortedAtEnd false
elapsedMs 501
handshake "ok"
callOutcome { ok: false, error: "McpError: MCP error -32001: AbortError: This operation was aborted" }
childStderr (excerpt) (node:93446) ExperimentalWarning: Type Stripping is an experimental feature ...

Head sha aeaa6e902e (with this patch) — console output of the parent node process:

metric (parent stdout) value
executeInvoked 1
signalDefined true
signalIsAbortSignal true
abortObserved true
signalAbortedAtEnd true
elapsedMs 502
handshake "ok"
callOutcome { ok: false, error: "McpError: MCP error -32001: AbortError: This operation was aborted" }
childStderr (excerpt) (node:93452) ExperimentalWarning: Type Stripping is an experimental feature ...
  • Observed result after fix: with this patch, the host-issued cancellation reaches the in-flight tool.execute over real stdio MCP framing. The probe's terminal-captured stdout flips signalDefined from false to true, signalIsAbortSignal from false to true, and the abort listener registered inside tool.execute fires (abortObserved flips false to true), with signal.aborted === true observed by tool.execute before it returns (signalAbortedAtEnd flips false to true). Without this patch the same node probe records all four flags as false and the server-side tool.execute runs to completion regardless of the host cancellation. childStderr is identical-shape across both runs (only Node's Type Stripping is an experimental feature warning), confirming the difference is the patch, not the environment.
  • What was not tested: shutdown drain axis (the void server.close() floating promise on stdin close / SIGTERM in connectToolsMcpServerToStdio) — separately investigated and confirmed already handled by the SDK's Protocol._onclose (unconditional _requestHandlerAbortControllers.abort()) and protocol.js:369-372 post-handler abort check. Plugin tools whose execute does not yet accept a signal argument (e.g. some memory-lancedb / cron-tool implementations) will continue to ignore cancellation — separate follow-up per tool. Real memory-lancedb / cron-tool / browser plugin tools were not exercised in this run; the injected probe is the cancellation instrumentation point.
  • Before evidence: same node-parent probe against base sha 3b663ad1c1 (without this patch) reproduces the defect — see the first table above for the signalDefined: false, abortObserved: false, etc. console output measurements.

Root Cause (if applicable)

  • Root cause: src/mcp/tools-stdio-server.ts:17 wires the SDK request handler as a one-argument arrow function async (request) => handlers.callTool(request.params). The SDK passes (request, extra) per @modelcontextprotocol/sdk RequestHandler (where extra: RequestHandlerExtra exposes signal: AbortSignal); the second argument is silently dropped at the call site. Additionally, src/mcp/plugin-tools-handlers.ts:45 defines callTool: async (params: CallPluginToolParams) with no signal parameter, and calls tool.execute("mcp-" + Date.now(), params.arguments ?? {}) — leaving the optional 4th argument signal?: AbortSignal (declared on ErasedAgentToolExecute in src/agents/tools/common.ts:22-28) permanently undefined.
  • Missing detection / guardrail: no regression test exercised an end-to-end MCP cancellation through the standalone plugin-tools server. Existing src/mcp/plugin-tools-serve.test.ts mocks createToolsMcpServer via vi.mock, so the SDK abort flow never traverses the real handler.
  • Contributing context: the shape setRequestHandler(schema, async (request) => ...) is type-correct against the SDK signature (extra is optional in the callback contract), and tool.execute(id, params) is also type-correct (the trailing two parameters are optional). The compiler does not flag the drop. The cancellation contract is documented in the SDK types but not enforced at the call site.

Regression Test Plan (if applicable)

  • Coverage level that should have caught this:
    • Unit test
    • Seam / integration test
    • End-to-end test
    • Existing coverage already sufficient
  • Target test or file: src/mcp/plugin-tools-handlers.cancel.test.ts (new).
  • Scenario the test should lock in: with a real @modelcontextprotocol/sdk Client + Server linked by InMemoryTransport.createLinkedPair() (no SDK mocks), calling client.callTool({...}, undefined, { signal: controller.signal }) and then controller.abort() must (1) deliver an AbortSignal to tool.execute as its 4th argument and (2) fire the abort listener registered inside tool.execute before the call rejects.
  • Why this is the smallest reliable guardrail: it exercises both required hops — the SDK extra.signal arriving at the handler, and handlers.callTool forwarding it into tool.execute — without touching unrelated subsystems (no child process spawn, no stdio framing, no plugin loader). It would have failed on either of the two missing wires individually.
  • Existing test that already covers this (if any): none. src/mcp/plugin-tools-serve.test.ts mocks createToolsMcpServer, bypassing the SDK abort flow entirely.
  • If no new test is added, why not: N/A — a new test is added (src/mcp/plugin-tools-handlers.cancel.test.ts).

User-visible / Behavior Changes

Plugin tools whose execute accepts a signal: AbortSignal argument will now have that signal aborted when an MCP host cancels the request (via notifications/cancelled or transport close). Tools that ignore their signal argument behave unchanged. No public API renames; the additional optional parameter on callTool is backward-compatible.

Diagram (if applicable)

Before — host → notifications/cancelled → SDK → extra.signal.abort() → setRequestHandler cb drops extra, handlers.callTool(params) has no signal, tool.execute(id, params, signal=undefined) runs to completion, host SIGKILL leaks external resources.

After — host → notifications/cancelled → SDK → extra.signal.abort() → setRequestHandler cb(request, extra) → handlers.callTool(params, extra.signal) → tool.execute(id, params, signal); abort listener fires inside tool.execute, signal-aware cleanup runs.

Security Impact (required)

  • New permissions/capabilities? No
  • Secrets/tokens handling changed? No
  • New/changed network calls? No
  • Command/tool execution surface changed? No — same tools, same args; only adds the existing optional signal parameter into the call.
  • Data access scope changed? No
  • If any Yes, explain risk + mitigation: N/A — patch only forwards an AbortSignal the SDK already produces.

Repro + Verification

Environment

  • OS: macOS 15.4 (Darwin 25.4.0, arm64)
  • Runtime/container: node v23.9.0, pnpm 11.1.0
  • Model/provider: N/A — defect is in MCP transport wiring, model-independent
  • Integration/channel (if any): MCP stdio plugin-tools server (dist/mcp/plugin-tools-serve.js)
  • Relevant config (redacted): N/A — defect reproduces with default config + no plugins (probe tool injected via createPluginToolsMcpServer({tools:[probe]}))

Steps

  1. git fetch upstream main && git checkout 3b663ad1c1 (base) or aeaa6e902e (head).
  2. pnpm install --frozen-lockfile && pnpm build.
  3. Run the wire-level probe described in the Real behavior proof section above — node parent spawns node child via StdioClientTransport, child loads createPluginToolsMcpServer from the freshly built dist/mcp/plugin-tools-serve.js, parent calls callTool with { signal: ctrl.signal }, parent aborts after 100 ms.
  4. Read the sideband JSON the probe tool's execute wrote and observe it on the parent's stdout.

Expected

  • After this patch: signalDefined: true, signalIsAbortSignal: true, abortObserved: true, signalAbortedAtEnd: true.

Actual

  • Before this patch: all four false (see the first table above).
  • After this patch: all four true (see the second table above).

Evidence

  • Failing test/log before + passing after — the wire-level measurement above contrasts base 3b663ad1c1 (signalDefined: false, etc.) against head aeaa6e902e (signalDefined: true, abort listener fires).
  • Trace/log snippets — see the two tables under Real behavior proof. Both are direct console output of the node parent process.
  • Screenshot/recording — N/A (terminal capture above).
  • Perf numbers (if relevant) — N/A.

Regression test (new): src/mcp/plugin-tools-handlers.cancel.test.ts. Single-test run on the fix branch: Test Files 1 passed (1) / Tests 1 passed (1) / Duration 1.71s.

Human Verification (required)

  • Verified scenarios:
    • End-to-end probe against dist/mcp/plugin-tools-serve.js on both base 3b663ad1c1 and head aeaa6e902e. Measurements above are the direct result.
    • New regression test passes locally on the fix branch (and fails as written on base, since observedSignal stays undefined and the expect(observedSignal).toBeInstanceOf(AbortSignal) assertion trips).
    • pnpm build green on the fix branch.
    • pnpm check green on the fix branch.
    • Full repo tests on the fix branch — 1057 passed, 1 failed in extensions/slack/src/send.identity-fallback.test.ts:80. The failing test is unrelated to this change (different package extensions/slack/, asserts a Slack chat.postMessage payload shape); see Risks below.
  • Edge cases checked: handler invoked with extra.signal that is already aborted at entry, listener registered before controller.abort(), listener invoked after abort.
  • What you did not verify: real plugin tools (memory_recall, browser tools, shell exec) honoring cancellation in their own execute bodies — those are separate per-tool concerns. Shutdown drain axis (covered by SDK Protocol._onclose).

Review Conversations

  • I replied to or resolved every bot review conversation I addressed in this PR.
  • I left unresolved only the conversations that still need reviewer or maintainer judgment.

Compatibility / Migration

  • Backward compatible? Yessignal is an optional new parameter on callTool; existing callers continue to work. Existing tool implementations that ignore the 4th argument continue to behave as before.
  • Config/env changes? No
  • Migration needed? No
  • If yes, exact upgrade steps: N/A

Risks and Mitigations

  • Risk: plugin tools that internally rely on completing their work even after cancellation (e.g. writing partial results before returning) may now be interrupted earlier if they observe signal.aborted.
    • Mitigation: this only affects tools that explicitly read the optional 4th parameter. Tools that ignore signal behave unchanged. The change matches the documented contract on AnyAgentTool.execute (signal is an optional cleanup hook, not a contract change).
  • Risk: pre-existing repo failure unrelated to this PR — running the full repo suite on the fix branch shows 1 failure in extensions/slack/src/send.identity-fallback.test.ts:80 (the assertion expects a Slack chat.postMessage payload without unfurl_links, but the actual payload now includes unfurl_links: false).
    • Mitigation: this failure is unrelated to this change. The file lives in extensions/slack/, asserts Slack outbound payload shape, and matches the behavior introduced by upstream commit cb695b0986 fix(slack): default unfurl_links to false for outbound messages (the test was not updated alongside that change). This PR does not touch extensions/slack/ or any Slack code path. Flagging here for maintainer awareness; will fix separately if requested.

AI-assisted: drafted with claude code (claude-opus-4-7). Testing level: lightly tested locally (full repo suite minus the pre-existing slack flake noted above; new regression test passes single-run; end-to-end wire-level probe collected on both base and head builds).

Feelw00 and others added 2 commits May 16, 2026 11:28
The plugin-tools MCP server (createToolsMcpServer / createPluginToolsMcpHandlers)
dropped the RequestHandlerExtra.signal exposed by the SDK on every callTool
invocation. Host cancellation (notifications/cancelled) and transport close
both fire the SDK's per-request AbortController, but the handler ignored
the extra argument and called tool.execute without its optional signal
parameter, so in-flight tool work hung until SIGKILL.

Thread extra.signal through callTool to tool.execute so signal-aware tools
(memory-lancedb recall, browser navigation, shell exec, etc.) can release
external resources when the host cancels.

AI-assisted: drafted with claude code (claude-opus-4-7).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
Regression test for SOL-0010 fix. Uses a real MCP SDK Client + Server
linked by InMemoryTransport (no SDK mocks) so the production wiring
(setRequestHandler callback + callTool + tool.execute) is exercised
end-to-end. Asserts that the in-flight tool receives the host's
AbortSignal and that an abort listener fires when the client cancels.

AI-assisted: drafted with claude code (claude-opus-4-7).

Co-Authored-By: Claude Opus 4.7 <[email protected]>
@openclaw-barnacle openclaw-barnacle Bot added size: S triage: mock-only-proof Candidate: PR proof only shows tests, mocks, snapshots, lint, typecheck, or CI. labels May 16, 2026
@clawsweeper

clawsweeper Bot commented May 16, 2026

Copy link
Copy Markdown
Contributor

Codex review: needs maintainer review before merge.

Summary
The PR forwards MCP request cancellation signals through the plugin tool handler into tool.execute and adds an MCP cancellation propagation regression test.

Reproducibility: yes. Current source inspection shows the MCP handler does not accept or forward RequestHandlerExtra.signal, and the reporter supplied a concrete stdio cancellation probe with before/after terminal measurements; I did not run that probe in this read-only review.

Real behavior proof
Sufficient (terminal): The PR body includes after-fix terminal output from a real MCP stdio probe comparing base and patched builds, with the server-side tool observing the abort signal after the patch.

Next step before merge
No ClawSweeper repair lane is needed because this PR already contains the focused fix; the remaining action is maintainer landing choice versus the parallel PR.

Security
Cleared: The diff only forwards an existing AbortSignal and adds a test; it does not change dependencies, workflows, secrets handling, permissions, or command execution scope.

Review details

Best possible solution:

Land one narrow fix that threads the MCP SDK extra.signal through createToolsMcpServer, createPluginToolsMcpHandlers.callTool, and AnyAgentTool.execute, with focused cancellation regression coverage.

Do we have a high-confidence way to reproduce the issue?

Yes. Current source inspection shows the MCP handler does not accept or forward RequestHandlerExtra.signal, and the reporter supplied a concrete stdio cancellation probe with before/after terminal measurements; I did not run that probe in this read-only review.

Is this the best way to solve the issue?

Yes. Forwarding the existing SDK AbortSignal into the existing optional AnyAgentTool.execute signal parameter is the narrowest maintainable fix, with no new config or public API rename.

What I checked:

  • Current main drops the SDK handler extra: createToolsMcpServer registers the tools/call handler as async (request) and calls handlers.callTool(request.params), so the second MCP SDK handler argument is not accepted or forwarded on current main. (src/mcp/tools-stdio-server.ts:17, 80ca48418a21)
  • Current main calls execute without a signal: createPluginToolsMcpHandlers.callTool currently accepts only CallPluginToolParams and invokes tool.execute with only the call id and arguments. (src/mcp/plugin-tools-handlers.ts:45, 80ca48418a21)
  • Tool contract already has a cancellation parameter: AnyAgentTool.execute already declares signal?: AbortSignal before onUpdate, so the PR reuses an existing tool contract rather than adding a new API surface. (src/agents/tools/common.ts:21, 80ca48418a21)
  • MCP SDK contract supplies the signal: The pinned @modelcontextprotocol/sdk 1.29.0 RequestHandlerExtra includes signal: AbortSignal, and setRequestHandler handlers receive (request, extra).
  • PR forwards the existing signal path: The diff changes the tools/call handler to accept (request, extra) and passes extra.signal through handlers.callTool; callTool then passes it as the third positional argument to tool.execute. (src/mcp/tools-stdio-server.ts:17, 36b9e73e38c7)
  • Regression test exercises MCP cancellation wiring: The added test uses a real MCP SDK Client and Server connected with InMemoryTransport, aborts the client request, and asserts the tool received an AbortSignal and observed the abort event. (src/mcp/plugin-tools-handlers.cancel.test.ts:1, 36b9e73e38c7)

Likely related people:

  • steipete: GitHub commit history shows Peter Steinberger introduced the shared MCP tools stdio server used by both plugin-tools and openclaw-tools in commit 61ab68f. (role: feature-history contributor; confidence: high; commits: 61ab68f5c904, e75cd46ba658; files: src/mcp/tools-stdio-server.ts, src/mcp/plugin-tools-serve.ts, src/mcp/openclaw-tools-serve.ts)
  • vincentkoc: GitHub file history shows Vincent Koc made recent fixes to src/mcp/plugin-tools-handlers.ts, including owner-only filtering and plugin tool result serialization around the affected handler path. (role: recent handler contributor; confidence: high; commits: 8f3b99c51281, 5fa0d282a81a, e4b09e1bf3b2; files: src/mcp/plugin-tools-handlers.ts, src/mcp/plugin-tools-serve.test.ts)

Remaining risk / open question:

  • A parallel open fix PR covers the same root cause, so maintainers should land one implementation and close the other as superseded.

Codex review notes: model gpt-5.5, reasoning high; reviewed against 80ca48418a21.

@openclaw-barnacle openclaw-barnacle Bot added proof: supplied External PR includes structured after-fix real behavior proof. and removed triage: mock-only-proof Candidate: PR proof only shows tests, mocks, snapshots, lint, typecheck, or CI. labels May 16, 2026
@clawsweeper clawsweeper Bot added the proof: sufficient ClawSweeper judged the real behavior proof convincing. label May 16, 2026
@joshavant

Copy link
Copy Markdown
Contributor

Thanks @Feelw00 for the patch and for the original repro in #82424.

Your diagnosis and source fix were correct: MCP request cancellation was available as RequestHandlerExtra.signal, but OpenClaw dropped it before invoking plugin tool.execute. The merged fix in #82443 uses the same core wiring: preserve the SDK-provided signal and pass it into the existing optional tool.execute(..., signal) argument.

#82443 became the closeout PR because its regression test waits until tool.execute has actually received the signal before aborting, which makes the cancellation assertion less timing-dependent than a fixed sleep. This PR is superseded by #82443.

Appreciate the careful report, patch, and proof.

@joshavant joshavant closed this May 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

proof: sufficient ClawSweeper judged the real behavior proof convincing. proof: supplied External PR includes structured after-fix real behavior proof. size: S

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: MCP plugin tools server drops host AbortSignal — in-flight tool.execute cannot be cancelled

2 participants