diff --git a/design/mrtr.md b/design/mrtr.md index f41ac9a92..f1cb60c93 100644 --- a/design/mrtr.md +++ b/design/mrtr.md @@ -82,7 +82,7 @@ mcp.AddTool(s, tool, func(ctx context.Context, req *mcp.CallToolRequest, in MyIn ``` The SDK validates at runtime that a handler does not return both content and `InputRequests` — doing so logs a warning and returns a `CodeInternalError` JSON-RPC error. -An unexported receiving middleware is installed on the server for backward compatibility with older clients. When a handler returns `InputRequests` and the connected client uses a protocol version that does not support MRTR, the middleware fulfills the requests by calling `ServerSession.Elicit`/`CreateMessage`/`ListRoots` on the client directly and reinvokes the handler once with the collected `InputResponses`. If any of these calls fail, the entire request fails. Input requests are fulfilled concurrently. This lets server developers write protocol-version-independent code. +An unexported receiving middleware is installed on the server for backward compatibility with older clients. When a handler returns `InputRequests` and the connected client uses a protocol version that does not support MRTR, the middleware fulfills the requests by calling `ServerSession.Elicit`/`CreateMessage`/`ListRoots` on the client directly and reinvokes the handler with the collected `InputResponses`, repeating until the handler completes or the same retry limit as the client-side middleware is reached. If any of these calls fail, the entire request fails. Input requests are fulfilled concurrently. This lets server developers write protocol-version-independent code. An unexported sending middleware is installed on the client, which similarly to `urlElicitationMiddleware` will automatically invoke handlers for the corresponding methods on incomplete results and retry the original request. Clients have an option to disable it and write a retry loop manually using `NeedsInput()`: ```go diff --git a/docs/server.md b/docs/server.md index 8973e3ae9..764c96bf1 100644 --- a/docs/server.md +++ b/docs/server.md @@ -868,9 +868,11 @@ clients on a protocol version earlier than `2026-07-28`, the middleware intercepts any `InputRequiredResult` your handler returns, fulfils each input request itself by calling the legacy server-initiated APIs (`Elicit`, `CreateMessage`, `ListRoots`), and re-invokes your handler -exactly once with the responses already populated. This means a handler -written in the MRTR style works against both old and new clients without -code changes. +with the responses already populated. If the handler asks for more input, +the middleware repeats this until the handler completes, up to the same +limit of 10 rounds that the client-side middleware applies. This means a +handler written in the MRTR style works against both old and new clients +without code changes. ### Example diff --git a/internal/docs/server.src.md b/internal/docs/server.src.md index ff6638363..1b24c1d6f 100644 --- a/internal/docs/server.src.md +++ b/internal/docs/server.src.md @@ -349,9 +349,11 @@ clients on a protocol version earlier than `2026-07-28`, the middleware intercepts any `InputRequiredResult` your handler returns, fulfils each input request itself by calling the legacy server-initiated APIs (`Elicit`, `CreateMessage`, `ListRoots`), and re-invokes your handler -exactly once with the responses already populated. This means a handler -written in the MRTR style works against both old and new clients without -code changes. +with the responses already populated. If the handler asks for more input, +the middleware repeats this until the handler completes, up to the same +limit of 10 rounds that the client-side middleware applies. This means a +handler written in the MRTR style works against both old and new clients +without code changes. ### Example diff --git a/mcp/mrtr.go b/mcp/mrtr.go index fdf98ece8..90c888c8d 100644 --- a/mcp/mrtr.go +++ b/mcp/mrtr.go @@ -111,7 +111,8 @@ func clientMultiRoundTripMiddleware() Middleware { // 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. +// the requests by calling the client directly and reinvokes the handler with the responses, +// repeating until the handler completes or maxMultiRoundTripRetries is reached. func serverMultiRoundTripMiddleware() Middleware { return func(next MethodHandler) MethodHandler { return func(ctx context.Context, method string, req Request) (Result, error) { @@ -127,30 +128,34 @@ func serverMultiRoundTripMiddleware() Middleware { 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 + 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 { + return nil, fmt.Errorf("the server is busy, retry later") + } + if retries >= maxMultiRoundTripRetries { + return nil, fmt.Errorf("multi-round-trip: exceeded maximum retries (%d)", maxMultiRoundTripRetries) + } + 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) } } } diff --git a/mcp/mrtr_test.go b/mcp/mrtr_test.go index d96621987..fa968c516 100644 --- a/mcp/mrtr_test.go +++ b/mcp/mrtr_test.go @@ -557,6 +557,99 @@ func TestMultiRoundTrip_ServerMiddleware_GetPrompt(t *testing.T) { } } +// TestMultiRoundTrip_ServerMiddleware_MultipleRounds verifies that the +// server-side shim keeps fulfilling input requests for a client on an older +// protocol version until the handler completes, as the client-side +// middleware does, instead of returning a later round's input-required +// result, which that client cannot interpret. +func TestMultiRoundTrip_ServerMiddleware_MultipleRounds(t *testing.T) { + ctx := context.Background() + + srv := NewServer(testImpl, nil) + srv.AddTool( + &Tool{Name: "act", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(_ context.Context, req *CallToolRequest) (*CallToolResult, error) { + switch req.Params.RequestState { + case "": + return &CallToolResult{ + InputRequests: InputRequestMap{"first": &ElicitParams{Message: "First?"}}, + RequestState: "step-1", + }, nil + case "step-1": + return &CallToolResult{ + InputRequests: InputRequestMap{"second": &ElicitParams{Message: "Second?"}}, + RequestState: "step-2", + }, nil + default: + return &CallToolResult{Content: []Content{&TextContent{Text: "done"}}}, nil + } + }, + ) + + var elicitations atomic.Int32 + // Disable the client-side middleware so the client behaves like one + // built for 2025-11-25, which has no notion of input-required results. + cs := mustConnectOldProtocol(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + elicitations.Add(1) + return &ElicitResult{Action: "accept"}, nil + }, + }) + + res, err := cs.CallTool(ctx, &CallToolParams{Name: "act"}) + if err != nil { + t.Fatalf("CallTool() error = %v", err) + } + if len(res.Content) != 1 { + t.Fatalf("CallTool() content = %v, inputRequests = %v; want one content block", res.Content, res.InputRequests) + } + if got := res.Content[0].(*TextContent).Text; got != "done" { + t.Errorf("result text = %q, want %q", got, "done") + } + if got := elicitations.Load(); got != 2 { + t.Errorf("elicitation requests = %d, want 2", got) + } +} + +// TestMultiRoundTrip_ServerMiddleware_MaxRetries verifies that the +// server-side shim stops after maxMultiRoundTripRetries handler calls when +// the handler keeps asking an older client for input. +func TestMultiRoundTrip_ServerMiddleware_MaxRetries(t *testing.T) { + ctx := context.Background() + + var serverCalls atomic.Int32 + srv := NewServer(testImpl, nil) + srv.AddTool( + &Tool{Name: "loop", InputSchema: &jsonschema.Schema{Type: "object"}}, + func(_ context.Context, _ *CallToolRequest) (*CallToolResult, error) { + serverCalls.Add(1) + return &CallToolResult{ + InputRequests: InputRequestMap{"confirm": &ElicitParams{Message: "Again?"}}, + RequestState: "loop-state", + }, nil + }, + ) + + cs := mustConnectOldProtocol(t, srv, &ClientOptions{ + MultiRoundTrip: &MultiRoundTripOptions{Disabled: true}, + ElicitationHandler: func(_ context.Context, _ *ElicitRequest) (*ElicitResult, error) { + return &ElicitResult{Action: "accept"}, nil + }, + }) + + _, err := cs.CallTool(ctx, &CallToolParams{Name: "loop"}) + if err == nil { + t.Fatal("CallTool() err = nil, want error for exceeded max retries") + } + if !strings.Contains(err.Error(), "exceeded maximum retries") { + t.Errorf("CallTool() err = %v, want it to mention the retry limit", err) + } + if got := serverCalls.Load(); got != maxMultiRoundTripRetries { + t.Errorf("serverCalls = %d, want %d", got, maxMultiRoundTripRetries) + } +} + func TestMultiRoundTrip_GetPrompt_AutoRetry(t *testing.T) { ctx := context.Background()