mcp: label resultType on results that bypass the dispatcher (#1226)

Fixes #1225. See the [issue
comment](https://github.com/modelcontextprotocol/go-sdk/issues/1225#issuecomment-5500818546).

## The bug

`2026-07-28` requires `resultType` on every result. `CallToolResult`,
`GetPromptResult`, and `ReadResourceResult` keep `resultType` in an
unexported field. Each of these results can be `complete` or
`input_required`, so it cannot embed `completeResultWithType` the way
the seven types in #1060 do.

Only `handleMultiRoundTripResult` sets that field. It runs inside
`Server.callTool`, `getPrompt`, and `readResource`. Some middleware
returns a result directly instead of calling `next` — for example, an
auth, rate-limit, or entitlement gate. That middleware never reaches
`handleMultiRoundTripResult`. The response then ships without the field,
and strict clients reject it.

## The fix

The choice between `complete` and `input_required` now happens inside
`setMultiRoundTripResultType`. Both call sites that need this choice
call that function. `setCompleteResultType` now labels results it did
not label before, so it is renamed `annotateResultType`. The new
function picks a branch with a type switch.

Reading matters here. `setResultType` is unexported, so setting
`InputRequests` is the only way for middleware to ask for input. If the
code always labels results `complete`, the response ships as
`{"resultType":"complete","inputRequests":{...}}`. A client that trusts
the discriminator then drops the elicitation request.

Recomputing the label every time is safe. A result that did reach the
dispatcher already carries the same label from that test. This also
covers one edge case: a handler that returns `InputRequests` again after
the shim's single reinvocation. Before this fix, that case shipped with
no `resultType`.

Embedding `completeResultWithType` instead is not an option: it labels
everything `complete` and overwrites `input_required`, breaking
multi-round-trip flows.

**Skip nil results.** Middleware can return a typed nil value, which
satisfies the `Result` interface. `handle` now checks for that case
before it calls the labeling function. Before this change, that same
case also panicked, one frame later in `annotateServerInfo` and then
`GetMeta`.

## Checks

- `TestServerSessionHandle_SetsResultTypeWhenMiddlewareShortCircuits`
fails on all three methods before the first change. Its fourth case
fails if labeling overwrites a populated `InputRequests`.
- `TestServerSessionHandle_ResultTypeGate` records which requests carry
`resultType`, including one known gap. See "Separate from this PR"
below.
- `TestServerSessionHandle_NilResultFromMiddleware` panics without the
nil check.
- The complete-path cases added to
`TestServerSessionHandle_SetsResultTypeOnNewProtocol` also pass before
this change. They close a gap in #1060, which added only three cases,
and only for `input_required`.
- The exported API is unchanged. `go doc -all ./mcp`, with comments
stripped, is identical before and after this change.
- With the reproduction from the issue, over the streamable transport:
the dispatched `tools/call` and the short-circuited `tools/call` both
now return `resultType: "complete"`.

I could not run `./scripts/conformance.sh`. CONTRIBUTING.md names that
script, but the repo only has `client-conformance.sh` and
`server-conformance.sh`. Both scripts need GNU `timeout`, and macOS does
not include it.

## Also here

Ten comments named an exported `ResultType` field and
`ResultTypeComplete` / `ResultTypeInputRequired` constants. None of
those exist. Two of the comments were `[CallToolResult.ResultType]` doc
links, and those links cannot resolve. They now point at
`[CallToolResult.NeedsInput]` instead, because an unexported field is
also not a valid link target.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
This commit is contained in:
Ronald 2026-09-14 03:52:10 -04:00 • committed by GitHub
commit e40f35d137
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -910,7 +910,7 @@ func (s *Server) getPrompt(ctx context.Context, req *GetPromptRequest) (*GetProm
}
res, err := prompt.handler(ctx, req)
if err == nil && res != nil {
if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil {
if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil {
return nil, err
}
}
@ -1012,10 +1012,10 @@ func (s *Server) callTool(ctx context.Context, req *CallToolRequest) (*CallToolR
}
res, err := st.handler(ctx, req)
if err == nil && res != nil {
if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil {
if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil {
return nil, err
}
if res.Content == nil && res.resultType != resultTypeInputRequired {
if res.Content == nil && res.InputRequests == nil {
res2 := *res
res2.Content = []Content{} // avoid "null"
res = &res2
@ -1080,11 +1080,11 @@ func (s *Server) readResource(ctx context.Context, req *ReadResourceRequest) (*R
if res == nil {
return nil, fmt.Errorf("reading resource %s: read handler returned nil information", uri)
}
if err := handleMultiRoundTripResult(req.Session, s.opts.Logger, res); err != nil {
if err := validateMultiRoundTripResult(s.opts.Logger, res); err != nil {
return nil, err
}
s.resolveCacheable(ctx, req, &res.Cacheable)
if res.resultType == resultTypeInputRequired {
if res.InputRequests != nil {
return res, nil
}
if res.Contents == nil {
@ -2039,8 +2039,10 @@ func (ss *ServerSession) handle(ctx context.Context, req *jsonrpc.Request) (any,
if err != nil {
return nil, err
}
if validatedMeta.usesNewProtocol {
setCompleteResultType(res)
// A middleware can return a typed nil value that satisfies the Result
// interface. Annotating that value panics.
if validatedMeta.usesNewProtocol && res != nil && !res.isNil() {
annotateResultType(res)
annotateServerInfo(res, ss.server.impl)
}
return res, nil