Skip to content
Open
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
3 changes: 2 additions & 1 deletion src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -532,7 +532,8 @@ private void HandleMessageWithId(JsonRpcMessage message, JsonRpcMessageWithId me
throw new McpProtocolException($"Method '{request.Method}' is not available.", McpErrorCode.MethodNotFound);
}

JsonNode? result = await handler(request, cancellationToken).ConfigureAwait(false);
RequestHandlerResult handlerResult = await handler(request, cancellationToken).ConfigureAwait(false);
JsonNode? result = _requestHandlers.PrepareForEmission(request, handlerResult);

await SendMessageAsync(new JsonRpcResponse
{
Expand Down
25 changes: 20 additions & 5 deletions src/ModelContextProtocol.Core/RequestHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,18 @@

namespace ModelContextProtocol;

internal sealed class RequestHandlers : Dictionary<string, Func<JsonRpcRequest, CancellationToken, Task<JsonNode?>>>
internal readonly record struct RequestHandlerResult(JsonNode? Json, bool IsProtocolResult = false, bool IsCacheable = false);

internal sealed class RequestHandlers : Dictionary<string, Func<JsonRpcRequest, CancellationToken, Task<RequestHandlerResult>>>
{
private readonly Func<JsonRpcRequest, RequestHandlerResult, JsonNode?>? _prepareResponseForEmission;

public RequestHandlers(Func<JsonRpcRequest, RequestHandlerResult, JsonNode?>? prepareResponseForEmission = null) =>
_prepareResponseForEmission = prepareResponseForEmission;

public JsonNode? PrepareForEmission(JsonRpcRequest request, RequestHandlerResult result) =>
_prepareResponseForEmission?.Invoke(request, result) ?? result.Json;

/// <summary>
/// Registers a handler for incoming requests of a specific method in the MCP protocol.
/// </summary>
Expand Down Expand Up @@ -41,8 +51,9 @@ public void Set<TParams, TResult>(
this[method] = async (request, cancellationToken) =>
{
TParams typedRequest = JsonSerializer.Deserialize(request.Params, requestTypeInfo)!;
object? result = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false);
return JsonSerializer.SerializeToNode(result, responseTypeInfo);
TResult result = await handler(typedRequest, request, cancellationToken).ConfigureAwait(false);
JsonNode? resultNode = JsonSerializer.SerializeToNode(result, responseTypeInfo);
return new(resultNode, result is Result, result is ICacheableResult);
};
}

Expand Down Expand Up @@ -70,10 +81,14 @@ public void SetWithAlternate<TParams, TResult>(

if (augmented.IsAlternate)
{
return JsonSerializer.SerializeToNode(augmented.Alternate!, augmented.AlternateTypeInfo!);
var result = augmented.Alternate!;
JsonNode? resultNode = JsonSerializer.SerializeToNode(result, augmented.AlternateTypeInfo!);
return new(resultNode, IsProtocolResult: true, IsCacheable: result is ICacheableResult);
}

return JsonSerializer.SerializeToNode(augmented.Result!, responseTypeInfo);
var immediateResult = augmented.Result!;
JsonNode? immediateResultNode = JsonSerializer.SerializeToNode(immediateResult, responseTypeInfo);
return new(immediateResultNode, IsProtocolResult: true, IsCacheable: immediateResult is ICacheableResult);
};
}
#pragma warning restore MCPEXP002
Expand Down
130 changes: 58 additions & 72 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact
UpdateEndpointNameWithClientInfo();

_notificationHandlers = new();
_requestHandlers = [];
_requestHandlers = new(PrepareResultForEmission);

// Configure all request handlers based on the supplied options.
ServerCapabilities = new();
Expand Down Expand Up @@ -1190,10 +1190,8 @@ private void ConfigureCustomRequestHandlers(McpServerOptions options)
#pragma warning restore MCPEXP002
}

private void SetRawHandler(string method, Func<JsonRpcRequest, CancellationToken, ValueTask<JsonNode?>> handler)
{
_requestHandlers[method] = (request, ct) => handler(request, ct).AsTask();
}
private void SetRawHandler(string method, Func<JsonRpcRequest, CancellationToken, ValueTask<JsonNode?>> handler) =>
_requestHandlers[method] = async (request, ct) => new(await handler(request, ct).ConfigureAwait(false));

private void ConfigureResources(McpServerOptions options)
{
Expand Down Expand Up @@ -1851,57 +1849,57 @@ private DestinationBoundMcpServer CreateDestinationBoundServer(JsonRpcRequest js
return server;
}

private void SetHandler<TParams, TResult>(
string method,
McpRequestHandler<TParams, TResult> handler,
JsonTypeInfo<TParams> requestTypeInfo,
JsonTypeInfo<TResult> responseTypeInfo)
private JsonNode? PrepareResultForEmission(JsonRpcRequest request, RequestHandlerResult result)
{
// SEP-2549: results that carry caching hints (tools/list, prompts/list, resources/list,
// resources/templates/list, and resources/read) declare ttlMs and cacheScope as required fields.
// When a handler leaves them unset, fill in conservative defaults (immediately stale and not
// shareable) so the wire form always carries the fields while preserving today's "don't cache"
// behavior. Any value supplied by the handler or a filter is left untouched.
if (typeof(ICacheableResult).IsAssignableFrom(typeof(TResult)))
{
var innerHandler = handler;
handler = async (request, cancellationToken) =>
{
var result = await innerHandler(request, cancellationToken).ConfigureAwait(false);
if (!result.IsProtocolResult || result.Json is not JsonObject resultObject)
{
return result.Json;
}

// ttlMs and cacheScope are 2026-07-28 result fields; only stamp them when the request
// was negotiated under that revision or later. Earlier revisions (e.g. 2025-11-25) reject
// these as unrecognized keys (issue #1721).
if (result is ICacheableResult cacheable && IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest))
{
cacheable.TimeToLive ??= TimeSpan.Zero;
cacheable.CacheScope ??= CacheScope.Private;
}
if (IsJuly2026OrLaterProtocolRequest(request))
{
resultObject["resultType"] ??= "complete";
if (result.IsCacheable)
{
resultObject["ttlMs"] ??= 0;
resultObject["cacheScope"] ??= "private";
}

return result;
};
return resultObject;
}

if (typeof(Result).IsAssignableFrom(typeof(TResult)))
string? strippedFields = resultObject.Remove("resultType") ? "resultType" : null;
if (result.IsCacheable)
{
var innerHandler = handler;
handler = async (request, cancellationToken) =>
if (resultObject.Remove("ttlMs"))
{
var result = await innerHandler(request, cancellationToken).ConfigureAwait(false);
strippedFields = strippedFields is null ? "ttlMs" : $"{strippedFields}, ttlMs";
}

// resultType is a 2026-07-28 result field; only stamp it when the request was negotiated
// under that revision or later. Earlier revisions (e.g. 2025-11-25) reject it as an
// unrecognized key (issue #1721).
if (result is Result protocolResult && protocolResult.ResultType is null &&
IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest))
{
protocolResult.ResultType = "complete";
}
if (resultObject.Remove("cacheScope"))
{
strippedFields = strippedFields is null ? "cacheScope" : $"{strippedFields}, cacheScope";
}
}

return result;
};
if (strippedFields is not null)
{
ProtocolIncompatibleResultFieldsOmitted(
_endpointName,
request.Method,
strippedFields,
request.Context?.ProtocolVersion ?? NegotiatedProtocolVersion);
}

return resultObject;
}

private void SetHandler<TParams, TResult>(
string method,
McpRequestHandler<TParams, TResult> handler,
JsonTypeInfo<TParams> requestTypeInfo,
JsonTypeInfo<TResult> responseTypeInfo)
{
_requestHandlers.Set(method,
(request, jsonRpcRequest, cancellationToken) =>
InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken),
Expand All @@ -1916,23 +1914,6 @@ private void SetWithAlternateHandler<TParams, TResult>(
JsonTypeInfo<TResult> responseTypeInfo)
where TResult : Result
{
var innerHandler = handler;
handler = async (request, cancellationToken) =>
{
var result = await innerHandler(request, cancellationToken).ConfigureAwait(false);

// resultType is a 2026-07-28 result field; only stamp it when the request was negotiated
// under that revision or later. Earlier revisions (e.g. 2025-11-25) reject it as an
// unrecognized key (issue #1721).
if (!result.IsAlternate && result.Result is { ResultType: null } immediateResult &&
IsJuly2026OrLaterProtocolRequest(request.JsonRpcRequest))
{
immediateResult.ResultType = "complete";
}

return result;
};

_requestHandlers.SetWithAlternate(method,
(request, jsonRpcRequest, cancellationToken) =>
InvokeHandlerAsync(handler, request, jsonRpcRequest, cancellationToken),
Expand Down Expand Up @@ -2082,8 +2063,8 @@ internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestCon
/// calls (elicitation, sampling, roots) and the handler is retried with the responses - allowing
/// MRTR-native tools to work transparently with clients that don't support MRTR.
/// </summary>
private async Task<JsonNode?> InvokeWithInputRequiredResultHandlingAsync(
Func<JsonRpcRequest, CancellationToken, Task<JsonNode?>> handler,
private async Task<RequestHandlerResult> InvokeWithInputRequiredResultHandlingAsync(
Func<JsonRpcRequest, CancellationToken, Task<RequestHandlerResult>> handler,
JsonRpcRequest request,
CancellationToken cancellationToken)
{
Expand All @@ -2102,7 +2083,7 @@ internal bool IsJuly2026OrLaterProtocolRequest(JsonRpcMessageContext? requestCon
// or by RETURNING an InputRequiredResult through the alternate result path (ResultOrAlternate).
// Normalize both forms so a client that doesn't natively support MRTR gets the same server-side
// resolution either way.
if (GetReturnedInputRequiredResult(result) is not { } returnedInputRequired)
if (GetReturnedInputRequiredResult(result.Json) is not { } returnedInputRequired)
{
return result;
}
Expand Down Expand Up @@ -2261,8 +2242,10 @@ private static async Task<InputResponse> ResolveInputRequestAsync(McpServer dest
}
}

private static JsonNode? SerializeInputRequiredResult(InputRequiredResult inputRequiredResult) =>
JsonSerializer.SerializeToNode(inputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult);
private static RequestHandlerResult SerializeInputRequiredResult(InputRequiredResult inputRequiredResult) =>
new(
JsonSerializer.SerializeToNode(inputRequiredResult, McpJsonUtilities.JsonContext.Default.InputRequiredResult),
IsProtocolResult: true);

/// <summary>
/// Detects an <see cref="InputRequiredResult"/> that a handler surfaced by RETURNING it through the alternate
Expand Down Expand Up @@ -2384,7 +2367,7 @@ private void WrapHandlerWithMrtr(string method)
// on the per-request DestinationBoundMcpServer. This is picked up synchronously
// before any await, so the finally cleanup is safe.
_mrtrContextsByRequestId[request.Id] = mrtrContext;
Task<JsonNode?> handlerTask;
Task<RequestHandlerResult> handlerTask;
try
{
handlerTask = originalHandler(request, handlerCts.Token);
Expand Down Expand Up @@ -2415,8 +2398,8 @@ private void WrapHandlerWithMrtr(string method)
/// If the handler throws <see cref="InputRequiredException"/>, the result is returned directly
/// without storing a continuation (explicit MRTR path).
/// </summary>
private async Task<JsonNode?> AwaitMrtrHandlerAsync(
Task<JsonNode?> handlerTask,
private async Task<RequestHandlerResult> AwaitMrtrHandlerAsync(
Task<RequestHandlerResult> handlerTask,
MrtrContinuation continuation,
Task<MrtrExchange> exchangeTask,
CancellationToken cancellationToken)
Expand Down Expand Up @@ -2460,7 +2443,7 @@ private void WrapHandlerWithMrtr(string method)
/// double-reporting at Error) and decrements <see cref="_mrtrInFlightCount"/> when the
/// handler completes, following the same in-flight tracking pattern as <see cref="McpSessionHandler"/>.
/// </summary>
private async Task ObserveHandlerCompletionAsync(Task<JsonNode?> handlerTask)
private async Task ObserveHandlerCompletionAsync(Task<RequestHandlerResult> handlerTask)
{
try
{
Expand Down Expand Up @@ -2491,7 +2474,7 @@ private async Task ObserveHandlerCompletionAsync(Task<JsonNode?> handlerTask)
/// Awaits a handler task, catching <see cref="InputRequiredException"/> to convert it to an
/// <see cref="InputRequiredResult"/> JSON response without storing a continuation.
/// </summary>
private static async Task<JsonNode?> AwaitHandlerWithInputRequiredResultHandlingAsync(Task<JsonNode?> handlerTask)
private static async Task<RequestHandlerResult> AwaitHandlerWithInputRequiredResultHandlingAsync(Task<RequestHandlerResult> handlerTask)
{
try
{
Expand Down Expand Up @@ -2529,4 +2512,7 @@ private async Task ObserveHandlerCompletionAsync(Task<JsonNode?> handlerTask)

[LoggerMessage(Level = LogLevel.Debug, Message = "Failed to deliver \"{NotificationMethod}\" to subscription \"{SubscriptionId}\".")]
private partial void SubscriptionNotificationFailed(string notificationMethod, string subscriptionId, Exception exception);

[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} application returned protocol-incompatible result field(s) '{Fields}' for '{Method}' on protocol version '{ProtocolVersion}'. The fields were omitted from the response.")]
private partial void ProtocolIncompatibleResultFieldsOmitted(string endpointName, string method, string fields, string? protocolVersion);
}
6 changes: 2 additions & 4 deletions src/ModelContextProtocol.Core/Server/MrtrContinuation.cs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
using System.Text.Json.Nodes;

namespace ModelContextProtocol.Server;

/// <summary>
Expand All @@ -11,7 +9,7 @@ internal sealed class MrtrContinuation
{
private readonly CancellationTokenSource _handlerCts;

public MrtrContinuation(CancellationTokenSource handlerCts, Task<JsonNode?> handlerTask, MrtrContext mrtrContext)
public MrtrContinuation(CancellationTokenSource handlerCts, Task<RequestHandlerResult> handlerTask, MrtrContext mrtrContext)
{
_handlerCts = handlerCts;
HandlerTask = handlerTask;
Expand All @@ -27,7 +25,7 @@ public MrtrContinuation(CancellationTokenSource handlerCts, Task<JsonNode?> hand
/// <summary>
/// The handler task that is suspended awaiting input.
/// </summary>
public Task<JsonNode?> HandlerTask { get; }
public Task<RequestHandlerResult> HandlerTask { get; }

/// <summary>
/// The MRTR context for the handler's async flow.
Expand Down
Loading