Skip to content

mcp: retry input-required results that carry only requestState - #1365

Open
SergeevDmitry wants to merge 4 commits into
modelcontextprotocol:mainfrom
SergeevDmitry:fix/client-mrtr-requeststate-only
Open

SergeevDmitry wants to merge 4 commits into
modelcontextprotocol:mainfrom
SergeevDmitry:fix/client-mrtr-requeststate-only

Conversation

@SergeevDmitry

@SergeevDmitry SergeevDmitry commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

A result with only requestState is a valid InputRequiredResult under SEP-2322 (servers "MUST include at least one of inputRequests or requestState"), and the TypeScript SDK server sends one from inputRequired({ requestState }). The Go SDK handled it on neither side:

  • Client: clientMultiRoundTripMiddleware returned early when InputRequests was nil, so such a result reached the caller as the final one (NeedsInput() true, no content), although the client docs say the middleware retries while NeedsInput() is true.
  • Server: annotateResultType and the rest of the server's result handling checked only InputRequests, so a handler returning only a RequestState sent a complete result, and a resources/read handler doing so failed with "read handler returned nil information".

This uses one check, inputRequests() != nil || requestState() != "", for the server's result type, the content validation, the empty content/messages fill, the resources/read early return and the legacy shim. For clients before 2026-07-28 the shim calls the handler again with the state echoed. The client retries these results with the state echoed and counts them as rounds (maxMultiRoundTripRetries), not as load shedding, the same as the shim. An empty inputRequests map is still load shedding, and a result with neither field still fails after the load-shedding limit. Content together with a requestState is now rejected like content with inputRequests. The server docs are updated and docs/server.md is regenerated.

New tests, all failing on main:

  • Client: TestMultiRoundTrip_AutoRetry_RequestStateOnly, TestMultiRoundTrip_AutoRetry_InputRequiredWithoutFields, TestMultiRoundTrip_RequestStateOnly_MaxRetries.
  • Server: TestMultiRoundTrip_ServerRequestStateOnly (tools/call, prompts/get, resources/read), TestMultiRoundTrip_ServerMiddleware_RequestStateOnly, TestMultiRoundTrip_ContentWithRequestState, and three requestState-only cases in TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits.

go test -race ./..., go vet ./... and both conformance scripts pass locally (server 40/40, client 239/239).

Fixes #1364

AI assistance: I used Claude Code to help write this fix and the tests.

SEP-2322 requires an InputRequiredResult to carry at least one of
inputRequests and requestState, so a result with only requestState is
valid, and the TypeScript SDK server sends one from
inputRequired({ requestState }). clientMultiRoundTripMiddleware stopped
as soon as InputRequests was nil and returned such a result to the
caller as the final one.

Also check the result type, so these results are retried with the
requestState echoed, like an empty inputRequests map. A result with
neither field is now retried until the load-shedding limit instead of
being returned.
@guglielmo-san

Copy link
Copy Markdown
Contributor

@SergeevDmitry what do you think about fixing this on server side too?
Currently annotateResultType only sets the resultType if inputRequests is different from nil.

case multiRoundTripResponse:
		// These results are complete or input_required, so label them by
		// whether the handler asked for more client input.
		if r.inputRequests() != nil {
			r.setResultType(resultTypeInputRequired)
		} else {
			r.setResultType(resultTypeComplete)
		}

@SergeevDmitry

Copy link
Copy Markdown
Contributor Author

Agreed, the server should be able to send a requestState-only result too. I'll add it to this PR.

inputRequests != nil decides this in a few more places on the server, so I'd switch them together to one check (inputRequests() != nil || requestState() != ""):

  • annotateResultType: label the result input_required.
  • validateMultiRoundTripResult: also reject content together with a requestState. Today that goes out as a complete result with a stray requestState.
  • CallTool / GetPrompt: don't fill in an empty content / messages for such a result.
  • ReadResource: a requestState-only result currently fails with "read handler returned nil information".

One question about the legacy shim: for a client before 2026-07-28, should a requestState-only round call the handler again with the state echoed, counted against maxMultiRoundTripRetries, or return the same "busy" error as an empty inputRequests map? I'd call the handler again, since the server is asking to continue rather than shedding load. The TypeScript shim does the same (it paces those rounds and counts them in the round cap).

@guglielmo-san

Copy link
Copy Markdown
Contributor

Yes I would call the handler again

The server treated a result as input-required only when InputRequests
was set. A handler that returned only a RequestState sent a complete
result, and a resources/read handler doing so failed with "read handler
returned nil information".

Use one check, inputRequests or requestState, for the result type, the
content validation, the empty content and messages fill, the
resources/read early return and the legacy shim. For clients before
2026-07-28 the shim now calls the handler again with the state echoed.
On the client, a requestState-only result counts as a round rather than
load shedding, the same as in the shim.
@SergeevDmitry

Copy link
Copy Markdown
Contributor Author

Pushed the server side.

  • asksForInput (inputRequests or requestState) now drives annotateResultType, validateMultiRoundTripResult, the empty content/messages fill in CallTool/GetPrompt, the early return in ReadResource and the legacy shim.
  • Legacy shim: a requestState-only round calls the handler again with the state echoed and no responses, counted against maxMultiRoundTripRetries.
  • Client: one change from the first commit. A requestState-only result now counts as a round instead of load shedding, so the client and the shim treat it the same way (10 rounds, not 3). An empty inputRequests map is still load shedding.
  • Server docs are updated. New tests cover all three methods, the legacy shim, the round limit, and content together with a requestState. I updated the PR description to match.

Comment thread mcp/mrtr_test.go Outdated
Comment on lines 341 to 344
@@ -342,6 +342,226 @@ type wrappedCallToolParams struct{ *CallToolParams }
// retry loop reports an explicit error when the params carry a type it
// cannot build retry params for, instead of silently resending the request
// unchanged until the retry cap.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

this comment now is placed in the wrong place

Comment thread mcp/mrtr.go Outdated
Comment on lines 159 to 161
if reqMap != nil && len(reqMap) == 0 {
return nil, fmt.Errorf("the server is busy, retry later")
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
if reqMap != nil && len(reqMap) == 0 {
return nil, fmt.Errorf("the server is busy, retry later")
}
if len(reqMap) == 0 && mrtrResult.requestState() == "" {
return nil, fmt.Errorf("the server is busy, retry later")
}

Comment thread mcp/mrtr.go
// round, not as load shedding.
continuing := reqMap == nil && mrtrResult.requestState() != ""
if len(reqMap) == 0 && !continuing {
loadSheddingFailures++

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Suggested change
loadSheddingFailures++
if len(reqMap) == 0 && mrtrResult.requestState() == "" {
loadSheddingFailures++
}

Apply the review suggestions: the client counts a round as load
shedding, and the legacy server path returns "busy", only when the
result has neither input requests nor a requestState. An empty
inputRequests map with a requestState now continues like a
requestState-only result.

Also move the TestMultiRoundTrip_UnsupportedRetryParamsType comment back
above its function.
@SergeevDmitry

Copy link
Copy Markdown
Contributor Author

Thanks. I applied both suggestions and moved the comment back.

  • The client now counts a round as load shedding only when the result has neither input requests nor a requestState. The legacy server path returns "busy" under the same condition. So an empty inputRequests map with a requestState now continues, like a requestState-only result.
  • That changes one existing case. TestMultiRoundTrip_MaxRetries "load shedding" returned {} together with RequestState: "loop-state". I dropped the state from that case and added "empty requests with state", which now runs to maxMultiRoundTripRetries.
  • TestMultiRoundTrip_ServerMiddleware_RequestStateOnly now covers a nil and an empty map, each with a state.
  • The new TestMultiRoundTrip_ServerMiddleware_Busy pins the busy error for an empty map without a state.

go test -race ./... and both conformance scripts pass (server 40/40, client 239/239).

This branch has not been deployed

No deployments
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.

mcp: client returns an input_required result that has only requestState instead of retrying

2 participants