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>
299 lines
9.7 KiB
Go
299 lines
9.7 KiB
Go
// Copyright 2025 The Go MCP SDK Authors. All rights reserved.
|
|
// Use of this source code is governed by the license
|
|
// that can be found in the LICENSE file.
|
|
|
|
package mcp
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"log/slog"
|
|
"sync"
|
|
|
|
"github.com/modelcontextprotocol/go-sdk/jsonrpc"
|
|
"golang.org/x/sync/errgroup"
|
|
)
|
|
|
|
const maxMultiRoundTripRetries = 10
|
|
const maxLoadSheddingMultiRoundTripRetries = 3
|
|
|
|
// MultiRoundTripOptions configures the client-side multi round-trip request (SEP-2322)
|
|
// middleware. The middleware is enabled by default and automatically fulfills input
|
|
// requests from the server by invoking the appropriate client handlers and
|
|
// retrying the original call.
|
|
type MultiRoundTripOptions struct {
|
|
// Disabled prevents the automatic multi-round-tirp middleware from being installed.
|
|
// When true, the client returns input-required results directly and callers must
|
|
// handle the retry loop themselves using [CallToolResult.NeedsInput],
|
|
// [GetPromptResult.NeedsInput], or [ReadResourceResult.NeedsInput].
|
|
Disabled bool
|
|
}
|
|
|
|
type multiRoundTripResponse interface {
|
|
setResultType(resultType)
|
|
inputRequests() map[string]InputRequest
|
|
requestState() string
|
|
hasContent() bool
|
|
}
|
|
|
|
// validateMultiRoundTripResult rejects a result that carries both content and
|
|
// input requests. [annotateResultType] labels the result later, in
|
|
// [ServerSession.handle], so this function does not set resultType.
|
|
func validateMultiRoundTripResult(logger *slog.Logger, res multiRoundTripResponse) error {
|
|
if res == nil {
|
|
return nil
|
|
}
|
|
if res.inputRequests() != nil && res.hasContent() {
|
|
logger.Warn("handler returned both content and inputRequests")
|
|
return &jsonrpc.Error{
|
|
Code: jsonrpc.CodeInternalError,
|
|
Message: "server bug: result has both content and inputRequests",
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func clientSupportsMultiRoundTrip(ss *ServerSession) bool {
|
|
protocolVersion := latestProtocolVersion
|
|
if iparams := ss.InitializeParams(); iparams != nil {
|
|
protocolVersion = iparams.ProtocolVersion
|
|
}
|
|
return protocolVersion >= protocolVersion20260728
|
|
}
|
|
|
|
func clientMultiRoundTripMiddleware() Middleware {
|
|
return func(next MethodHandler) MethodHandler {
|
|
return func(ctx context.Context, method string, req Request) (Result, error) {
|
|
if method != methodCallTool && method != methodGetPrompt && method != methodReadResource {
|
|
return next(ctx, method, req)
|
|
}
|
|
|
|
loadSheddingFailures := 0
|
|
for retries := 1; ; retries++ {
|
|
res, err := next(ctx, method, req)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
mrtrResult, ok := res.(multiRoundTripResponse)
|
|
if !ok {
|
|
return res, nil
|
|
}
|
|
reqMap := mrtrResult.inputRequests()
|
|
if reqMap == nil {
|
|
return res, nil
|
|
}
|
|
if len(reqMap) == 0 {
|
|
loadSheddingFailures++
|
|
}
|
|
if loadSheddingFailures >= maxLoadSheddingMultiRoundTripRetries {
|
|
return nil, fmt.Errorf("multi-round-trip: exceeded maximum load-shedding retries (%d)", maxLoadSheddingMultiRoundTripRetries)
|
|
}
|
|
if retries >= maxMultiRoundTripRetries {
|
|
return nil, fmt.Errorf("multi-round-trip: exceeded maximum retries (%d)", maxMultiRoundTripRetries)
|
|
}
|
|
cs, ok := req.GetSession().(*ClientSession)
|
|
if !ok {
|
|
return res, nil
|
|
}
|
|
responses, err := fulfillInputRequests(ctx, cs, reqMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err = setMultiRoundTripRetryParams(method, req, responses, mrtrResult.requestState())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// serverMultiRoundTripMiddleware is a receiving middleware for servers that transparently
|
|
// handles multi-round-trip for clients on older protocol versions. When a handler returns
|
|
// InputRequests and the client does not support multi-round-trip, the middleware fulfills
|
|
// the requests by calling the client directly and reinvokes the handler once with the responses.
|
|
func serverMultiRoundTripMiddleware() Middleware {
|
|
return func(next MethodHandler) MethodHandler {
|
|
return func(ctx context.Context, method string, req Request) (Result, error) {
|
|
if method != methodCallTool && method != methodGetPrompt && method != methodReadResource {
|
|
return next(ctx, method, req)
|
|
}
|
|
|
|
ss, ok := req.GetSession().(*ServerSession)
|
|
if !ok {
|
|
return next(ctx, method, req)
|
|
}
|
|
if clientSupportsMultiRoundTrip(ss) {
|
|
return next(ctx, method, req)
|
|
}
|
|
|
|
res, err := next(ctx, method, req)
|
|
if err != nil {
|
|
return res, err
|
|
}
|
|
mrtrResult, ok := res.(multiRoundTripResponse)
|
|
if !ok {
|
|
return res, nil
|
|
}
|
|
reqMap := mrtrResult.inputRequests()
|
|
if reqMap == nil {
|
|
return res, nil
|
|
}
|
|
if len(reqMap) == 0 {
|
|
return nil, fmt.Errorf("the server is busy, retry later")
|
|
}
|
|
responses, err := fulfillServerInputRequests(ctx, ss, reqMap)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req, err = setMultiRoundTripRetryParams(method, req, responses, mrtrResult.requestState())
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return next(ctx, method, req)
|
|
}
|
|
}
|
|
}
|
|
|
|
func fulfillServerInputRequests(ctx context.Context, ss *ServerSession, requests InputRequestMap) (InputResponseMap, error) {
|
|
g, ctx := errgroup.WithContext(ctx)
|
|
var mu sync.Mutex
|
|
responses := make(InputResponseMap, len(requests))
|
|
for id, ir := range requests {
|
|
g.Go(func() error {
|
|
resp, err := fulfillServerInputRequest(ctx, ss, ir)
|
|
if err != nil {
|
|
return fmt.Errorf("fulfilling input request %q: %w", id, err)
|
|
}
|
|
mu.Lock()
|
|
responses[id] = resp
|
|
mu.Unlock()
|
|
return nil
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
return nil, fmt.Errorf("multi-round-trip: %w", err)
|
|
}
|
|
return responses, nil
|
|
}
|
|
|
|
func fulfillServerInputRequest(ctx context.Context, ss *ServerSession, ir InputRequest) (InputResponse, error) {
|
|
switch p := ir.(type) {
|
|
case *ElicitParams:
|
|
return ss.Elicit(ctx, p)
|
|
case *CreateMessageParams:
|
|
return ss.CreateMessageWithTools(ctx, createMessageParamsToWithTools(p))
|
|
case *CreateMessageWithToolsParams:
|
|
return ss.CreateMessageWithTools(ctx, p)
|
|
case *ListRootsParams:
|
|
return ss.ListRoots(ctx, p)
|
|
default:
|
|
return nil, fmt.Errorf("unknown input request type: %T", ir)
|
|
}
|
|
}
|
|
|
|
func createMessageParamsToWithTools(p *CreateMessageParams) *CreateMessageWithToolsParams {
|
|
var msgs []*SamplingMessageV2
|
|
for _, m := range p.Messages {
|
|
msgs = append(msgs, &SamplingMessageV2{Content: []Content{m.Content}, Role: m.Role})
|
|
}
|
|
return &CreateMessageWithToolsParams{
|
|
Meta: p.Meta,
|
|
IncludeContext: p.IncludeContext,
|
|
MaxTokens: p.MaxTokens,
|
|
Messages: msgs,
|
|
Metadata: p.Metadata,
|
|
ModelPreferences: p.ModelPreferences,
|
|
StopSequences: p.StopSequences,
|
|
SystemPrompt: p.SystemPrompt,
|
|
Temperature: p.Temperature,
|
|
}
|
|
}
|
|
|
|
// setMultiRoundTripRetryParams returns the request for the next round of a
|
|
// multi-round-trip retry: a request whose params are a shallow copy of the
|
|
// original, carrying the fulfilled responses and the request state to echo.
|
|
// The original params are never mutated: on the client side they are
|
|
// caller-owned, and a params struct reused across calls must not carry one
|
|
// call's inputResponses and requestState into the next, where a server
|
|
// gating on them (for example an elicitation confirmation) would treat the
|
|
// new call as already answered.
|
|
//
|
|
// An unrecognized params type is an error. It cannot occur on the server
|
|
// side, but client sending middleware runs before the retry loop and may
|
|
// substitute its own params.
|
|
func setMultiRoundTripRetryParams(method string, req Request, responses InputResponseMap, state string) (Request, error) {
|
|
var retryParams Params
|
|
switch p := req.GetParams().(type) {
|
|
case *CallToolParams:
|
|
cp := *p
|
|
cp.InputResponses = responses
|
|
cp.RequestState = state
|
|
retryParams = &cp
|
|
case *CallToolParamsRaw:
|
|
cp := *p
|
|
cp.InputResponses = responses
|
|
cp.RequestState = state
|
|
retryParams = &cp
|
|
case *GetPromptParams:
|
|
cp := *p
|
|
cp.InputResponses = responses
|
|
cp.RequestState = state
|
|
retryParams = &cp
|
|
case *ReadResourceParams:
|
|
cp := *p
|
|
cp.InputResponses = responses
|
|
cp.RequestState = state
|
|
retryParams = &cp
|
|
default:
|
|
return nil, fmt.Errorf("multi-round-trip: unsupported params type %T", p)
|
|
}
|
|
switch s := req.GetSession().(type) {
|
|
case *ClientSession:
|
|
return newClientRequest(s, retryParams), nil
|
|
case *ServerSession:
|
|
// Rebuild with the same constructor handleReceive uses: the
|
|
// receiving dispatch type-asserts on the request's concrete
|
|
// *ServerRequest[P] type, and Extra must carry over.
|
|
return s.receivingMethodInfos()[method].newRequest(s, retryParams, req.GetExtra()), nil
|
|
default:
|
|
return nil, fmt.Errorf("multi-round-trip: unsupported session type %T", s)
|
|
}
|
|
}
|
|
|
|
func fulfillInputRequests(ctx context.Context, cs *ClientSession, requests InputRequestMap) (InputResponseMap, error) {
|
|
g, ctx := errgroup.WithContext(ctx)
|
|
var mu sync.Mutex
|
|
responses := make(InputResponseMap, len(requests))
|
|
for id, ir := range requests {
|
|
g.Go(func() error {
|
|
resp, err := fulfillInputRequest(ctx, cs, ir)
|
|
if err != nil {
|
|
return fmt.Errorf("fulfilling input request %q: %w", id, err)
|
|
}
|
|
mu.Lock()
|
|
responses[id] = resp
|
|
mu.Unlock()
|
|
return nil
|
|
})
|
|
}
|
|
if err := g.Wait(); err != nil {
|
|
return nil, fmt.Errorf("multi round-trip: %w", err)
|
|
}
|
|
return responses, nil
|
|
}
|
|
|
|
func fulfillInputRequest(ctx context.Context, cs *ClientSession, ir InputRequest) (InputResponse, error) {
|
|
switch p := ir.(type) {
|
|
case *ElicitParams:
|
|
return cs.client.elicit(ctx, newClientRequest(cs, p))
|
|
case *CreateMessageParams:
|
|
return cs.client.createMessage(ctx, &CreateMessageWithToolsRequest{Session: cs, Params: createMessageParamsToWithTools(p)})
|
|
case *CreateMessageWithToolsParams:
|
|
return cs.client.createMessage(ctx, &CreateMessageWithToolsRequest{Session: cs, Params: p})
|
|
case *ListRootsParams:
|
|
return cs.client.listRoots(ctx, newClientRequest(cs, p))
|
|
default:
|
|
return nil, fmt.Errorf("unknown input request type: %T", ir)
|
|
}
|
|
}
|