Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion design/mrtr.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 5 additions & 3 deletions docs/server.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions internal/docs/server.src.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
53 changes: 29 additions & 24 deletions mcp/mrtr.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -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)
}
}
}
Expand Down
93 changes: 93 additions & 0 deletions mcp/mrtr_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
Loading