Reconnect MCP clients only for transport and session failures (#2056) - #2061
Merged
Aaronontheweb merged 1 commit intoAug 26, 2026
Merged
Aaronontheweb merged 1 commit into
Aaronontheweb merged 1 commit into
Conversation
Aaronontheweb
force-pushed
the
fix/mcp-reconnect-classification
branch
from
August 26, 2026 04:56
8cd854c to
3ecffa1
Compare
Aaronontheweb
force-pushed
the
fix/mcp-reconnect-classification
branch
from
August 26, 2026 05:44
3ecffa1 to
8710eae
Compare
Aaronontheweb
marked this pull request as ready for review
August 26, 2026 05:48
`IsTransportOrSessionFailure` treated every `HttpRequestException` as a transport failure. An HTTP 500, 429, or 403 on `tools/call` therefore built a new session. That costs about five extra requests and it raises the published generation. A new session cannot repair a server-side application error. The predicate now reads the typed status. It returns true only when the status is absent or 404. An absent status means the request got no answer. The Streamable HTTP spec reports an expired session as 404. Every other status is an application error from a server that answered. `LoadAsync` owns the MCP prompt-skill path. Its first catch clause accepted only `McpException`, so an `HttpRequestException` with an application status would escape to the tool dispatcher. The clause now also accepts `HttpRequestException` and returns the same failed load result. Tests: two lifecycle tests cover HTTP 500 and HTTP 429. One prompt test covers HTTP 500 on `GetPromptAsync`. Each queues a spare client plan, so a reconnect raises the create count and fails the test. The prompt test also asserts the text of the first catch clause. The lifecycle tests assert one Warning line that names the tool and the HTTP status. One lifecycle test covers HTTP 404 with one reconnect, and it waits under the test token so a missed reconnect fails the run.
Aaronontheweb
force-pushed
the
fix/mcp-reconnect-classification
branch
from
August 26, 2026 08:39
8710eae to
7d51200
Compare
Aaronontheweb
commented
Aug 26, 2026
| // spec reports an expired session as 404. A new session repairs both. Every other | ||
| // status is an application error from a server that answered, so a new session | ||
| // cannot change the answer and the reconnect is wasted work. | ||
| if (ex is HttpRequestException http) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
McpClientManager.IsTransportOrSessionFailuretreated everyHttpRequestExceptionas a transport failure. An HTTP 500, 429, or 403 ontools/calltherefore built a new session. That costs about five extra requests, and it raises the published generation with no config change. A new session cannot repair a server-side application error. A rate-limited server gets hit harder, so the loop feeds itself.The predicate now reads the typed status. It returns true only when the status is absent or 404.
Closes #2056
Stacked on #2060
Design refs
openspec/changes/mcp-tool-outcome-receipts/design.md, decision D3. Tasks 3.1 and 3.2.What changed
IsTransportOrSessionFailurereturns true for anHttpRequestExceptiononly whenStatusCodeis null or 404. An absent status means the request got no answer. The Streamable HTTP spec reports an expired session as 404. Every other status is an application error from a server that answered. TheIOException,EndOfStreamException,TimeoutException,ObjectDisposedException, andMcpExceptionbranches do not change.LoadAsyncowns the MCP prompt-skill path. Its first catch clause accepted onlyMcpException, so anHttpRequestExceptionwith an application status would match neither clause and escape to the tool dispatcher. The clause now also acceptsHttpRequestExceptionand returns the same failed load result. No new clause and no new type.The tool path is unchanged apart from the predicate.
InvokeSharedAsyncstill logs one Warning and rethrows. The adapter still turns the exception into the tool result (#2060).Tests added
src/Netclaw.Daemon.Tests/Mcp/McpClientManagerLifecycleTests.cs:ApplicationHttpStatus_ReachesTheCallerWithoutAReconnect(Theory: HTTP 500 and HTTP 429). The exception reaches the caller. A spare client plan is queued and stays unused, so a reconnect would raiseCreateCountto 2 and fail the test. The generation stays 1. The test also pins the Warning line that Record MCP tool-call exceptions as failed outcomes (#2055) #2060 added: exactly one entry namestest/run, and it carries the(HTTP 500)suffix ((HTTP 429)for the second case). Nothing in the stack asserted that suffix before.SessionExpiryStatus_ReconnectsForLaterCalls(HTTP 404). The exception reaches the caller. The manager builds one replacement client and does not replay the call. The wait on the disposal of the first client runs under the test token, so an over-narrowed predicate fails the run instead of hanging it.TransportFailure_ReconnectsForLaterCallsAndDoesNotReplaypins the status-less case. It still passes without a change.src/Netclaw.Daemon.Tests/Mcp/McpPromptSkillTests.cs:LoadReturnsFailedResultForAnApplicationHttpStatus. An HTTP 500 onGetPromptAsyncreturns a failed load result that names the prompt. A spare client plan is queued and stays unused. The result text must match the first catch clause: it containsfailed:and it does not containconnection closed, which is the transport clause's text.Mutation check: with the predicate reverted to "every
HttpRequestExceptionis a transport failure", all three tests fail.The
ClientPlantest double gets aGetPromptFailurehook. It mirrors thePromptListFailurehook that is already there.Gate outputs
dotnet build src/Netclaw.Daemon/Netclaw.Daemon.csprojdotnet test ... --filter "FullyQualifiedName~Mcp"dotnet test src/Netclaw.Daemon.Testsdotnet slopwatch analyze./scripts/Add-FileHeaders.ps1 -VerifyAdversarial review
The review returned PARTIAL. Four NOW findings are fixed in this pull request:
ControlledMcpClientRuntime.CreateAsyncbefore it counts the client, soCreateCountread 1 either way. Each test now queues a spare plan.DISMISSED: the 404 test near-duplicates the status-less transport test. Both are kept because each maps to one scenario in the spec delta.