using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; using System.Collections.Concurrent; using System.Diagnostics; using System.Diagnostics.Metrics; using System.Text.Json; using System.Text.Json.Nodes; #if !NET using System.Threading.Channels; #endif namespace ModelContextProtocol; /// /// Class for managing an MCP JSON-RPC session. This covers both MCP clients and servers. /// internal sealed partial class McpSessionHandler : IAsyncDisposable { private static readonly Histogram s_clientSessionDuration = Diagnostics.CreateDurationHistogram( "mcp.client.session.duration", "The duration of the MCP session as observed on the MCP client."); private static readonly Histogram s_serverSessionDuration = Diagnostics.CreateDurationHistogram( "mcp.server.session.duration", "The duration of the MCP session as observed on the MCP server."); private static readonly Histogram s_clientOperationDuration = Diagnostics.CreateDurationHistogram( "mcp.client.operation.duration", "The duration of the MCP request or notification as observed on the sender from the time it was sent until the response or ack is received."); private static readonly Histogram s_serverOperationDuration = Diagnostics.CreateDurationHistogram( "mcp.server.operation.duration", "MCP request or notification duration as observed on the receiver from the time it was received until the result or ack is sent."); /// /// All protocol versions supported by this implementation. The era-specific lists live on /// so the shared source file is the single source of truth. /// internal static readonly string[] SupportedProtocolVersions = McpProtocolVersions.SupportedProtocolVersions; /// /// Checks if the given protocol version supports priming events. /// /// The protocol version to check. /// True if the protocol version supports priming events. /// /// Priming events are only supported in protocol version >= 2025-11-25. /// Older clients may crash when receiving SSE events with empty data. /// internal static bool SupportsPrimingEvent(string? protocolVersion) { const string MinResumabilityProtocolVersion = McpProtocolVersions.November2025ProtocolVersion; if (protocolVersion is null) { return false; } return string.Compare(protocolVersion, MinResumabilityProtocolVersion, StringComparison.Ordinal) >= 0; } /// /// Checks whether the negotiated protocol version permits emitting non-object output /// schemas and their structured content in their natural shape (per SEP-2106). /// /// The negotiated protocol version, or null if /// negotiation has not completed. /// true if the version is the 2026-07-28 revision or later, which is where /// SEP-2106 widened outputSchema to any JSON Schema 2020-12 document; false otherwise. /// A false return signals that the wire emission boundary must apply the /// {"result": <value>} envelope expected by clients on protocol versions that pre-date /// SEP-2106. internal static bool SupportsNaturalOutputSchemas(string? protocolVersion) { if (protocolVersion is null) { return false; } return string.Compare(protocolVersion, McpProtocolVersions.July2026ProtocolVersion, StringComparison.Ordinal) >= 0; } private readonly bool _isServer; private readonly string _transportKind; private readonly ITransport _transport; private readonly RequestHandlers _requestHandlers; private readonly NotificationHandlers _notificationHandlers; private readonly JsonRpcMessageFilter _incomingMessageFilter; private readonly JsonRpcMessageFilter _outgoingMessageFilter; private readonly long _sessionStartingTimestamp = Stopwatch.GetTimestamp(); private readonly DistributedContextPropagator _propagator = DistributedContextPropagator.Current; /// Collection of requests sent on this session and waiting for responses. private readonly ConcurrentDictionary> _pendingRequests = []; /// /// Collection of requests received on this session and currently being handled. The value provides a /// that can be used to request cancellation of the in-flight handler. /// private readonly ConcurrentDictionary _handlingRequests = new(); private readonly ILogger _logger; // This _sessionId is solely used to identify the session in telemetry and logs. private readonly string _sessionId = Guid.NewGuid().ToString("N"); private long _lastRequestId; private CancellationTokenSource? _messageProcessingCts; private Task? _messageProcessingTask; /// /// Initializes a new instance of the class. /// /// true if this is a server; false if it's a client. /// An MCP transport implementation. /// The name of the endpoint for logging and debug purposes. /// A collection of request handlers. /// A collection of notification handlers. /// A filter that wraps incoming message processing. Takes the next handler and returns a wrapped handler. If null, a passthrough filter is used. /// A filter that wraps outgoing message processing. Takes the next handler and returns a wrapped handler. If null, a passthrough filter is used. /// The logger. public McpSessionHandler( bool isServer, ITransport transport, string endpointName, RequestHandlers requestHandlers, NotificationHandlers notificationHandlers, JsonRpcMessageFilter? incomingMessageFilter, JsonRpcMessageFilter? outgoingMessageFilter, ILogger logger) { Throw.IfNull(transport); _transportKind = transport switch { StdioClientSessionTransport or StdioServerTransport => "pipe", StreamClientSessionTransport or StreamServerTransport => "pipe", SseClientSessionTransport or SseResponseStreamTransport => "tcp", StreamableHttpClientSessionTransport or StreamableHttpServerTransport or StreamableHttpPostTransport => "tcp", _ => "unknown" }; _isServer = isServer; _transport = transport; EndpointName = endpointName; _requestHandlers = requestHandlers; _notificationHandlers = notificationHandlers; _incomingMessageFilter = incomingMessageFilter ?? (next => next); _outgoingMessageFilter = outgoingMessageFilter ?? (next => next); _logger = logger; // ping was removed in the 2026-07-28 protocol revision (SEP-2575). On the 2026-07-28 or later version, // return MethodNotFound; on an older version, the per-spec behavior is to always answer // with PingResult. Liveness on those requests belongs to transport- and request-level // timeouts, not a dedicated MCP RPC. _requestHandlers.Set( RequestMethods.Ping, (request, jsonRpcRequest, cancellationToken) => { string? perRequestVersion = jsonRpcRequest?.Context?.ProtocolVersion ?? NegotiatedProtocolVersion; if (McpProtocolVersions.RequiresPerRequestMetadata(perRequestVersion)) { throw new McpProtocolException( $"Method '{RequestMethods.Ping}' is not available on protocol version '{perRequestVersion}'.", McpErrorCode.MethodNotFound); } return new ValueTask(new PingResult()); }, McpJsonUtilities.JsonContext.Default.JsonNode, McpJsonUtilities.JsonContext.Default.PingResult); LogSessionCreated(EndpointName, _sessionId, _transportKind); } /// /// Gets and sets the name of the endpoint for logging and debug purposes. /// public string EndpointName { get; set; } /// /// Gets or sets the negotiated MCP protocol version for telemetry. /// public string? NegotiatedProtocolVersion { get; set; } /// /// Gets a task that completes when the client session has completed, providing details about the closure. /// Completion details are resolved from the transport's channel completion exception: if a transport /// completes its channel with a , the wrapped /// is unwrapped. Otherwise, a default instance is returned. /// internal Task CompletionTask => field ??= GetCompletionDetailsAsync(_transport.MessageReader.Completion); /// /// Starts processing messages from the transport. This method will block until the transport is disconnected. /// This is generally started in a background task or thread from the initialization logic of the derived class. /// public Task ProcessMessagesAsync(CancellationToken cancellationToken) { if (_messageProcessingTask is not null) { throw new InvalidOperationException("The message processing loop has already started."); } Debug.Assert(_messageProcessingCts is null); _messageProcessingCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); _messageProcessingTask = ProcessMessagesCoreAsync(_messageProcessingCts.Token); return _messageProcessingTask; } private async Task ProcessMessagesCoreAsync(CancellationToken cancellationToken) { // Track in-flight message handlers so we can wait for them to complete before returning. // Start at 1 to represent ProcessMessagesCoreAsync itself; it's decremented after the loop exits. int inFlightCount = 1; var allHandlersCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); try { await foreach (var message in _transport.MessageReader.ReadAllAsync(cancellationToken).ConfigureAwait(false)) { LogMessageRead(EndpointName, message.GetType().Name); Interlocked.Increment(ref inFlightCount); // Fire and forget the message handling to avoid blocking the transport. if (message.Context?.ExecutionContext is null) { _ = ProcessMessageAsync(); } else { // Flow the execution context from the HTTP request corresponding to this message if provided. ExecutionContext.Run(message.Context.ExecutionContext, _ => _ = ProcessMessageAsync(), null); } async Task ProcessMessageAsync() { JsonRpcMessageWithId? messageWithId = message as JsonRpcMessageWithId; CancellationTokenSource? combinedCts = null; try { // Register before we yield, so that the tracking is guaranteed to be there // when subsequent messages arrive, even if the asynchronous processing happens // out of order. Per spec, "The initialize request MUST NOT be cancelled by clients", // so we don't track it in _handlingRequests to prevent cancellation notifications from // canceling it. if (messageWithId is not null) { combinedCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); if (message is not JsonRpcRequest { Method: RequestMethods.Initialize }) { _handlingRequests[messageWithId.Id] = combinedCts; } } // If we await the handler without yielding first, the transport may not be able to read more messages, // which could lead to a deadlock if the handler sends a message back. #if NET await Task.CompletedTask.ConfigureAwait(ConfigureAwaitOptions.ForceYielding); #else await default(ForceYielding); #endif // Handle the message. await HandleMessageAsync(message, combinedCts?.Token ?? cancellationToken).ConfigureAwait(false); } catch (Exception ex) { // Only send responses for request errors that aren't user-initiated cancellation. bool isUserCancellation = ex is OperationCanceledException && !cancellationToken.IsCancellationRequested && combinedCts?.IsCancellationRequested is true; if (!isUserCancellation && message is JsonRpcRequest request) { JsonRpcErrorDetail detail = ex switch { UrlElicitationRequiredException urlException => new() { Code = (int)urlException.ErrorCode, Message = urlException.Message, Data = urlException.CreateErrorDataNode(), }, UnsupportedProtocolVersionException upvException => new() { Code = (int)upvException.ErrorCode, Message = upvException.Message, Data = upvException.CreateErrorDataNode(), }, MissingRequiredClientCapabilityException mrccException => new() { Code = (int)mrccException.ErrorCode, Message = mrccException.Message, Data = mrccException.CreateErrorDataNode(), }, McpProtocolException mcpProtocolException => new() { Code = (int)mcpProtocolException.ErrorCode, Message = mcpProtocolException.Message, Data = ConvertExceptionData(mcpProtocolException.Data), }, McpException mcpException => new() { Code = (int)McpErrorCode.InternalError, Message = mcpException.Message, }, _ => new() { Code = (int)McpErrorCode.InternalError, Message = "An error occurred.", }, }; var errorMessage = new JsonRpcError { Id = request.Id, JsonRpc = "2.0", Error = detail, Context = new JsonRpcMessageContext { RelatedTransport = request.Context?.RelatedTransport }, }; await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false); } else if (ex is not OperationCanceledException) { if (_logger.IsEnabled(LogLevel.Trace)) { LogMessageHandlerExceptionSensitive(EndpointName, message.GetType().Name, JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage), ex); } else { LogMessageHandlerException(EndpointName, message.GetType().Name, ex); } } } finally { if (messageWithId is not null) { _handlingRequests.TryRemove(messageWithId.Id, out _); combinedCts!.Dispose(); } if (Interlocked.Decrement(ref inFlightCount) == 0) { allHandlersCompleted.TrySetResult(true); } } } } } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { // Normal shutdown LogEndpointMessageProcessingCanceled(EndpointName); } finally { // Decrement our own count. If all handlers have already completed, this will signal completion. if (Interlocked.Decrement(ref inFlightCount) != 0) { await allHandlersCompleted.Task.ConfigureAwait(false); } // Fail any pending requests, as they'll never be satisfied. // If the transport's channel was completed with a ClientTransportClosedException, // propagate it so callers can access the structured completion details. Exception pendingException = _transport.MessageReader.Completion is { IsCompleted: true, IsFaulted: true } completion && completion.Exception?.InnerException is { } innerException ? innerException : new IOException("The server shut down unexpectedly."); foreach (var entry in _pendingRequests) { entry.Value.TrySetException(pendingException); } } } /// /// Resolves from the transport's channel completion. /// If the channel was completed with a , the wrapped /// details are returned. Otherwise a default instance is created from the completion state. /// private static async Task GetCompletionDetailsAsync(Task channelCompletion) { try { await channelCompletion.ConfigureAwait(false); return new ClientCompletionDetails(); } catch (ClientTransportClosedException tce) { return tce.Details; } catch (Exception ex) { return new ClientCompletionDetails { Exception = ex }; } } private async Task HandleMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken) { // Project the 2026-07-28 protocol's per-request _meta fields onto the message context before any // filters run so they (and downstream handlers) can read client info / capabilities / // protocol version / log level without re-parsing. if (_isServer && message is JsonRpcRequest incomingRequest) { PopulateContextFromMeta(incomingRequest); } Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; string method = GetMethodName(message); long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null; Activity? activity = null; string? target = null; if (Diagnostics.ShouldInstrumentMessage(message)) { target = ExtractTargetFromMessage(message, method); activity = Diagnostics.ActivitySource.StartActivity( CreateActivityName(method, target), ActivityKind.Server, parentContext: _propagator.ExtractActivityContext(message), links: Diagnostics.ActivityLinkFromCurrent()); } TagList tags = default; bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null; try { if (addTags) { AddTags(ref tags, activity, message, method, target); } await _incomingMessageFilter(async (msg, ct) => { var result = await HandleMessageCoreAsync(msg, ct).ConfigureAwait(false); if (addTags && result is not null) { AddResponseTags(ref tags, activity, result, method); } })(message, cancellationToken).ConfigureAwait(false); } catch (Exception e) when (addTags) { AddExceptionTags(ref tags, activity, e); throw; } finally { FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags); } } private async Task HandleMessageCoreAsync(JsonRpcMessage message, CancellationToken cancellationToken) { switch (message) { case JsonRpcRequest request: LogRequestHandlerCalled(EndpointName, request.Method); long requestStartingTimestamp = Stopwatch.GetTimestamp(); try { var result = await HandleRequestAsync(request, cancellationToken).ConfigureAwait(false); LogRequestHandlerCompleted(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds); return result; } catch (Exception ex) { LogRequestHandlerException(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds, ex); throw; } case JsonRpcNotification notification: await HandleNotificationAsync(notification, cancellationToken).ConfigureAwait(false); return null; case JsonRpcMessageWithId messageWithId: HandleMessageWithId(message, messageWithId); return null; default: LogEndpointHandlerUnexpectedMessageType(EndpointName, message.GetType().Name); return null; } } private async Task HandleNotificationAsync(JsonRpcNotification notification, CancellationToken cancellationToken) { // Special-case cancellation to cancel a pending operation. (We'll still subsequently invoke a user-specified handler if one exists.) if (notification.Method == NotificationMethods.CancelledNotification) { try { if (GetCancelledNotificationParams(notification.Params) is CancelledNotificationParams cn && _handlingRequests.TryGetValue(cn.RequestId, out var cts)) { await cts.CancelAsync().ConfigureAwait(false); LogRequestCanceled(EndpointName, cn.RequestId, cn.Reason); } } catch { // "Invalid cancellation notifications SHOULD be ignored" } } // Handle user-defined notifications. await _notificationHandlers.InvokeHandlers(notification.Method, notification, cancellationToken).ConfigureAwait(false); } private void HandleMessageWithId(JsonRpcMessage message, JsonRpcMessageWithId messageWithId) { if (_pendingRequests.TryRemove(messageWithId.Id, out var tcs)) { tcs.TrySetResult(message); } else { LogNoRequestFoundForMessageWithId(EndpointName, messageWithId.Id); } } private async Task HandleRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) { if (!_requestHandlers.TryGetValue(request.Method, out var handler)) { LogNoHandlerFoundForRequest(EndpointName, request.Method); throw new McpProtocolException($"Method '{request.Method}' is not available.", McpErrorCode.MethodNotFound); } JsonNode? result = await handler(request, cancellationToken).ConfigureAwait(false); await SendMessageAsync(new JsonRpcResponse { Id = request.Id, Result = result, Context = request.Context, }, cancellationToken).ConfigureAwait(false); return result; } /// /// Reads the 2026-07-28 protocol's per-request _meta fields off the request and projects them onto /// so they're available without re-parsing throughout the pipeline. /// /// /// Per SEP-2575 the keys are io.modelcontextprotocol/protocolVersion, /// /clientInfo, /clientCapabilities, and (optional) /logLevel. Any field /// that's already set on the context (e.g., /// populated by the HTTP transport from the MCP-Protocol-Version header) is left alone /// unless explicitly overwritten by a non-null value parsed here. /// internal static void PopulateContextFromMeta(JsonRpcRequest request) { if (request.Params is not JsonObject paramsObj) { return; } if (paramsObj["_meta"] is not JsonObject metaObj) { return; } var context = request.Context ??= new JsonRpcMessageContext(); if (metaObj[MetaKeys.ProtocolVersion] is JsonValue protocolVersion && protocolVersion.TryGetValue(out string? protocolVersionValue)) { // If a transport-level header (e.g., the Streamable HTTP MCP-Protocol-Version header) already // populated this, validate the body _meta matches per SEP-2575. A disagreement is reported with // -32020 HeaderMismatch (the same code used for the Mcp-Method/Mcp-Name header-vs-body checks), // which conformant 2026-07-28 clients recognize as a SEP-2575 signal and surface as-is rather // than mistaking it for an initialize-handshake server and falling back to initialize. if (context.ProtocolVersion is { } existing && !string.Equals(existing, protocolVersionValue, StringComparison.Ordinal)) { throw new McpProtocolException( $"Header mismatch: the per-request _meta protocol version '{protocolVersionValue}' does not match the MCP-Protocol-Version header value '{existing}'.", McpErrorCode.HeaderMismatch); } context.ProtocolVersion = protocolVersionValue; } if (metaObj[MetaKeys.ClientInfo] is JsonNode clientInfoNode) { context.ClientInfo = JsonSerializer.Deserialize(clientInfoNode, McpJsonUtilities.JsonContext.Default.Implementation); } if (metaObj[MetaKeys.ClientCapabilities] is JsonNode clientCapabilitiesNode) { context.ClientCapabilities = JsonSerializer.Deserialize(clientCapabilitiesNode, McpJsonUtilities.JsonContext.Default.ClientCapabilities); } if (metaObj[MetaKeys.LogLevel] is JsonNode logLevelNode) { context.LogLevel = JsonSerializer.Deserialize(logLevelNode, McpJsonUtilities.JsonContext.Default.LoggingLevel); } } /// /// Injects the 2026-07-28 protocol's per-request _meta fields into an outgoing request. /// Protocol version and client info overwrite any existing values; client capabilities are merged /// so per-request capability opt-ins already present in the envelope are preserved. /// /// /// Used by on a 2026-07-28 or later session to carry protocol version, client /// info, and client capabilities on every outgoing request (replacing what the /// initialize handshake previously negotiated once). /// internal static void InjectRequestMeta( JsonRpcRequest request, string protocolVersion, Implementation clientInfo, ClientCapabilities clientCapabilities, LoggingLevel? logLevel = null) { var paramsObj = request.Params as JsonObject; if (paramsObj is null) { paramsObj = new JsonObject(); request.Params = paramsObj; } if (paramsObj["_meta"] is not JsonObject metaObj) { metaObj = new JsonObject(); paramsObj["_meta"] = metaObj; } metaObj[MetaKeys.ProtocolVersion] = protocolVersion; metaObj[MetaKeys.ClientInfo] = JsonSerializer.SerializeToNode(clientInfo, McpJsonUtilities.JsonContext.Default.Implementation); // Overlay the session-level standard capabilities onto whatever the request already carried // in _meta.clientCapabilities. A caller higher up the pipeline (e.g. CallToolRawAsync via // GetMetaWithTaskCapability) may have already written per-request capability opt-ins such as // extensions/io.modelcontextprotocol/tasks. Blindly overwriting the node would drop those // additions, so merge instead: set the standard capability fields from the session // capabilities while preserving any extra keys (extensions) the request envelope already had. var serializedCapabilities = (JsonObject)JsonSerializer.SerializeToNode(clientCapabilities, McpJsonUtilities.JsonContext.Default.ClientCapabilities)!; if (metaObj[MetaKeys.ClientCapabilities] is JsonObject existingCapabilities) { foreach (var property in serializedCapabilities.ToArray()) { existingCapabilities[property.Key] = property.Value?.DeepClone(); } } else { metaObj[MetaKeys.ClientCapabilities] = serializedCapabilities; } if (logLevel is { } level) { metaObj[MetaKeys.LogLevel] = JsonSerializer.SerializeToNode(level, McpJsonUtilities.JsonContext.Default.LoggingLevel); } else { metaObj.Remove(MetaKeys.LogLevel); } } private CancellationTokenRegistration RegisterCancellation(CancellationToken cancellationToken, JsonRpcRequest request) { if (!cancellationToken.CanBeCanceled) { return default; } return cancellationToken.Register(static objState => { var state = (Tuple)objState!; _ = state.Item1.SendMessageAsync(new JsonRpcNotification { Method = NotificationMethods.CancelledNotification, Params = JsonSerializer.SerializeToNode(new CancelledNotificationParams { RequestId = state.Item2.Id }, McpJsonUtilities.JsonContext.Default.CancelledNotificationParams), Context = new JsonRpcMessageContext { RelatedTransport = state.Item2.Context?.RelatedTransport }, }); }, Tuple.Create(this, request)); } public IAsyncDisposable RegisterNotificationHandler(string method, Func handler) { Throw.IfNullOrWhiteSpace(method); Throw.IfNull(handler); return _notificationHandlers.Register(method, handler); } /// /// Sends a JSON-RPC request to the server. /// It is strongly recommended to use the capability-specific methods instead of this one. /// Use this method for custom requests or those not yet covered explicitly by the endpoint implementation. /// /// The JSON-RPC request to send. /// The to monitor for cancellation requests. The default is . /// A task containing the server's response. public async Task SendRequestAsync(JsonRpcRequest request, CancellationToken cancellationToken) { Throw.IfNull(request); cancellationToken.ThrowIfCancellationRequested(); Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; string method = request.Method; long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null; // If outer GenAI instrumentation is already tracing the tool execution, // add MCP attributes to that activity instead of creating a new one. Activity? activity = null; bool usingOuterActivity = false; string? target = null; if (Diagnostics.ShouldInstrumentMessage(request)) { target = ExtractTargetFromMessage(request, method); if (method == RequestMethods.ToolsCall && Diagnostics.TryGetOuterToolExecutionActivity(out var outerActivity)) { activity = outerActivity; usingOuterActivity = true; } else { activity = Diagnostics.ActivitySource.StartActivity(CreateActivityName(method, target), ActivityKind.Client); } } // Set request ID if (request.Id.Id is null) { request = request.WithId(new RequestId(Interlocked.Increment(ref _lastRequestId))); } _propagator.InjectActivityContext(activity, request); TagList tags = default; bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null; var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); _pendingRequests[request.Id] = tcs; try { if (addTags) { AddTags(ref tags, activity, request, method, target); } await SendToRelatedTransportAsync(request, cancellationToken).ConfigureAwait(false); // Now that the request has been sent, register for cancellation. If we registered before, // a cancellation request could arrive before the server knew about that request ID, in which // case the server could ignore it. // Per spec, "The initialize request MUST NOT be cancelled by clients", so skip registration for initialize. LogRequestSentAwaitingResponse(EndpointName, request.Method, request.Id); JsonRpcMessage? response; using (var registration = method != RequestMethods.Initialize ? RegisterCancellation(cancellationToken, request) : default) { response = await tcs.Task.WaitAsync(cancellationToken).ConfigureAwait(false); } if (response is JsonRpcError error) { LogSendingRequestFailed(EndpointName, request.Method, error.Error.Message, error.Error.Code); throw CreateRemoteProtocolException(error); } if (response is JsonRpcResponse success) { if (addTags) { AddResponseTags(ref tags, activity, success.Result, method); } if (_logger.IsEnabled(LogLevel.Trace)) { LogRequestResponseReceivedSensitive(EndpointName, request.Method, success.Result?.ToJsonString() ?? "null"); } else { LogRequestResponseReceived(EndpointName, request.Method); } return success; } // Unexpected response type LogSendingRequestInvalidResponseType(EndpointName, request.Method); throw new McpException("Invalid response type"); } catch (Exception ex) when (addTags) { AddExceptionTags(ref tags, activity, ex); throw; } finally { _pendingRequests.TryRemove(request.Id, out _); FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags, disposeActivity: !usingOuterActivity); } } public async Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default) { Throw.IfNull(message); if (message is JsonRpcRequest request) { throw new InvalidOperationException( $"Cannot send '{request.Method}' request via {nameof(SendMessageAsync)}. " + $"Use {nameof(SendRequestAsync)} instead to get a correlated response."); } cancellationToken.ThrowIfCancellationRequested(); Histogram durationMetric = _isServer ? s_serverOperationDuration : s_clientOperationDuration; string method = GetMethodName(message); long? startingTimestamp = durationMetric.Enabled ? Stopwatch.GetTimestamp() : null; Activity? activity = null; string? target = null; if (Diagnostics.ShouldInstrumentMessage(message)) { target = ExtractTargetFromMessage(message, method); activity = Diagnostics.ActivitySource.StartActivity(CreateActivityName(method, target), ActivityKind.Client); } TagList tags = default; bool addTags = activity is { IsAllDataRequested: true } || startingTimestamp is not null; // propagate trace context _propagator?.InjectActivityContext(activity, message); try { if (addTags) { AddTags(ref tags, activity, message, method, target); } await SendToRelatedTransportAsync(message, cancellationToken).ConfigureAwait(false); // If the sent notification was a cancellation notification, cancel the pending request's await, as either the // server won't be sending a response, or per the specification, the response should be ignored. There are inherent // race conditions here, so it's possible and allowed for the operation to complete before we get to this point. if (message is JsonRpcNotification { Method: NotificationMethods.CancelledNotification } notification && GetCancelledNotificationParams(notification.Params) is CancelledNotificationParams cn && _pendingRequests.TryRemove(cn.RequestId, out var tcs)) { tcs.TrySetCanceled(default); } } catch (Exception ex) when (addTags) { AddExceptionTags(ref tags, activity, ex); throw; } finally { FinalizeDiagnostics(activity, startingTimestamp, durationMetric, ref tags); } } // The JsonRpcMessage should be sent over the RelatedTransport if set. This is used to support the // Streamable HTTP transport where the specification states that the server SHOULD include JSON-RPC responses in // the HTTP response body for the POST request containing the corresponding JSON-RPC request. private Task SendToRelatedTransportAsync(JsonRpcMessage message, CancellationToken cancellationToken) => _outgoingMessageFilter((msg, ct) => { if (msg is JsonRpcRequest request) { if (_logger.IsEnabled(LogLevel.Trace)) { LogSendingRequestSensitive(EndpointName, request.Method, JsonSerializer.Serialize(msg, McpJsonUtilities.JsonContext.Default.JsonRpcMessage)); } else { LogSendingRequest(EndpointName, request.Method); } } else { if (_logger.IsEnabled(LogLevel.Trace)) { LogSendingMessageSensitive(EndpointName, JsonSerializer.Serialize(msg, McpJsonUtilities.JsonContext.Default.JsonRpcMessage)); } else { LogSendingMessage(EndpointName); } } return (msg.Context?.RelatedTransport ?? _transport).SendMessageAsync(msg, ct); })(message, cancellationToken); private static CancelledNotificationParams? GetCancelledNotificationParams(JsonNode? notificationParams) { try { return JsonSerializer.Deserialize(notificationParams, McpJsonUtilities.JsonContext.Default.CancelledNotificationParams); } catch { return null; } } private static string CreateActivityName(string method) => method; /// /// Creates a span name according to semantic conventions: "{mcp.method.name} {target}" where /// target is the tool name, prompt name, or resource URI when applicable. /// private static string CreateActivityName(string method, string? target) => target is null ? method : $"{method} {target}"; /// /// Extracts the target (tool name, prompt name, or resource URI) from a message for use in span naming. /// private static string? ExtractTargetFromMessage(JsonRpcMessage message, string method) { JsonObject? paramsObj = message switch { JsonRpcRequest request => request.Params as JsonObject, JsonRpcNotification notification => notification.Params as JsonObject, _ => null }; if (paramsObj is null) { return null; } return method switch { RequestMethods.ToolsCall or RequestMethods.PromptsGet => GetStringProperty(paramsObj, "name"), // Note: resource URI is not included in span name by default due to high cardinality per semantic conventions _ => null }; } private static string GetMethodName(JsonRpcMessage message) => message switch { JsonRpcRequest request => request.Method, JsonRpcNotification notification => notification.Method, _ => "unknownMethod" }; private void AddTags(ref TagList tags, Activity? activity, JsonRpcMessage message, string method, string? target) { tags.Add("mcp.method.name", method); tags.Add("network.transport", _transportKind); if (_transportKind is "tcp") { tags.Add("network.protocol.name", "http"); } if (NegotiatedProtocolVersion is not null) { tags.Add("mcp.protocol.version", NegotiatedProtocolVersion); } if (activity is { IsAllDataRequested: true }) { activity.AddTag("mcp.session.id", _sessionId); if (message is JsonRpcMessageWithId withId) { activity.AddTag("jsonrpc.request.id", withId.Id.Id?.ToString()); } } switch (method) { case RequestMethods.ToolsCall: if (target is not null) { tags.Add("gen_ai.tool.name", target); tags.Add("gen_ai.operation.name", "execute_tool"); } break; case RequestMethods.PromptsGet: if (target is not null) { tags.Add("gen_ai.prompt.name", target); } break; case RequestMethods.ResourcesRead: case RequestMethods.ResourcesSubscribe: case RequestMethods.ResourcesUnsubscribe: case NotificationMethods.ResourceUpdatedNotification: { JsonObject? paramsObj = message switch { JsonRpcRequest request => request.Params as JsonObject, JsonRpcNotification notification => notification.Params as JsonObject, _ => null }; string? uri = paramsObj is not null ? GetStringProperty(paramsObj, "uri") : null; if (uri is not null) { tags.Add("mcp.resource.uri", uri); } } break; } } private static void AddExceptionTags(ref TagList tags, Activity? activity, Exception e) { if (e is AggregateException ae && ae.InnerException is not null and not AggregateException) { e = ae.InnerException; } int? intErrorCode = (int?)((e as McpProtocolException)?.ErrorCode) is int errorCode ? errorCode : e is JsonException ? (int)McpErrorCode.ParseError : null; string? errorType = intErrorCode?.ToString() ?? e.GetType().FullName; tags.Add("error.type", errorType); if (intErrorCode is not null) { tags.Add("rpc.response.status_code", errorType); } if (activity is { IsAllDataRequested: true }) { activity.SetStatus(ActivityStatusCode.Error, e.Message); } } private static void AddResponseTags(ref TagList tags, Activity? activity, JsonNode? response, string method) { if (response is JsonObject jsonObject && jsonObject.TryGetPropertyValue("isError", out var isError) && isError?.GetValueKind() == JsonValueKind.True) { if (activity is { IsAllDataRequested: true }) { string? content = null; if (jsonObject.TryGetPropertyValue("content", out var prop) && prop != null) { content = prop.ToJsonString(); } activity.SetStatus(ActivityStatusCode.Error, content); } tags.Add("error.type", method == RequestMethods.ToolsCall ? "tool_error" : "_OTHER"); } } private static void FinalizeDiagnostics( Activity? activity, long? startingTimestamp, Histogram durationMetric, ref TagList tags, bool disposeActivity = true) { try { if (startingTimestamp is not null) { durationMetric.Record(GetElapsed(startingTimestamp.Value).TotalSeconds, tags); } if (activity is { IsAllDataRequested: true }) { foreach (var tag in tags) { activity.AddTag(tag.Key, tag.Value); } } } finally { // Only dispose the activity if we created it (not when reusing an outer GenAI activity) if (disposeActivity) { activity?.Dispose(); } } } public async ValueTask DisposeAsync() { Histogram durationMetric = _isServer ? s_serverSessionDuration : s_clientSessionDuration; if (durationMetric.Enabled) { TagList tags = default; tags.Add("network.transport", _transportKind); // TODO: Add server.address and server.port on client-side when using SSE transport, // client.* attributes are not added to metrics because of cardinality durationMetric.Record(GetElapsed(_sessionStartingTimestamp).TotalSeconds, tags); } foreach (var entry in _pendingRequests) { entry.Value.TrySetCanceled(); } _pendingRequests.Clear(); if (_messageProcessingCts is not null) { await _messageProcessingCts.CancelAsync().ConfigureAwait(false); } if (_messageProcessingTask is not null) { try { await _messageProcessingTask.ConfigureAwait(false); } catch { // Ignore exceptions from the message processing loop. It may fault with // OperationCanceledException on normal shutdown or ClientTransportClosedException // when the transport's channel completes with an error. } } LogSessionDisposed(EndpointName, _sessionId, _transportKind); } #if !NET private static readonly double s_timestampToTicks = TimeSpan.TicksPerSecond / (double)Stopwatch.Frequency; #endif private static TimeSpan GetElapsed(long startingTimestamp) => #if NET Stopwatch.GetElapsedTime(startingTimestamp); #else new((long)(s_timestampToTicks * (Stopwatch.GetTimestamp() - startingTimestamp))); #endif private static string? GetStringProperty(JsonObject parameters, string propName) { if (parameters.TryGetPropertyValue(propName, out var prop) && prop?.GetValueKind() is JsonValueKind.String) { return prop.GetValue(); } return null; } private static McpProtocolException CreateRemoteProtocolException(JsonRpcError error) => CreateRemoteProtocolExceptionFromError(error); /// /// Creates a typed from a JSON-RPC error response. /// /// /// Exposed internally so transports that surface an HTTP-level error containing a JSON-RPC error /// body (e.g., a 400 with ) can convert /// the error to the same typed exception that JSON-RPC-level error responses produce. /// internal static McpProtocolException CreateRemoteProtocolExceptionFromError(JsonRpcError error) { string formattedMessage = $"Request failed (remote): {error.Error.Message}"; var errorCode = (McpErrorCode)error.Error.Code; McpProtocolException exception; if (errorCode == McpErrorCode.UrlElicitationRequired && UrlElicitationRequiredException.TryCreateFromError(formattedMessage, error.Error, out var urlException)) { exception = urlException; } else if (errorCode == McpErrorCode.UnsupportedProtocolVersion && UnsupportedProtocolVersionException.TryCreateFromError(formattedMessage, error.Error, out var upvException)) { exception = upvException; } else if (errorCode == McpErrorCode.MissingRequiredClientCapability && MissingRequiredClientCapabilityException.TryCreateFromError(formattedMessage, error.Error, out var mrccException)) { exception = mrccException; } else { exception = new McpProtocolException(formattedMessage, errorCode); } // Populate exception.Data with the error data if present. // When deserializing JSON, Data will be a JsonElement. // We extract primitive values (strings, numbers, bools) for broader compatibility, // as JsonElement is not [Serializable] and cannot be stored in Exception.Data on .NET Framework. if (error.Error.Data is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object) { foreach (var property in jsonElement.EnumerateObject()) { object? value = property.Value.ValueKind switch { JsonValueKind.String => property.Value.GetString(), JsonValueKind.Number => property.Value.GetDouble(), JsonValueKind.True => true, JsonValueKind.False => false, JsonValueKind.Null => null, #if NET // Objects and arrays are stored as JsonElement on .NET Core only _ => property.Value, #else // Skip objects/arrays on .NET Framework as JsonElement is not serializable _ => (object?)null, #endif }; if (value is not null || property.Value.ValueKind == JsonValueKind.Null) { exception.Data[property.Name] = value; } } } return exception; } /// /// Converts the dictionary to a serializable . /// Returns null if the data dictionary is empty or contains no string keys with serializable values. /// /// /// /// Only entries with string keys are included in the result. Entries with non-string keys are ignored. /// /// /// Each value is serialized to a to ensure it can be safely included in the /// JSON-RPC error response. Values that cannot be serialized are silently skipped. /// /// private static Dictionary? ConvertExceptionData(System.Collections.IDictionary data) { if (data.Count == 0) { return null; } var typeInfo = McpJsonUtilities.DefaultOptions.GetTypeInfo(); Dictionary? result = null; foreach (System.Collections.DictionaryEntry entry in data) { if (entry.Key is string key) { try { // Serialize each value upfront to catch any serialization issues // before attempting to send the message. If the value is already a // JsonElement, use it directly. var element = entry.Value is JsonElement je ? je : JsonSerializer.SerializeToElement(entry.Value, typeInfo); result ??= new(data.Count); result[key] = element; } catch (Exception ex) when (ex is JsonException or NotSupportedException) { // Skip non-serializable values silently } } } return result?.Count > 0 ? result : null; } [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} message processing canceled.")] private partial void LogEndpointMessageProcessingCanceled(string endpointName); [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} method '{Method}' request handler called.")] private partial void LogRequestHandlerCalled(string endpointName, string method); [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} method '{Method}' request handler completed in {ElapsedMilliseconds}ms.")] private partial void LogRequestHandlerCompleted(string endpointName, string method, double elapsedMilliseconds); [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} method '{Method}' request handler failed in {ElapsedMilliseconds}ms.")] private partial void LogRequestHandlerException(string endpointName, string method, double elapsedMilliseconds, Exception exception); [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received request for unknown request ID '{RequestId}'.")] private partial void LogNoRequestFoundForMessageWithId(string endpointName, RequestId requestId); [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} request failed for method '{Method}': {ErrorMessage} ({ErrorCode}).")] private partial void LogSendingRequestFailed(string endpointName, string method, string errorMessage, int errorCode); [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received invalid response for method '{Method}'.")] private partial void LogSendingRequestInvalidResponseType(string endpointName, string method); [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} sending method '{Method}' request.")] private partial void LogSendingRequest(string endpointName, string method); [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} sending method '{Method}' request. Request: '{Request}'.")] private partial void LogSendingRequestSensitive(string endpointName, string method, string request); [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} canceled request '{RequestId}' per client notification. Reason: '{Reason}'.")] private partial void LogRequestCanceled(string endpointName, RequestId requestId, string? reason); [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} Request response received for method {method}")] private partial void LogRequestResponseReceived(string endpointName, string method); [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} Request response received for method {method}. Response: '{Response}'.")] private partial void LogRequestResponseReceivedSensitive(string endpointName, string method, string response); [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} read {MessageType} message from channel.")] private partial void LogMessageRead(string endpointName, string messageType); [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} message handler {MessageType} failed.")] private partial void LogMessageHandlerException(string endpointName, string messageType, Exception exception); [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} message handler {MessageType} failed. Message: '{Message}'.")] private partial void LogMessageHandlerExceptionSensitive(string endpointName, string messageType, string message, Exception exception); [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received unexpected {MessageType} message type.")] private partial void LogEndpointHandlerUnexpectedMessageType(string endpointName, string messageType); [LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received request for method '{Method}', but no handler is available.")] private partial void LogNoHandlerFoundForRequest(string endpointName, string method); [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} waiting for response to request '{RequestId}' for method '{Method}'.")] private partial void LogRequestSentAwaitingResponse(string endpointName, string method, RequestId requestId); [LoggerMessage(Level = LogLevel.Debug, Message = "{EndpointName} sending message.")] private partial void LogSendingMessage(string endpointName); [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} sending message. Message: '{Message}'.")] private partial void LogSendingMessageSensitive(string endpointName, string message); [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} session {SessionId} created with transport {TransportKind}")] private partial void LogSessionCreated(string endpointName, string sessionId, string transportKind); [LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} session {SessionId} disposed with transport {TransportKind}")] private partial void LogSessionDisposed(string endpointName, string sessionId, string transportKind); }