Skip to content

Commit 350d326

Browse files
halter73Copilot
andcommitted
Add MRTR-native backcompat: resolve IncompleteResultException via legacy JSON-RPC
When a tool throws IncompleteResultException and the client doesn't support MRTR, the server now resolves each InputRequest by sending the corresponding standard JSON-RPC call (elicitation, sampling, roots) to the client and retries the handler with the responses. This allows authors to write a single MRTR-native tool implementation that works with any client. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7e65a72 commit 350d326

4 files changed

Lines changed: 514 additions & 20 deletions

File tree

docs/concepts/mrtr/mrtr.md

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -329,7 +329,45 @@ When a server has MRTR enabled but the connected client does not:
329329

330330
- The high-level API (`ElicitAsync`, `SampleAsync`) automatically falls back to sending standard JSON-RPC requests — no code changes needed.
331331
- The low-level API reports `IsMrtrSupported == false`, allowing the tool to provide a custom fallback message.
332-
- Throwing `IncompleteResultException` when MRTR is not supported results in a JSON-RPC error being returned to the client.
332+
333+
### Backward compatibility for MRTR-native tools
334+
335+
Tools written with the low-level MRTR pattern (`IncompleteResultException`) work automatically with clients that don't support MRTR. When a tool throws `IncompleteResultException` and the client hasn't negotiated MRTR, the SDK resolves each `InputRequest` by sending the corresponding standard JSON-RPC call (elicitation, sampling, or roots) to the client, then retries the handler with the resolved responses.
336+
337+
This means you can write a single tool implementation using the MRTR-native pattern and it will work with any client:
338+
339+
```csharp
340+
[McpServerTool, Description("Get weather with user's preferred units")]
341+
public static string GetWeather(
342+
RequestContext<CallToolRequestParams> context,
343+
string location)
344+
{
345+
// On retry, inputResponses and requestState are populated
346+
if (context.Params!.InputResponses?.TryGetValue("units", out var response) == true)
347+
{
348+
var units = response.ElicitationResult?.Content?.FirstOrDefault().Value;
349+
return $"Weather for {location} in {units}: 72°";
350+
}
351+
352+
// First call: request the user's preferred units
353+
throw new IncompleteResultException(
354+
inputRequests: new Dictionary<string, InputRequest>
355+
{
356+
["units"] = InputRequest.ForElicitation(new ElicitRequestParams
357+
{
358+
Message = "Which temperature units?",
359+
RequestedSchema = new()
360+
})
361+
},
362+
requestState: "awaiting-units");
363+
}
364+
```
365+
366+
- **With an MRTR client**: The `IncompleteResult` is sent over the wire. The client resolves the elicitation and retries with `inputResponses`.
367+
- **Without MRTR**: The SDK sends a standard `elicitation/create` JSON-RPC request to the client, collects the response, and retries the handler internally. The client never sees the `IncompleteResult`.
368+
369+
> [!NOTE]
370+
> The backcompat retry loop resolves up to 10 rounds. Tools that need more rounds should use the high-level API (`ElicitAsync`) instead.
333371
334372
## Transitioning from MRTR to Tasks
335373

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 87 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1321,33 +1321,103 @@ private void WrapHandlerWithMrtr(string method)
13211321

13221322
/// <summary>
13231323
/// Invokes a handler and catches <see cref="IncompleteResultException"/> to convert it to an
1324-
/// <see cref="IncompleteResult"/> JSON response. In stateless mode, the exception is always
1325-
/// serialized because the server cannot determine client MRTR support. In stateful mode,
1326-
/// if MRTR is not supported, the exception is wrapped with a descriptive message.
1324+
/// <see cref="IncompleteResult"/> JSON response. When MRTR is negotiated or the server is stateless,
1325+
/// the result is serialized directly. Otherwise, input requests are resolved via standard JSON-RPC
1326+
/// calls (elicitation, sampling, roots) and the handler is retried with the responses — allowing
1327+
/// MRTR-native tools to work transparently with clients that don't support MRTR.
13271328
/// </summary>
13281329
private async Task<JsonNode?> InvokeWithIncompleteResultHandlingAsync(
13291330
Func<JsonRpcRequest, CancellationToken, Task<JsonNode?>> handler,
13301331
JsonRpcRequest request,
13311332
CancellationToken cancellationToken)
13321333
{
1333-
try
1334-
{
1335-
return await handler(request, cancellationToken).ConfigureAwait(false);
1336-
}
1337-
catch (IncompleteResultException ex)
1334+
const int MaxRetries = 10;
1335+
1336+
for (int retry = 0; ; retry++)
13381337
{
1339-
// Allow the IncompleteResult if the client supports MRTR or the server is stateless
1340-
// (in stateless mode, the tool handler has explicitly chosen to return an IncompleteResult
1341-
// via the low-level API, so we trust that decision regardless of negotiated version).
1342-
if (!ClientSupportsMrtr() && _sessionTransport is not StreamableHttpServerTransport { Stateless: true })
1338+
try
13431339
{
1344-
throw new McpException(
1345-
"A tool handler returned an incomplete result, but the client does not support Multi Round-Trip Requests (MRTR). " +
1346-
"Ensure both the server and client have ExperimentalProtocolVersion configured to enable MRTR.",
1347-
ex);
1340+
return await handler(request, cancellationToken).ConfigureAwait(false);
13481341
}
1342+
catch (IncompleteResultException ex)
1343+
{
1344+
// If the client supports MRTR or the server is stateless, serialize and return directly.
1345+
// In stateless mode, the tool handler has explicitly chosen to return an IncompleteResult
1346+
// via the low-level API, so we trust that decision regardless of negotiated version.
1347+
if (ClientSupportsMrtr() || _sessionTransport is StreamableHttpServerTransport { Stateless: true })
1348+
{
1349+
return SerializeIncompleteResult(ex.IncompleteResult);
1350+
}
13491351

1350-
return SerializeIncompleteResult(ex.IncompleteResult);
1352+
// Backcompat: resolve input requests via standard JSON-RPC calls and retry the handler.
1353+
if (ex.IncompleteResult.InputRequests is not { Count: > 0 } inputRequests)
1354+
{
1355+
throw new McpException(
1356+
"A tool handler returned an incomplete result without input requests, and the client does not support MRTR.", ex);
1357+
}
1358+
1359+
if (retry >= MaxRetries)
1360+
{
1361+
throw new McpException(
1362+
$"MRTR-native tool exceeded {MaxRetries} retry rounds without completing.", ex);
1363+
}
1364+
1365+
// Resolve each input request by sending the corresponding JSON-RPC call to the client.
1366+
var inputResponses = new Dictionary<string, InputResponse>(inputRequests.Count);
1367+
foreach (var kvp in inputRequests)
1368+
{
1369+
inputResponses[kvp.Key] = await ResolveInputRequestAsync(kvp.Value, cancellationToken).ConfigureAwait(false);
1370+
}
1371+
1372+
// Reconstruct request params with inputResponses and requestState for the retry.
1373+
var paramsObj = request.Params?.DeepClone() as JsonObject ?? new JsonObject();
1374+
paramsObj["inputResponses"] = JsonSerializer.SerializeToNode(
1375+
(IDictionary<string, InputResponse>)inputResponses, McpJsonUtilities.JsonContext.Default.IDictionaryStringInputResponse);
1376+
1377+
if (ex.IncompleteResult.RequestState is { } requestState)
1378+
{
1379+
paramsObj["requestState"] = requestState;
1380+
}
1381+
1382+
request = new JsonRpcRequest
1383+
{
1384+
Id = request.Id,
1385+
Method = request.Method,
1386+
Params = paramsObj,
1387+
Context = request.Context,
1388+
};
1389+
}
1390+
}
1391+
}
1392+
1393+
/// <summary>
1394+
/// Resolves a single MRTR <see cref="InputRequest"/> by dispatching it as a standard JSON-RPC
1395+
/// request to the client. This is the server-side mirror of the client's input resolution logic,
1396+
/// used for backward compatibility when the client doesn't support MRTR.
1397+
/// </summary>
1398+
private async Task<InputResponse> ResolveInputRequestAsync(InputRequest inputRequest, CancellationToken cancellationToken)
1399+
{
1400+
switch (inputRequest.Method)
1401+
{
1402+
case RequestMethods.ElicitationCreate:
1403+
var elicitParams = inputRequest.ElicitationParams
1404+
?? throw new McpException("Failed to deserialize elicitation parameters from MRTR input request.");
1405+
var elicitResult = await ElicitAsync(elicitParams, cancellationToken).ConfigureAwait(false);
1406+
return InputResponse.FromElicitResult(elicitResult);
1407+
1408+
case RequestMethods.SamplingCreateMessage:
1409+
var samplingParams = inputRequest.SamplingParams
1410+
?? throw new McpException("Failed to deserialize sampling parameters from MRTR input request.");
1411+
var samplingResult = await SampleAsync(samplingParams, cancellationToken).ConfigureAwait(false);
1412+
return InputResponse.FromSamplingResult(samplingResult);
1413+
1414+
case RequestMethods.RootsList:
1415+
var rootsParams = inputRequest.RootsParams ?? new ListRootsRequestParams();
1416+
var rootsResult = await RequestRootsAsync(rootsParams, cancellationToken).ConfigureAwait(false);
1417+
return InputResponse.FromRootsResult(rootsResult);
1418+
1419+
default:
1420+
throw new McpException($"Unsupported input request method: '{inputRequest.Method}'.");
13511421
}
13521422
}
13531423

0 commit comments

Comments
 (0)