diff --git a/Directory.Packages.props b/Directory.Packages.props
index d204d871f..bfc9522a7 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -13,6 +13,7 @@
+
diff --git a/docs/concepts/logging/logging.md b/docs/concepts/logging/logging.md
index 2b381aa54..1fff75a50 100644
--- a/docs/concepts/logging/logging.md
+++ b/docs/concepts/logging/logging.md
@@ -85,3 +85,42 @@ Lastly, the client must configure a notification handler for , which most logging providers render as the exception message plus its stack trace.
+That output can contain sensitive or overly detailed runtime data.
+
+Set to log a sanitized description instead.
+When it is set, the failure paths log only the string the delegate returns, and the raw exception is not attached to
+the log entry. The default is `null`, which preserves the existing behavior of logging the raw exception.
+
+```csharp
+builder.Services.AddMcpServer(options =>
+{
+ options.ExceptionSummarizer = ex => ex.GetType().Name;
+});
+```
+
+The summarized and raw forms of each event share one `EventId`, so filters and alerts keyed on `EventId` behave the
+same whether or not a summarizer is configured. The delegate runs only when the corresponding log level is enabled,
+so it costs nothing on a level that is filtered out.
+
+The `ModelContextProtocol` package also integrates with the standard
+[Microsoft.Extensions.Diagnostics.ExceptionSummarization](https://learn.microsoft.com/dotnet/api/microsoft.extensions.diagnostics.exceptionsummarization)
+abstractions. If an `IExceptionSummarizer` is registered in the container and `ExceptionSummarizer` has not been set
+explicitly, the SDK populates it with `"{ExceptionType}: {Description}"` taken from the `ExceptionSummary`:
+
+```csharp
+builder.Services.AddExceptionSummarizer(b => b.AddHttpProvider());
+builder.Services.AddMcpServer();
+```
+
+Both of those fields are documented as free of privacy-sensitive information. `ExceptionSummary.AdditionalDetails` is
+not, and `ExceptionSummary.ToString()` appends it, so neither is used. The exception type is included because
+`Description` on its own is `"Unknown"` for exception types that no registered provider handles.
+
+If the delegate throws or returns `null`, the SDK falls back to logging the raw exception, so a faulty summarizer
+can never fail the session.
diff --git a/src/ModelContextProtocol.Core/ExceptionSummaryHelper.cs b/src/ModelContextProtocol.Core/ExceptionSummaryHelper.cs
new file mode 100644
index 000000000..3f482c49f
--- /dev/null
+++ b/src/ModelContextProtocol.Core/ExceptionSummaryHelper.cs
@@ -0,0 +1,35 @@
+using System.Diagnostics.CodeAnalysis;
+
+namespace ModelContextProtocol;
+
+///
+/// Provides the single, shared entry point used by exception-logging callsites to apply a
+/// user-supplied exception summarizer.
+///
+internal static class ExceptionSummaryHelper
+{
+ ///
+ /// Attempts to produce a sanitized description of using .
+ /// Callers check that a summarizer is configured, and that the event's level is enabled, before calling this.
+ ///
+ ///
+ /// if a summary was produced and the caller should log it in place of
+ /// ; otherwise, , in which case the caller must log
+ /// exactly as it would have without a summarizer. The summarizer is supplied
+ /// by the host, so throwing or returning both fall back rather than disrupt the session.
+ ///
+ public static bool TrySummarize(Func summarizer, Exception exception, [NotNullWhen(true)] out string? summary)
+ {
+ try
+ {
+ summary = summarizer(exception);
+ return summary is not null;
+ }
+ catch
+ {
+ // A faulty summarizer must never fail logging; fall back to the raw exception.
+ summary = null;
+ return false;
+ }
+ }
+}
diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs
index 61a1872f2..155362e43 100644
--- a/src/ModelContextProtocol.Core/McpSessionHandler.cs
+++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs
@@ -95,6 +95,7 @@ internal static bool SupportsNaturalOutputSchemas(string? protocolVersion)
///
private readonly ConcurrentDictionary _handlingRequests = new();
private readonly ILogger _logger;
+ private readonly Func? _exceptionSummarizer;
// This _sessionId is solely used to identify the session in telemetry and logs.
private readonly string _sessionId = Guid.NewGuid().ToString("N");
@@ -115,6 +116,10 @@ internal static bool SupportsNaturalOutputSchemas(string? protocolVersion)
/// 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.
+ ///
+ /// An optional callback that produces a sanitized description of an exception. When non-,
+ /// exception logging callsites log that description instead of the raw .
+ ///
public McpSessionHandler(
bool isServer,
ITransport transport,
@@ -123,7 +128,8 @@ public McpSessionHandler(
NotificationHandlers notificationHandlers,
JsonRpcMessageFilter? incomingMessageFilter,
JsonRpcMessageFilter? outgoingMessageFilter,
- ILogger logger)
+ ILogger logger,
+ Func? exceptionSummarizer = null)
{
Throw.IfNull(transport);
@@ -144,6 +150,7 @@ public McpSessionHandler(
_incomingMessageFilter = incomingMessageFilter ?? (next => next);
_outgoingMessageFilter = outgoingMessageFilter ?? (next => next);
_logger = logger;
+ _exceptionSummarizer = exceptionSummarizer;
// 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
@@ -323,14 +330,7 @@ ex is OperationCanceledException &&
}
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);
- }
+ LogMessageHandlerFailure(message, ex);
}
}
finally
@@ -470,7 +470,7 @@ await _incomingMessageFilter(async (msg, ct) =>
}
catch (Exception ex)
{
- LogRequestHandlerException(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds, ex);
+ LogRequestHandlerFailure(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds, ex);
throw;
}
@@ -1280,9 +1280,68 @@ internal static McpProtocolException CreateRemoteProtocolExceptionFromError(Json
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} method '{Method}' request handler completed in {ElapsedMilliseconds}ms.")]
private partial void LogRequestHandlerCompleted(string endpointName, string method, double elapsedMilliseconds);
+ ///
+ /// Logs a failed request handler, substituting a sanitized description for the raw exception when a
+ /// summarizer is configured. The summarizer only runs when the event's level is enabled, matching the
+ /// enabled-check the generated logging methods perform internally.
+ ///
+ private void LogRequestHandlerFailure(string endpointName, string method, double elapsedMilliseconds, Exception exception)
+ {
+ if (_exceptionSummarizer is not null &&
+ _logger.IsEnabled(LogLevel.Warning) &&
+ ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
+ {
+ LogRequestHandlerExceptionSummarized(endpointName, method, elapsedMilliseconds, exceptionSummary);
+ }
+ else
+ {
+ LogRequestHandlerException(endpointName, method, elapsedMilliseconds, exception);
+ }
+ }
+
+ ///
+ /// Logs a failed message handler. The trace-vs-warning selection is unchanged from the non-summarizing
+ /// path; only the payload differs. The summarizer runs only when the selected event's level is enabled.
+ ///
+ private void LogMessageHandlerFailure(JsonRpcMessage message, Exception exception)
+ {
+ string messageType = message.GetType().Name;
+
+ if (_logger.IsEnabled(LogLevel.Trace))
+ {
+ if (_exceptionSummarizer is not null &&
+ ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
+ {
+ LogMessageHandlerExceptionSensitiveSummarized(EndpointName, messageType, exceptionSummary, JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage));
+ }
+ else
+ {
+ LogMessageHandlerExceptionSensitive(EndpointName, messageType, JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage), exception);
+ }
+ }
+ else if (_exceptionSummarizer is not null &&
+ _logger.IsEnabled(LogLevel.Warning) &&
+ ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
+ {
+ LogMessageHandlerExceptionSummarized(EndpointName, messageType, exceptionSummary);
+ }
+ else
+ {
+ LogMessageHandlerException(EndpointName, messageType, exception);
+ }
+ }
+
+ // Each summarized variant names its raw counterpart as its EventName so the pair emits one EventId and
+ // one event name, keeping consumers that filter on EventId working when a summarizer is configured. The
+ // generator derives the numeric id from the event name, which defaults to the method name, so only the
+ // summarized variant declares EventName: declaring it on both trips SYSLIB1025.
+
[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.Warning, EventName = nameof(LogRequestHandlerException), Message = "{EndpointName} method '{Method}' request handler failed in {ElapsedMilliseconds}ms: {ExceptionSummary}.")]
+ private partial void LogRequestHandlerExceptionSummarized(string endpointName, string method, double elapsedMilliseconds, string exceptionSummary);
+
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received request for unknown request ID '{RequestId}'.")]
private partial void LogNoRequestFoundForMessageWithId(string endpointName, RequestId requestId);
@@ -1313,9 +1372,15 @@ internal static McpProtocolException CreateRemoteProtocolExceptionFromError(Json
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} message handler {MessageType} failed.")]
private partial void LogMessageHandlerException(string endpointName, string messageType, Exception exception);
+ [LoggerMessage(Level = LogLevel.Warning, EventName = nameof(LogMessageHandlerException), Message = "{EndpointName} message handler {MessageType} failed: {ExceptionSummary}.")]
+ private partial void LogMessageHandlerExceptionSummarized(string endpointName, string messageType, string exceptionSummary);
+
[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.Trace, EventName = nameof(LogMessageHandlerExceptionSensitive), Message = "{EndpointName} message handler {MessageType} failed: {ExceptionSummary}. Message: '{Message}'.")]
+ private partial void LogMessageHandlerExceptionSensitiveSummarized(string endpointName, string messageType, string exceptionSummary, string message);
+
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received unexpected {MessageType} message type.")]
private partial void LogEndpointHandlerUnexpectedMessageType(string endpointName, string messageType);
diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
index f68aacdff..6188318a7 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs
@@ -21,6 +21,7 @@ internal sealed partial class McpServerImpl : McpServer
};
private readonly ILogger _logger;
+ private readonly Func? _exceptionSummarizer;
private readonly ITransport _sessionTransport;
private readonly bool _servicesScopePerRequest;
private readonly List _disposables = [];
@@ -88,6 +89,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact
_endpointName = _serverOnlyEndpointName;
_servicesScopePerRequest = options.ScopeRequests;
_logger = loggerFactory?.CreateLogger() ?? NullLogger.Instance;
+ _exceptionSummarizer = options.ExceptionSummarizer;
_clientInfo = options.KnownClientInfo;
_clientCapabilities = options.KnownClientCapabilities;
@@ -161,7 +163,8 @@ void Register(McpServerPrimitiveCollection? collection,
_notificationHandlers,
incomingMessageFilter,
outgoingMessageFilter,
- _logger);
+ _logger,
+ _exceptionSummarizer);
}
///
@@ -1230,7 +1233,7 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure
}
catch (Exception e)
{
- ReadResourceError(request.Params?.Uri ?? string.Empty, e);
+ LogReadResourceError(request.Params?.Uri ?? string.Empty, e);
throw;
}
});
@@ -1344,7 +1347,7 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals
}
catch (Exception e)
{
- GetPromptError(request.Params?.Name ?? string.Empty, e);
+ LogGetPromptError(request.Params?.Name ?? string.Empty, e);
throw;
}
});
@@ -1597,7 +1600,7 @@ private McpRequestInvocationFilter handlerTask)
}
catch (Exception ex)
{
- MrtrHandlerError(ex);
+ LogMrtrHandlerError(ex);
}
finally
{
@@ -2407,21 +2410,95 @@ private async Task ObserveHandlerCompletionAsync(Task handlerTask)
}
}
+ // Each wrapper below gates the summarizer on the level declared by the event it replaces, so a
+ // host-supplied delegate never runs for a log entry that would be filtered out anyway. The generated
+ // logging methods perform the same enabled-check internally, so the raw path is unaffected.
+
+ private void LogToolCallError(string toolName, Exception exception)
+ {
+ if (_exceptionSummarizer is not null &&
+ _logger.IsEnabled(LogLevel.Error) &&
+ ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
+ {
+ ToolCallErrorSummarized(toolName, exceptionSummary);
+ }
+ else
+ {
+ ToolCallError(toolName, exception);
+ }
+ }
+
+ private void LogGetPromptError(string promptName, Exception exception)
+ {
+ if (_exceptionSummarizer is not null &&
+ _logger.IsEnabled(LogLevel.Error) &&
+ ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
+ {
+ GetPromptErrorSummarized(promptName, exceptionSummary);
+ }
+ else
+ {
+ GetPromptError(promptName, exception);
+ }
+ }
+
+ private void LogReadResourceError(string resourceUri, Exception exception)
+ {
+ if (_exceptionSummarizer is not null &&
+ _logger.IsEnabled(LogLevel.Error) &&
+ ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
+ {
+ ReadResourceErrorSummarized(resourceUri, exceptionSummary);
+ }
+ else
+ {
+ ReadResourceError(resourceUri, exception);
+ }
+ }
+
+ private void LogMrtrHandlerError(Exception exception)
+ {
+ if (_exceptionSummarizer is not null &&
+ _logger.IsEnabled(LogLevel.Debug) &&
+ ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
+ {
+ MrtrHandlerErrorSummarized(exceptionSummary);
+ }
+ else
+ {
+ MrtrHandlerError(exception);
+ }
+ }
+
+ // Each summarized variant names its raw counterpart as its EventName so the pair emits one EventId and
+ // one event name, keeping consumers that filter on EventId working when a summarizer is configured. The
+ // generator derives the numeric id from the event name, which defaults to the method name, so only the
+ // summarized variant declares EventName: declaring it on both trips SYSLIB1025.
+
[LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception.")]
private partial void ToolCallError(string toolName, Exception exception);
+ [LoggerMessage(Level = LogLevel.Error, EventName = nameof(ToolCallError), Message = "\"{ToolName}\" threw an unhandled exception: {ExceptionSummary}.")]
+ private partial void ToolCallErrorSummarized(string toolName, string exceptionSummary);
+
[LoggerMessage(Level = LogLevel.Information, Message = "\"{ToolName}\" completed. IsError = {IsError}.")]
private partial void ToolCallCompleted(string toolName, bool isError);
[LoggerMessage(Level = LogLevel.Error, Message = "GetPrompt \"{PromptName}\" threw an unhandled exception.")]
private partial void GetPromptError(string promptName, Exception exception);
+ [LoggerMessage(Level = LogLevel.Error, EventName = nameof(GetPromptError), Message = "GetPrompt \"{PromptName}\" threw an unhandled exception: {ExceptionSummary}.")]
+ private partial void GetPromptErrorSummarized(string promptName, string exceptionSummary);
+
[LoggerMessage(Level = LogLevel.Information, Message = "GetPrompt \"{PromptName}\" completed.")]
private partial void GetPromptCompleted(string promptName);
[LoggerMessage(Level = LogLevel.Error, Message = "ReadResource \"{ResourceUri}\" threw an unhandled exception.")]
private partial void ReadResourceError(string resourceUri, Exception exception);
+ [LoggerMessage(Level = LogLevel.Error, EventName = nameof(ReadResourceError), Message = "ReadResource \"{ResourceUri}\" threw an unhandled exception: {ExceptionSummary}.")]
+ private partial void ReadResourceErrorSummarized(string resourceUri, string exceptionSummary);
+
[LoggerMessage(Level = LogLevel.Information, Message = "ReadResource \"{ResourceUri}\" completed.")]
private partial void ReadResourceCompleted(string resourceUri);
@@ -2431,6 +2508,9 @@ private async Task ObserveHandlerCompletionAsync(Task handlerTask)
[LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")]
private partial void MrtrHandlerError(Exception exception);
+ [LoggerMessage(Level = LogLevel.Debug, EventName = nameof(MrtrHandlerError), Message = "An MRTR handler threw an unhandled exception: {ExceptionSummary}.")]
+ private partial void MrtrHandlerErrorSummarized(string exceptionSummary);
+
[LoggerMessage(Level = LogLevel.Debug, Message = "Failed to deliver \"{NotificationMethod}\" to subscription \"{SubscriptionId}\".")]
private partial void SubscriptionNotificationFailed(string notificationMethod, string subscriptionId, Exception exception);
}
diff --git a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
index 2a26868a1..7d0ee0dc3 100644
--- a/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
+++ b/src/ModelContextProtocol.Core/Server/McpServerOptions.cs
@@ -205,6 +205,37 @@ public McpServerFilters Filters
[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
public int MaxSamplingOutputTokens { get; set; } = 1000;
+ ///
+ /// Gets or sets an optional callback used to produce a sanitized description of an exception for logging.
+ ///
+ ///
+ /// A delegate that maps an to a short description to be logged in place of the
+ /// exception itself, or to log the raw . The default is
+ /// .
+ ///
+ ///
+ ///
+ /// By default, server-side failure paths pass the raw to ,
+ /// which most logging providers render as the exception message plus its stack trace. That output can include
+ /// sensitive or overly detailed runtime data. Setting this property opts into logging only the string this
+ /// delegate returns; the raw is not attached to those log entries.
+ ///
+ ///
+ /// When the ModelContextProtocol package is used, this property is populated automatically from an
+ /// IExceptionSummarizer registered in the dependency injection container (for example, via
+ /// services.AddExceptionSummarizer()) if it has not already been set.
+ ///
+ ///
+ /// The delegate is invoked only when the log level of the event being written is enabled, and the summarized
+ /// entry carries the same as the raw one it replaces.
+ ///
+ ///
+ /// If the delegate throws or returns , the raw is logged instead,
+ /// so a faulty summarizer can never fail the session.
+ ///
+ ///
+ public Func? ExceptionSummarizer { get; set; }
+
///
/// Gets or sets custom request handlers to register with the server.
///
diff --git a/src/ModelContextProtocol/McpServerOptionsSetup.cs b/src/ModelContextProtocol/McpServerOptionsSetup.cs
index c46854460..167e0d969 100644
--- a/src/ModelContextProtocol/McpServerOptionsSetup.cs
+++ b/src/ModelContextProtocol/McpServerOptionsSetup.cs
@@ -1,3 +1,5 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.ExceptionSummarization;
using Microsoft.Extensions.Options;
using ModelContextProtocol.Server;
@@ -9,10 +11,12 @@ namespace ModelContextProtocol;
/// The individually registered tools.
/// The individually registered prompts.
/// The individually registered resources.
+/// The application's service provider, used to resolve optional services.
internal sealed class McpServerOptionsSetup(
IEnumerable serverTools,
IEnumerable serverPrompts,
- IEnumerable serverResources) : IConfigureOptions
+ IEnumerable serverResources,
+ IServiceProvider services) : IConfigureOptions
{
///
/// Configures the given McpServerOptions instance by setting server information
@@ -67,5 +71,21 @@ public void Configure(McpServerOptions options)
{
options.ResourceCollection = resourceCollection;
}
+
+ // Default the summarizer from an optionally registered IExceptionSummarizer. An explicitly
+ // configured delegate always wins, whether it was set before this runs or by a later IConfigureOptions.
+ if (options.ExceptionSummarizer is null &&
+ services.GetService() is { } summarizer)
+ {
+ options.ExceptionSummarizer = ex =>
+ {
+ // ExceptionType and Description are both documented as free of privacy-sensitive information.
+ // AdditionalDetails is not, and ExceptionSummary.ToString() appends it, so neither is used here.
+ // Description alone is "Unknown" for exception types no provider handles, which would drop the
+ // type from the log entirely, so the type is prepended.
+ ExceptionSummary summary = summarizer.Summarize(ex);
+ return $"{summary.ExceptionType}: {summary.Description}";
+ };
+ }
}
}
diff --git a/src/ModelContextProtocol/ModelContextProtocol.csproj b/src/ModelContextProtocol/ModelContextProtocol.csproj
index 36c6a1736..bd2511093 100644
--- a/src/ModelContextProtocol/ModelContextProtocol.csproj
+++ b/src/ModelContextProtocol/ModelContextProtocol.csproj
@@ -37,6 +37,7 @@
+
diff --git a/tests/ModelContextProtocol.Tests/Server/ExceptionSummarizationTests.cs b/tests/ModelContextProtocol.Tests/Server/ExceptionSummarizationTests.cs
new file mode 100644
index 000000000..d1a5985f7
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Server/ExceptionSummarizationTests.cs
@@ -0,0 +1,410 @@
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Diagnostics.ExceptionSummarization;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Protocol;
+using ModelContextProtocol.Server;
+using ModelContextProtocol.Tests.Utils;
+
+namespace ModelContextProtocol.Tests.Server;
+
+///
+/// Covers : the opt-in hook that logs a sanitized
+/// description instead of the raw on server-side failure paths.
+///
+public class ExceptionSummarizationTests : LoggedTest
+{
+ private const string HandlerFailureMessage = "handler blew up with sensitive details";
+ private const string Summary = "sanitized-summary";
+
+ public ExceptionSummarizationTests(ITestOutputHelper testOutputHelper)
+ : base(testOutputHelper)
+ {
+ }
+
+ private static McpServerOptions CreateOptions() => new()
+ {
+ ProtocolVersion = "2024-11-05",
+ InitializationTimeout = TimeSpan.FromSeconds(30),
+ Capabilities = new ServerCapabilities { Tools = new() },
+ };
+
+ /// Records how many times it was invoked so tests can assert it never ran.
+ private sealed class CountingSummarizer
+ {
+ private int _invocations;
+
+ public int Invocations => Volatile.Read(ref _invocations);
+
+ public string Summarize(Exception exception)
+ {
+ Interlocked.Increment(ref _invocations);
+ return Summary;
+ }
+ }
+
+ #region McpSessionHandler.LogRequestHandlerException
+
+ [Fact]
+ public async Task RequestHandlerFailure_WithoutSummarizer_LogsRawException()
+ {
+ var options = CreateOptions();
+ options.Handlers.ListToolsHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+
+ var logs = await RunFailingListToolsRequestAsync(options);
+
+ var log = Assert.Single(logs.LogMessages, m => m.Message.Contains("request handler failed"));
+ Assert.Equal(LogLevel.Warning, log.LogLevel);
+ var exception = Assert.IsType(log.Exception);
+ Assert.Equal(HandlerFailureMessage, exception.Message);
+ Assert.DoesNotContain(Summary, log.Message);
+ }
+
+ [Fact]
+ public async Task RequestHandlerFailure_WithSummarizer_LogsSummaryAndNoException()
+ {
+ var options = CreateOptions();
+ options.Handlers.ListToolsHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ options.ExceptionSummarizer = ex => Summary;
+
+ var logs = await RunFailingListToolsRequestAsync(options);
+
+ var log = Assert.Single(logs.LogMessages, m => m.Message.Contains("request handler failed"));
+ Assert.Equal(LogLevel.Warning, log.LogLevel);
+ Assert.Contains(Summary, log.Message);
+ Assert.DoesNotContain(HandlerFailureMessage, log.Message);
+ Assert.Null(log.Exception);
+ }
+
+ [Fact]
+ public async Task RequestHandlerFailure_WithThrowingSummarizer_FallsBackToRawException()
+ {
+ var options = CreateOptions();
+ options.Handlers.ListToolsHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ options.ExceptionSummarizer = ex => throw new NotSupportedException("summarizer is broken");
+
+ var logs = await RunFailingListToolsRequestAsync(options);
+
+ var log = Assert.Single(logs.LogMessages, m => m.Message.Contains("request handler failed"));
+ Assert.Equal(LogLevel.Warning, log.LogLevel);
+ var exception = Assert.IsType(log.Exception);
+ Assert.Equal(HandlerFailureMessage, exception.Message);
+ Assert.DoesNotContain("summarizer is broken", log.Message);
+ }
+
+ [Fact]
+ public async Task RequestHandlerFailure_WithNullReturningSummarizer_FallsBackToRawException()
+ {
+ var options = CreateOptions();
+ options.Handlers.ListToolsHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ options.ExceptionSummarizer = ex => null!;
+
+ var logs = await RunFailingListToolsRequestAsync(options);
+
+ var log = Assert.Single(logs.LogMessages, m => m.Message.Contains("request handler failed"));
+ Assert.IsType(log.Exception);
+ }
+
+ #endregion
+
+ #region McpServerImpl.ToolCallError
+
+ [Fact]
+ public async Task ToolCallFailure_WithoutSummarizer_LogsRawException()
+ {
+ var options = CreateOptions();
+ options.Handlers.CallToolHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
+
+ var logs = await RunFailingCallToolRequestAsync(options);
+
+ var log = Assert.Single(logs.LogMessages, m => m.LogLevel == LogLevel.Error);
+ Assert.Equal("\"\" threw an unhandled exception.", log.Message);
+ var exception = Assert.IsType(log.Exception);
+ Assert.Equal(HandlerFailureMessage, exception.Message);
+ }
+
+ [Fact]
+ public async Task ToolCallFailure_WithSummarizer_LogsSummaryAndNoException()
+ {
+ var options = CreateOptions();
+ options.Handlers.CallToolHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
+ options.ExceptionSummarizer = ex => Summary;
+
+ var logs = await RunFailingCallToolRequestAsync(options);
+
+ var log = Assert.Single(logs.LogMessages, m => m.LogLevel == LogLevel.Error);
+ Assert.Equal($"\"\" threw an unhandled exception: {Summary}.", log.Message);
+ Assert.Null(log.Exception);
+ }
+
+ [Fact]
+ public async Task ToolCallFailure_WithThrowingSummarizer_FallsBackToRawException()
+ {
+ var options = CreateOptions();
+ options.Handlers.CallToolHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
+ options.ExceptionSummarizer = ex => throw new NotSupportedException("summarizer is broken");
+
+ var logs = await RunFailingCallToolRequestAsync(options);
+
+ var log = Assert.Single(logs.LogMessages, m => m.LogLevel == LogLevel.Error);
+ Assert.Equal("\"\" threw an unhandled exception.", log.Message);
+ Assert.IsType(log.Exception);
+ }
+
+ #endregion
+
+ #region The summarizer must not run when the event's level is disabled
+
+ [Fact]
+ public async Task RequestHandlerFailure_WhenWarningDisabled_DoesNotInvokeSummarizer()
+ {
+ CountingSummarizer summarizer = new();
+ var options = CreateOptions();
+ options.Handlers.ListToolsHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ options.ExceptionSummarizer = summarizer.Summarize;
+
+ // LogRequestHandlerException is Warning; a Critical minimum filters it out, so nothing is emitted.
+ var logs = await RunFailingListToolsRequestAsync(options, LogLevel.Critical);
+
+ Assert.Empty(logs.LogMessages);
+ Assert.Equal(0, summarizer.Invocations);
+ }
+
+ [Fact]
+ public async Task RequestHandlerFailure_WhenWarningEnabled_InvokesSummarizer()
+ {
+ CountingSummarizer summarizer = new();
+ var options = CreateOptions();
+ options.Handlers.ListToolsHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ options.ExceptionSummarizer = summarizer.Summarize;
+
+ var logs = await RunFailingListToolsRequestAsync(options, LogLevel.Warning);
+
+ Assert.Single(logs.LogMessages, m => m.Message.Contains("request handler failed") && m.Message.Contains(Summary));
+ Assert.Equal(1, summarizer.Invocations);
+ }
+
+ [Fact]
+ public async Task ToolCallFailure_WhenErrorDisabled_DoesNotInvokeSummarizer()
+ {
+ CountingSummarizer summarizer = new();
+ var options = CreateOptions();
+ options.Handlers.CallToolHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
+ options.ExceptionSummarizer = summarizer.Summarize;
+
+ // ToolCallError is Error; a Critical minimum filters it out, so nothing is emitted.
+ var logs = await RunFailingCallToolRequestAsync(options, LogLevel.Critical);
+
+ Assert.Empty(logs.LogMessages);
+ Assert.Equal(0, summarizer.Invocations);
+ }
+
+ [Fact]
+ public async Task ToolCallFailure_WhenErrorEnabled_InvokesSummarizer()
+ {
+ CountingSummarizer summarizer = new();
+ var options = CreateOptions();
+ options.Handlers.CallToolHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
+ options.ExceptionSummarizer = summarizer.Summarize;
+
+ var logs = await RunFailingCallToolRequestAsync(options, LogLevel.Error);
+
+ Assert.Single(logs.LogMessages, m => m.Message == $"\"\" threw an unhandled exception: {Summary}.");
+ Assert.Equal(1, summarizer.Invocations);
+ }
+
+ #endregion
+
+ #region EventId identity is preserved across raw and summarized variants
+
+ [Fact]
+ public async Task RequestHandlerFailure_EventIdIsIdenticalWithAndWithoutSummarizer()
+ {
+ var rawOptions = CreateOptions();
+ rawOptions.Handlers.ListToolsHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ var rawLogs = await RunFailingListToolsRequestAsync(rawOptions);
+ var raw = Assert.Single(rawLogs.LogMessages, m => m.Message.Contains("request handler failed"));
+
+ var summarizedOptions = CreateOptions();
+ summarizedOptions.Handlers.ListToolsHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ summarizedOptions.ExceptionSummarizer = ex => Summary;
+ var summarizedLogs = await RunFailingListToolsRequestAsync(summarizedOptions);
+ var summarized = Assert.Single(summarizedLogs.LogMessages, m => m.Message.Contains("request handler failed"));
+
+ // Different rendering, same logical event, so EventId-based filtering keeps working.
+ Assert.NotEqual(raw.Message, summarized.Message);
+ Assert.Equal(raw.EventId, summarized.EventId);
+ Assert.Equal(raw.EventId.Id, summarized.EventId.Id);
+ Assert.Equal(raw.EventId.Name, summarized.EventId.Name);
+ }
+
+ [Fact]
+ public async Task ToolCallFailure_EventIdIsIdenticalWithAndWithoutSummarizer()
+ {
+ var rawOptions = CreateOptions();
+ rawOptions.Handlers.CallToolHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ rawOptions.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
+ var rawLogs = await RunFailingCallToolRequestAsync(rawOptions);
+ var raw = Assert.Single(rawLogs.LogMessages, m => m.LogLevel == LogLevel.Error);
+
+ var summarizedOptions = CreateOptions();
+ summarizedOptions.Handlers.CallToolHandler = (request, ct) => throw new InvalidOperationException(HandlerFailureMessage);
+ summarizedOptions.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
+ summarizedOptions.ExceptionSummarizer = ex => Summary;
+ var summarizedLogs = await RunFailingCallToolRequestAsync(summarizedOptions);
+ var summarized = Assert.Single(summarizedLogs.LogMessages, m => m.LogLevel == LogLevel.Error);
+
+ Assert.NotEqual(raw.Message, summarized.Message);
+ Assert.Equal(raw.EventId, summarized.EventId);
+ Assert.Equal(raw.EventId.Id, summarized.EventId.Id);
+ Assert.Equal(raw.EventId.Name, summarized.EventId.Name);
+ }
+
+ #endregion
+
+ #region Dependency injection wiring
+
+ [Fact]
+ public async Task OptionsSetup_WithoutRegisteredSummarizer_LeavesDelegateNull()
+ {
+ ServiceCollection services = new();
+ services.AddMcpServer();
+
+ await using var provider = services.BuildServiceProvider();
+ var options = provider.GetRequiredService>().Value;
+
+ Assert.Null(options.ExceptionSummarizer);
+ }
+
+ [Fact]
+ public async Task OptionsSetup_WithRegisteredSummarizer_PrependsExceptionTypeToDescription()
+ {
+ ServiceCollection services = new();
+ services.AddSingleton(new FakeExceptionSummarizer(Summary));
+ services.AddMcpServer();
+
+ await using var provider = services.BuildServiceProvider();
+ var options = provider.GetRequiredService>().Value;
+
+ Assert.NotNull(options.ExceptionSummarizer);
+ Assert.Equal(
+ $"InvalidOperationException: {Summary}",
+ options.ExceptionSummarizer(new InvalidOperationException(HandlerFailureMessage)));
+ }
+
+ [Fact]
+ public async Task OptionsSetup_WithAddExceptionSummarizer_PreservesExceptionTypeAndOmitsMessage()
+ {
+ ServiceCollection services = new();
+ services.AddExceptionSummarizer();
+ services.AddMcpServer();
+
+ await using var provider = services.BuildServiceProvider();
+ var options = provider.GetRequiredService>().Value;
+
+ Assert.NotNull(options.ExceptionSummarizer);
+
+ // The built-in summarizer has no provider for InvalidOperationException, so Description alone is
+ // "Unknown". The exception type must still survive, and the sensitive message must not appear.
+ string summary = options.ExceptionSummarizer(new InvalidOperationException(HandlerFailureMessage));
+ Assert.StartsWith("InvalidOperationException:", summary);
+ Assert.DoesNotContain(HandlerFailureMessage, summary);
+ }
+
+ [Fact]
+ public async Task OptionsSetup_WithExplicitlyConfiguredDelegate_DoesNotOverwriteIt()
+ {
+ ServiceCollection services = new();
+ services.AddSingleton(new FakeExceptionSummarizer(Summary));
+ services.AddMcpServer(options => options.ExceptionSummarizer = ex => "explicit");
+
+ await using var provider = services.BuildServiceProvider();
+ var options = provider.GetRequiredService>().Value;
+
+ Assert.NotNull(options.ExceptionSummarizer);
+ Assert.Equal("explicit", options.ExceptionSummarizer(new InvalidOperationException()));
+ }
+
+ private sealed class FakeExceptionSummarizer(string description) : IExceptionSummarizer
+ {
+ public ExceptionSummary Summarize(Exception exception) =>
+ new(exception.GetType().Name, description, string.Empty);
+ }
+
+ #endregion
+
+ private async Task RunFailingListToolsRequestAsync(
+ McpServerOptions options, LogLevel minimumLevel = LogLevel.Debug)
+ {
+ MockLoggerProvider logs = new();
+ using ILoggerFactory loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
+ {
+ builder.AddProvider(logs);
+ builder.SetMinimumLevel(minimumLevel);
+ });
+
+ await using var transport = new TestServerTransport();
+ await using var server = McpServer.Create(transport, options, loggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+
+ var receivedError = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ transport.OnMessageSent = message =>
+ {
+ if (message is JsonRpcError error)
+ {
+ receivedError.TrySetResult(error);
+ }
+ };
+
+ await transport.SendMessageAsync(
+ new JsonRpcRequest { Method = RequestMethods.ToolsList, Id = new RequestId(1) },
+ TestContext.Current.CancellationToken);
+
+ await receivedError.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+
+ await transport.DisposeAsync();
+ await runTask;
+
+ return logs;
+ }
+
+ private async Task RunFailingCallToolRequestAsync(
+ McpServerOptions options, LogLevel minimumLevel = LogLevel.Debug)
+ {
+ MockLoggerProvider logs = new();
+ using ILoggerFactory loggerFactory = Microsoft.Extensions.Logging.LoggerFactory.Create(builder =>
+ {
+ builder.AddProvider(logs);
+ builder.SetMinimumLevel(minimumLevel);
+ });
+
+ await using var transport = new TestServerTransport();
+ await using var server = McpServer.Create(transport, options, loggerFactory);
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+
+ var receivedResponse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ transport.OnMessageSent = message =>
+ {
+ if (message is JsonRpcResponse response)
+ {
+ receivedResponse.TrySetResult(response);
+ }
+ };
+
+ await transport.SendMessageAsync(
+ new JsonRpcRequest { Method = RequestMethods.ToolsCall, Id = new RequestId(1) },
+ TestContext.Current.CancellationToken);
+
+ await receivedResponse.Task.WaitAsync(TestConstants.DefaultTimeout, TestContext.Current.CancellationToken);
+
+ await transport.DisposeAsync();
+ await runTask;
+
+ return logs;
+ }
+}