fix(mcp): forward host cancellation signal to plugin tool.execute#82426
fix(mcp): forward host cancellation signal to plugin tool.execute#82426Feelw00 wants to merge 2 commits into
Conversation
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]>
|
Codex review: needs maintainer review before merge. Summary Reproducibility: yes. Current source inspection shows the MCP handler does not accept or forward Real behavior proof Next step before merge Security Review detailsBest possible solution: Land one narrow fix that threads the MCP SDK Do we have a high-confidence way to reproduce the issue? Yes. Current source inspection shows the MCP handler does not accept or forward Is this the best way to solve the issue? Yes. Forwarding the existing SDK What I checked:
Likely related people:
Remaining risk / open question:
Codex review notes: model gpt-5.5, reasoning high; reviewed against 80ca48418a21. |
|
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 #82443 became the closeout PR because its regression test waits until Appreciate the careful report, patch, and proof. |
Summary
createToolsMcpServerregisterssetRequestHandler(CallToolRequestSchema, async (request) => handlers.callTool(request.params))(src/mcp/tools-stdio-server.ts:17), discarding the SDK'sextra: RequestHandlerExtraargument that carries the per-requestAbortSignal.createPluginToolsMcpHandlers.callTool(src/mcp/plugin-tools-handlers.ts:45) has nosignalparameter either, sotool.execute(toolCallId, params)is always called with an undefined 4th argument.AbortError, but the in-flighttool.executekeeps running until it returns naturally or is SIGKILLed, skipping graceful release of external resources (DB transactions, browser sessions, network sockets, vector-DB scans).setRequestHandlercallback now accepts(request, extra)and forwardsextra.signal.callToolgains an optionalsignal?: AbortSignaland threads it into the existing 4th-positionsignal?onAnyAgentTool.execute(src/agents/tools/common.ts:22-28). 3 hunks across 2 source files.void server.close()drain change (SDKProtocol._onclosealready aborts_requestHandlerAbortControllersandprotocol.js:369-372skips 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)
Scope (select all touched areas)
Linked Issue/PR
Real behavior proof (required for external PRs)
dist/mcp/plugin-tools-serve.js) wiressetRequestHandler(CallToolRequestSchema, async (request) => handlers.callTool(request.params))and discards the second argumentextra: RequestHandlerExtra. When an MCP host issuesnotifications/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.pnpminstall + build commands shown in the steps below from base sha3b663ad1c1and head shaaeaa6e902e. End-to-end probe spawns the production bundledist/mcp/plugin-tools-serve.jsas a childnodeprocess, drives it through real@modelcontextprotocol/sdkStdioClientTransportfrom the parentnodeprocess, and uses a probe tool injected viacreatePluginToolsMcpServer({tools:[probe]})so the production wiring (tools-stdio-server.ts:17setRequestHandler callback +plugin-tools-handlers.tscallTool) is exercised end-to-end over real stdio framing. No external services (LLM/OAuth/network) — purely in-process MCP transport.3b663ad1c1and head shaaeaa6e902e), then run anodeparent process that spawns anodechild loadingdist/mcp/plugin-tools-serve.js. The parent callsclient.callTool({name:'probe-cancel'}, undefined, {signal: ctrl.signal})andctrl.abort()100 ms later. The SDK emitsnotifications/cancelledover stdio, the server-side SDKProtocolfires_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.nodeparent process. Both base and head builds were produced withpnpm install --frozen-lockfile && pnpm buildand the probe binary spawned was the freshly builtdist/mcp/plugin-tools-serve.jsin each worktree. The console output below is the actual stdout of the parentnodeprocess for each build.Base sha
3b663ad1c1(without this patch) — console output of the parentnodeprocess:executeInvoked1signalDefinedfalsesignalIsAbortSignalfalseabortObservedfalsesignalAbortedAtEndfalseelapsedMs501handshake"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 parentnodeprocess:executeInvoked1signalDefinedtruesignalIsAbortSignaltrueabortObservedtruesignalAbortedAtEndtrueelapsedMs502handshake"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 ...tool.executeover real stdio MCP framing. The probe's terminal-captured stdout flipssignalDefinedfromfalsetotrue,signalIsAbortSignalfromfalsetotrue, and the abort listener registered insidetool.executefires (abortObservedflipsfalsetotrue), withsignal.aborted === trueobserved bytool.executebefore it returns (signalAbortedAtEndflipsfalsetotrue). Without this patch the samenodeprobe records all four flags asfalseand the server-sidetool.executeruns to completion regardless of the host cancellation.childStderris identical-shape across both runs (only Node'sType Stripping is an experimental featurewarning), confirming the difference is the patch, not the environment.void server.close()floating promise on stdin close / SIGTERM inconnectToolsMcpServerToStdio) — separately investigated and confirmed already handled by the SDK'sProtocol._onclose(unconditional_requestHandlerAbortControllers.abort()) andprotocol.js:369-372post-handler abort check. Plugin tools whoseexecutedoes not yet accept a signal argument (e.g. some memory-lancedb / cron-tool implementations) will continue to ignore cancellation — separate follow-up per tool. Realmemory-lancedb/cron-tool/ browser plugin tools were not exercised in this run; the injected probe is the cancellation instrumentation point.node-parent probe against base sha3b663ad1c1(without this patch) reproduces the defect — see the first table above for thesignalDefined: false,abortObserved: false, etc. console output measurements.Root Cause (if applicable)
src/mcp/tools-stdio-server.ts:17wires the SDK request handler as a one-argument arrow functionasync (request) => handlers.callTool(request.params). The SDK passes(request, extra)per@modelcontextprotocol/sdkRequestHandler(whereextra: RequestHandlerExtraexposessignal: AbortSignal); the second argument is silently dropped at the call site. Additionally,src/mcp/plugin-tools-handlers.ts:45definescallTool: async (params: CallPluginToolParams)with nosignalparameter, and callstool.execute("mcp-" + Date.now(), params.arguments ?? {})— leaving the optional 4th argumentsignal?: AbortSignal(declared onErasedAgentToolExecuteinsrc/agents/tools/common.ts:22-28) permanently undefined.src/mcp/plugin-tools-serve.test.tsmockscreateToolsMcpServerviavi.mock, so the SDK abort flow never traverses the real handler.setRequestHandler(schema, async (request) => ...)is type-correct against the SDK signature (extra is optional in the callback contract), andtool.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)
src/mcp/plugin-tools-handlers.cancel.test.ts(new).@modelcontextprotocol/sdkClient+Serverlinked byInMemoryTransport.createLinkedPair()(no SDK mocks), callingclient.callTool({...}, undefined, { signal: controller.signal })and thencontroller.abort()must (1) deliver anAbortSignaltotool.executeas its 4th argument and (2) fire the abort listener registered insidetool.executebefore the call rejects.extra.signalarriving at the handler, andhandlers.callToolforwarding it intotool.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.src/mcp/plugin-tools-serve.test.tsmockscreateToolsMcpServer, bypassing the SDK abort flow entirely.src/mcp/plugin-tools-handlers.cancel.test.ts).User-visible / Behavior Changes
Plugin tools whose
executeaccepts asignal: AbortSignalargument will now have that signal aborted when an MCP host cancels the request (vianotifications/cancelledor transport close). Tools that ignore theirsignalargument behave unchanged. No public API renames; the additional optional parameter oncallToolis backward-compatible.Diagram (if applicable)
Before —
host → notifications/cancelled → SDK → extra.signal.abort() → setRequestHandler cbdropsextra,handlers.callTool(params)has nosignal,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 insidetool.execute, signal-aware cleanup runs.Security Impact (required)
NoNoNoNo— same tools, same args; only adds the existing optionalsignalparameter into the call.NoYes, explain risk + mitigation: N/A — patch only forwards anAbortSignalthe SDK already produces.Repro + Verification
Environment
nodev23.9.0,pnpm11.1.0dist/mcp/plugin-tools-serve.js)createPluginToolsMcpServer({tools:[probe]}))Steps
git fetch upstream main && git checkout 3b663ad1c1(base) oraeaa6e902e(head).pnpm install --frozen-lockfile && pnpm build.nodeparent spawnsnodechild viaStdioClientTransport, child loadscreatePluginToolsMcpServerfrom the freshly builtdist/mcp/plugin-tools-serve.js, parent callscallToolwith{ signal: ctrl.signal }, parent aborts after 100 ms.executewrote and observe it on the parent's stdout.Expected
signalDefined: true,signalIsAbortSignal: true,abortObserved: true,signalAbortedAtEnd: true.Actual
false(see the first table above).true(see the second table above).Evidence
3b663ad1c1(signalDefined: false, etc.) against headaeaa6e902e(signalDefined: true, abort listener fires).nodeparent process.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)
dist/mcp/plugin-tools-serve.json both base3b663ad1c1and headaeaa6e902e. Measurements above are the direct result.observedSignalstays undefined and theexpect(observedSignal).toBeInstanceOf(AbortSignal)assertion trips).pnpm buildgreen on the fix branch.pnpm checkgreen on the fix branch.extensions/slack/src/send.identity-fallback.test.ts:80. The failing test is unrelated to this change (different packageextensions/slack/, asserts a Slackchat.postMessagepayload shape); see Risks below.extra.signalthat is already aborted at entry, listener registered beforecontroller.abort(), listener invoked after abort.memory_recall, browser tools, shell exec) honoring cancellation in their ownexecutebodies — those are separate per-tool concerns. Shutdown drain axis (covered by SDKProtocol._onclose).Review Conversations
Compatibility / Migration
Yes—signalis an optional new parameter oncallTool; existing callers continue to work. Existing tool implementations that ignore the 4th argument continue to behave as before.NoNoRisks and Mitigations
signal.aborted.signalbehave unchanged. The change matches the documented contract onAnyAgentTool.execute(signal is an optional cleanup hook, not a contract change).extensions/slack/src/send.identity-fallback.test.ts:80(the assertion expects a Slackchat.postMessagepayload withoutunfurl_links, but the actual payload now includesunfurl_links: false).extensions/slack/, asserts Slack outbound payload shape, and matches the behavior introduced by upstream commitcb695b0986 fix(slack): default unfurl_links to false for outbound messages(the test was not updated alongside that change). This PR does not touchextensions/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).