go-sdk/mcp/mrtr.go
Haoxin Yang 69c17de411
mcp: don't mutate caller params on multi-round-trip retries (#1145)
Fixes #1144.

`clientMultiRoundTripMiddleware` calls
`setMultiRoundTripRetryParams(req, ...)`, which writes `InputResponses`
and `RequestState` through the caller's params pointer and never clears
them. A `*CallToolParams` (or `GetPromptParams` / `ReadResourceParams`)
struct reused across calls then carries the first call's answers into
the next call: a server gating on `len(req.Params.InputResponses) == 0`
— the idiom in this repo's own tests and examples — treats the fresh
call as already answered, so the client's `ElicitationHandler` never
fires and an elicitation-gated (e.g. destructive) tool runs without
confirmation.

This change makes the retry loop carry the fulfilled responses and
echoed state on a shallow copy of the params
(`multiRoundTripRetryRequest`) instead of mutating the original request.
Caller-owned params are now left untouched by `CallTool`, `GetPrompt`,
and `ReadResource`. The server-side shim
(`serverMultiRoundTripMiddleware`) is unchanged: the params it mutates
are SDK-owned, decoded from the wire.

Tests: `TestMultiRoundTrip_AutoRetryDoesNotMutateCallerParams`
(CallTool: params unchanged after the call, and reusing the same struct
fulfills the input requests again instead of silently replaying the
first call's answers — it failed with `elicitations after reuse = 1,
want 2` before the fix), plus GetPrompt/ReadResource analogues asserting
params are left clean. All three fail on main and pass with this change;
`go test ./...` and `go vet ./...` are clean.

Note on observable behavior: callers can no longer read the fulfilled
responses off their own params struct after a call returns. That side
effect was undocumented, and relying on it is exactly what makes the
replay hazard above possible.

---------

Co-authored-by: Guglielmo Colombo <guglielmoc@google.com>
2026-08-31 13:53:34 +02:00

308 lines
9.9 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
}
func handleMultiRoundTripResult(ss *ServerSession, logger *slog.Logger, res multiRoundTripResponse) error {
if res == nil {
return nil
}
hasInputRequests := res.inputRequests() != nil
if hasInputRequests && 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",
}
}
if clientSupportsMultiRoundTrip(ss) {
// For older clients the resultType is left unset. Input requests will be handled
// by serverMultiRoundTripMiddleware client calls and handler reinvocation.
if hasInputRequests {
res.setResultType(resultTypeInputRequired)
} else {
res.setResultType(resultTypeComplete)
}
}
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)
}
}