Skip to content

Commit e07f499

Browse files
Akbar Dızajıclaude
andcommitted
Add opt-in exception summarization for server-side logging
Adds McpServerOptions.ExceptionSummarizer, an optional delegate that maps an Exception to a sanitized description. When set, server-side failure paths log that description instead of attaching the raw Exception. When null (the default), every callsite logs exactly as before. Core stays free of a hard dependency on Microsoft.Extensions.Diagnostics.ExceptionSummarization; the DI package takes the package reference and McpServerOptionsSetup populates the delegate from an optionally-registered IExceptionSummarizer. Fixes #1690 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 15f8b2d commit e07f499

9 files changed

Lines changed: 475 additions & 10 deletions

File tree

Directory.Packages.props

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
<PackageVersion Include="Microsoft.Extensions.AI.Abstractions" Version="$(MicrosoftExtensionsVersion)" />
1414
<PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="$(System10Version)" />
1515
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="$(System10Version)" />
16+
<PackageVersion Include="Microsoft.Extensions.Diagnostics.ExceptionSummarization" Version="10.8.0" />
1617
<PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="$(System10Version)" />
1718
<PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="$(System10Version)" />
1819
</ItemGroup>

docs/concepts/logging/logging.md

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,3 +85,34 @@ Lastly, the client must configure a notification handler for <xref:ModelContextP
8585
The following example simply writes the log messages to the console.
8686

8787
[!code-csharp[](samples/client/Program.cs?name=snippet_LoggingHandler)]
88+
89+
### Sanitizing exceptions in the server's own diagnostic logs
90+
91+
Separately from the MCP Logging utility described above, the server writes its own diagnostic logs to the
92+
[ILogger] it was configured with. When a request handler, tool, prompt, or resource throws, those logs include the
93+
raw <xref:System.Exception>, which most logging providers render as the exception message plus its stack trace.
94+
That output can contain sensitive or overly detailed runtime data.
95+
96+
Set <xref:ModelContextProtocol.Server.McpServerOptions.ExceptionSummarizer> to log a sanitized description instead.
97+
When it is set, the failure paths log only the string the delegate returns, and the raw exception is not attached to
98+
the log entry. The default is `null`, which preserves the existing behavior of logging the raw exception.
99+
100+
```csharp
101+
builder.Services.AddMcpServer(options =>
102+
{
103+
options.ExceptionSummarizer = ex => ex.GetType().Name;
104+
});
105+
```
106+
107+
The `ModelContextProtocol` package also integrates with the standard
108+
[Microsoft.Extensions.Diagnostics.ExceptionSummarization](https://learn.microsoft.com/dotnet/api/microsoft.extensions.diagnostics.exceptionsummarization)
109+
abstractions. If an `IExceptionSummarizer` is registered in the container and `ExceptionSummarizer` has not been set
110+
explicitly, the SDK populates it with `ExceptionSummary.Description`:
111+
112+
```csharp
113+
builder.Services.AddExceptionSummarizer(b => b.AddHttpProvider());
114+
builder.Services.AddMcpServer();
115+
```
116+
117+
If the delegate throws or returns `null`, the SDK falls back to logging the raw exception, so a faulty summarizer
118+
can never fail the session.
Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
using System.Diagnostics.CodeAnalysis;
2+
3+
namespace ModelContextProtocol;
4+
5+
/// <summary>
6+
/// Provides the single, shared entry point used by exception-logging callsites to apply an
7+
/// optional user-supplied exception summarizer.
8+
/// </summary>
9+
internal static class ExceptionSummaryHelper
10+
{
11+
/// <summary>
12+
/// Attempts to produce a sanitized description of <paramref name="exception"/> using <paramref name="summarizer"/>.
13+
/// </summary>
14+
/// <returns>
15+
/// <see langword="true"/> if a summary was produced and the caller should log it in place of
16+
/// <paramref name="exception"/>; otherwise, <see langword="false"/>, in which case the caller must log
17+
/// <paramref name="exception"/> exactly as it would have without a summarizer. The summarizer is supplied
18+
/// by the host, so throwing or returning <see langword="null"/> both fall back rather than disrupt the session.
19+
/// </returns>
20+
public static bool TrySummarize(Func<Exception, string>? summarizer, Exception exception, [NotNullWhen(true)] out string? summary)
21+
{
22+
if (summarizer is not null)
23+
{
24+
try
25+
{
26+
summary = summarizer(exception);
27+
return summary is not null;
28+
}
29+
catch
30+
{
31+
// A faulty summarizer must never fail logging; fall back to the raw exception.
32+
}
33+
}
34+
35+
summary = null;
36+
return false;
37+
}
38+
}

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 38 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,7 @@ internal static bool SupportsNaturalOutputSchemas(string? protocolVersion)
9595
/// </summary>
9696
private readonly ConcurrentDictionary<RequestId, CancellationTokenSource> _handlingRequests = new();
9797
private readonly ILogger _logger;
98+
private readonly Func<Exception, string>? _exceptionSummarizer;
9899

99100
// This _sessionId is solely used to identify the session in telemetry and logs.
100101
private readonly string _sessionId = Guid.NewGuid().ToString("N");
@@ -115,6 +116,10 @@ internal static bool SupportsNaturalOutputSchemas(string? protocolVersion)
115116
/// <param name="incomingMessageFilter">A filter that wraps incoming message processing. Takes the next handler and returns a wrapped handler. If null, a passthrough filter is used.</param>
116117
/// <param name="outgoingMessageFilter">A filter that wraps outgoing message processing. Takes the next handler and returns a wrapped handler. If null, a passthrough filter is used.</param>
117118
/// <param name="logger">The logger.</param>
119+
/// <param name="exceptionSummarizer">
120+
/// An optional callback that produces a sanitized description of an exception. When non-<see langword="null"/>,
121+
/// exception logging callsites log that description instead of the raw <see cref="Exception"/>.
122+
/// </param>
118123
public McpSessionHandler(
119124
bool isServer,
120125
ITransport transport,
@@ -123,7 +128,8 @@ public McpSessionHandler(
123128
NotificationHandlers notificationHandlers,
124129
JsonRpcMessageFilter? incomingMessageFilter,
125130
JsonRpcMessageFilter? outgoingMessageFilter,
126-
ILogger logger)
131+
ILogger logger,
132+
Func<Exception, string>? exceptionSummarizer = null)
127133
{
128134
Throw.IfNull(transport);
129135

@@ -144,6 +150,7 @@ public McpSessionHandler(
144150
_incomingMessageFilter = incomingMessageFilter ?? (next => next);
145151
_outgoingMessageFilter = outgoingMessageFilter ?? (next => next);
146152
_logger = logger;
153+
_exceptionSummarizer = exceptionSummarizer;
147154

148155
// ping was removed in the 2026-07-28 protocol revision (SEP-2575). On the 2026-07-28 or later version,
149156
// return MethodNotFound; on an older version, the per-spec behavior is to always answer
@@ -323,7 +330,18 @@ ex is OperationCanceledException &&
323330
}
324331
else if (ex is not OperationCanceledException)
325332
{
326-
if (_logger.IsEnabled(LogLevel.Trace))
333+
if (ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, ex, out string? exceptionSummary))
334+
{
335+
if (_logger.IsEnabled(LogLevel.Trace))
336+
{
337+
LogMessageHandlerExceptionSensitiveSummarized(EndpointName, message.GetType().Name, JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage), exceptionSummary);
338+
}
339+
else
340+
{
341+
LogMessageHandlerExceptionSummarized(EndpointName, message.GetType().Name, exceptionSummary);
342+
}
343+
}
344+
else if (_logger.IsEnabled(LogLevel.Trace))
327345
{
328346
LogMessageHandlerExceptionSensitive(EndpointName, message.GetType().Name, JsonSerializer.Serialize(message, McpJsonUtilities.JsonContext.Default.JsonRpcMessage), ex);
329347
}
@@ -470,7 +488,15 @@ await _incomingMessageFilter(async (msg, ct) =>
470488
}
471489
catch (Exception ex)
472490
{
473-
LogRequestHandlerException(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds, ex);
491+
if (ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, ex, out string? exceptionSummary))
492+
{
493+
LogRequestHandlerExceptionSummarized(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds, exceptionSummary);
494+
}
495+
else
496+
{
497+
LogRequestHandlerException(EndpointName, request.Method, GetElapsed(requestStartingTimestamp).TotalMilliseconds, ex);
498+
}
499+
474500
throw;
475501
}
476502

@@ -1283,6 +1309,9 @@ internal static McpProtocolException CreateRemoteProtocolExceptionFromError(Json
12831309
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} method '{Method}' request handler failed in {ElapsedMilliseconds}ms.")]
12841310
private partial void LogRequestHandlerException(string endpointName, string method, double elapsedMilliseconds, Exception exception);
12851311

1312+
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} method '{Method}' request handler failed in {ElapsedMilliseconds}ms: {ExceptionSummary}.")]
1313+
private partial void LogRequestHandlerExceptionSummarized(string endpointName, string method, double elapsedMilliseconds, string exceptionSummary);
1314+
12861315
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} received request for unknown request ID '{RequestId}'.")]
12871316
private partial void LogNoRequestFoundForMessageWithId(string endpointName, RequestId requestId);
12881317

@@ -1313,9 +1342,15 @@ internal static McpProtocolException CreateRemoteProtocolExceptionFromError(Json
13131342
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} message handler {MessageType} failed.")]
13141343
private partial void LogMessageHandlerException(string endpointName, string messageType, Exception exception);
13151344

1345+
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} message handler {MessageType} failed: {ExceptionSummary}.")]
1346+
private partial void LogMessageHandlerExceptionSummarized(string endpointName, string messageType, string exceptionSummary);
1347+
13161348
[LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} message handler {MessageType} failed. Message: '{Message}'.")]
13171349
private partial void LogMessageHandlerExceptionSensitive(string endpointName, string messageType, string message, Exception exception);
13181350

1351+
[LoggerMessage(Level = LogLevel.Trace, Message = "{EndpointName} message handler {MessageType} failed: {ExceptionSummary}. Message: '{Message}'.")]
1352+
private partial void LogMessageHandlerExceptionSensitiveSummarized(string endpointName, string messageType, string message, string exceptionSummary);
1353+
13191354
[LoggerMessage(Level = LogLevel.Warning, Message = "{EndpointName} received unexpected {MessageType} message type.")]
13201355
private partial void LogEndpointHandlerUnexpectedMessageType(string endpointName, string messageType);
13211356

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 69 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ internal sealed partial class McpServerImpl : McpServer
2121
};
2222

2323
private readonly ILogger _logger;
24+
private readonly Func<Exception, string>? _exceptionSummarizer;
2425
private readonly ITransport _sessionTransport;
2526
private readonly bool _servicesScopePerRequest;
2627
private readonly List<Action> _disposables = [];
@@ -88,6 +89,7 @@ public McpServerImpl(ITransport transport, McpServerOptions options, ILoggerFact
8889
_endpointName = _serverOnlyEndpointName;
8990
_servicesScopePerRequest = options.ScopeRequests;
9091
_logger = loggerFactory?.CreateLogger<McpServer>() ?? NullLogger<McpServer>.Instance;
92+
_exceptionSummarizer = options.ExceptionSummarizer;
9193

9294
_clientInfo = options.KnownClientInfo;
9395
_clientCapabilities = options.KnownClientCapabilities;
@@ -161,7 +163,8 @@ void Register<TPrimitive>(McpServerPrimitiveCollection<TPrimitive>? collection,
161163
_notificationHandlers,
162164
incomingMessageFilter,
163165
outgoingMessageFilter,
164-
_logger);
166+
_logger,
167+
_exceptionSummarizer);
165168
}
166169

167170
/// <summary>
@@ -1230,7 +1233,7 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure
12301233
}
12311234
catch (Exception e)
12321235
{
1233-
ReadResourceError(request.Params?.Uri ?? string.Empty, e);
1236+
LogReadResourceError(request.Params?.Uri ?? string.Empty, e);
12341237
throw;
12351238
}
12361239
});
@@ -1344,7 +1347,7 @@ await originalListPromptsHandler(request, cancellationToken).ConfigureAwait(fals
13441347
}
13451348
catch (Exception e)
13461349
{
1347-
GetPromptError(request.Params?.Name ?? string.Empty, e);
1350+
LogGetPromptError(request.Params?.Name ?? string.Empty, e);
13481351
throw;
13491352
}
13501353
});
@@ -1597,7 +1600,7 @@ private McpRequestInvocationFilter<CallToolRequestParams, ResultOrAlternate<Call
15971600
// not an error (tools throw it to signal an InputRequiredResult).
15981601
if (!(e is OperationCanceledException && cancellationToken.IsCancellationRequested) && e is not InputRequiredException)
15991602
{
1600-
ToolCallError(request.Params?.Name ?? string.Empty, e);
1603+
LogToolCallError(request.Params?.Name ?? string.Empty, e);
16011604
}
16021605

16031606
if ((e is OperationCanceledException && cancellationToken.IsCancellationRequested) || e is McpProtocolException || e is InputRequiredException)
@@ -1648,7 +1651,7 @@ private void LogToolCallLifecycles(
16481651
else if (!(lifecycle.Exception is OperationCanceledException && lifecycle.CancellationRequested) &&
16491652
lifecycle.Exception is not InputRequiredException)
16501653
{
1651-
ToolCallError(toolName, lifecycle.Exception!);
1654+
LogToolCallError(toolName, lifecycle.Exception!);
16521655
}
16531656
}
16541657
}
@@ -2380,7 +2383,7 @@ private async Task ObserveHandlerCompletionAsync(Task<JsonNode?> handlerTask)
23802383
}
23812384
catch (Exception ex)
23822385
{
2383-
MrtrHandlerError(ex);
2386+
LogMrtrHandlerError(ex);
23842387
}
23852388
finally
23862389
{
@@ -2407,21 +2410,78 @@ private async Task ObserveHandlerCompletionAsync(Task<JsonNode?> handlerTask)
24072410
}
24082411
}
24092412

2413+
private void LogToolCallError(string toolName, Exception exception)
2414+
{
2415+
if (ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
2416+
{
2417+
ToolCallErrorSummarized(toolName, exceptionSummary);
2418+
}
2419+
else
2420+
{
2421+
ToolCallError(toolName, exception);
2422+
}
2423+
}
2424+
2425+
private void LogGetPromptError(string promptName, Exception exception)
2426+
{
2427+
if (ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
2428+
{
2429+
GetPromptErrorSummarized(promptName, exceptionSummary);
2430+
}
2431+
else
2432+
{
2433+
GetPromptError(promptName, exception);
2434+
}
2435+
}
2436+
2437+
private void LogReadResourceError(string resourceUri, Exception exception)
2438+
{
2439+
if (ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
2440+
{
2441+
ReadResourceErrorSummarized(resourceUri, exceptionSummary);
2442+
}
2443+
else
2444+
{
2445+
ReadResourceError(resourceUri, exception);
2446+
}
2447+
}
2448+
2449+
private void LogMrtrHandlerError(Exception exception)
2450+
{
2451+
if (ExceptionSummaryHelper.TrySummarize(_exceptionSummarizer, exception, out string? exceptionSummary))
2452+
{
2453+
MrtrHandlerErrorSummarized(exceptionSummary);
2454+
}
2455+
else
2456+
{
2457+
MrtrHandlerError(exception);
2458+
}
2459+
}
2460+
24102461
[LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception.")]
24112462
private partial void ToolCallError(string toolName, Exception exception);
24122463

2464+
[LoggerMessage(Level = LogLevel.Error, Message = "\"{ToolName}\" threw an unhandled exception: {ExceptionSummary}.")]
2465+
private partial void ToolCallErrorSummarized(string toolName, string exceptionSummary);
2466+
24132467
[LoggerMessage(Level = LogLevel.Information, Message = "\"{ToolName}\" completed. IsError = {IsError}.")]
24142468
private partial void ToolCallCompleted(string toolName, bool isError);
24152469

24162470
[LoggerMessage(Level = LogLevel.Error, Message = "GetPrompt \"{PromptName}\" threw an unhandled exception.")]
24172471
private partial void GetPromptError(string promptName, Exception exception);
24182472

2473+
[LoggerMessage(Level = LogLevel.Error, Message = "GetPrompt \"{PromptName}\" threw an unhandled exception: {ExceptionSummary}.")]
2474+
private partial void GetPromptErrorSummarized(string promptName, string exceptionSummary);
2475+
24192476
[LoggerMessage(Level = LogLevel.Information, Message = "GetPrompt \"{PromptName}\" completed.")]
24202477
private partial void GetPromptCompleted(string promptName);
24212478

24222479
[LoggerMessage(Level = LogLevel.Error, Message = "ReadResource \"{ResourceUri}\" threw an unhandled exception.")]
24232480
private partial void ReadResourceError(string resourceUri, Exception exception);
24242481

2482+
[LoggerMessage(Level = LogLevel.Error, Message = "ReadResource \"{ResourceUri}\" threw an unhandled exception: {ExceptionSummary}.")]
2483+
private partial void ReadResourceErrorSummarized(string resourceUri, string exceptionSummary);
2484+
24252485
[LoggerMessage(Level = LogLevel.Information, Message = "ReadResource \"{ResourceUri}\" completed.")]
24262486
private partial void ReadResourceCompleted(string resourceUri);
24272487

@@ -2431,6 +2491,9 @@ private async Task ObserveHandlerCompletionAsync(Task<JsonNode?> handlerTask)
24312491
[LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception.")]
24322492
private partial void MrtrHandlerError(Exception exception);
24332493

2494+
[LoggerMessage(Level = LogLevel.Debug, Message = "An MRTR handler threw an unhandled exception: {ExceptionSummary}.")]
2495+
private partial void MrtrHandlerErrorSummarized(string exceptionSummary);
2496+
24342497
[LoggerMessage(Level = LogLevel.Debug, Message = "Failed to deliver \"{NotificationMethod}\" to subscription \"{SubscriptionId}\".")]
24352498
private partial void SubscriptionNotificationFailed(string notificationMethod, string subscriptionId, Exception exception);
24362499
}

src/ModelContextProtocol.Core/Server/McpServerOptions.cs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,33 @@ public McpServerFilters Filters
205205
[Obsolete(Obsoletions.DeprecatedSampling_Message, DiagnosticId = Obsoletions.Deprecated_DiagnosticId, UrlFormat = Obsoletions.Deprecated_Url)]
206206
public int MaxSamplingOutputTokens { get; set; } = 1000;
207207

208+
/// <summary>
209+
/// Gets or sets an optional callback used to produce a sanitized description of an exception for logging.
210+
/// </summary>
211+
/// <value>
212+
/// A delegate that maps an <see cref="Exception"/> to a short description to be logged in place of the
213+
/// exception itself, or <see langword="null"/> to log the raw <see cref="Exception"/>. The default is
214+
/// <see langword="null"/>.
215+
/// </value>
216+
/// <remarks>
217+
/// <para>
218+
/// By default, server-side failure paths pass the raw <see cref="Exception"/> to <see cref="Microsoft.Extensions.Logging.ILogger"/>,
219+
/// which most logging providers render as the exception message plus its stack trace. That output can include
220+
/// sensitive or overly detailed runtime data. Setting this property opts into logging only the string this
221+
/// delegate returns; the raw <see cref="Exception"/> is not attached to those log entries.
222+
/// </para>
223+
/// <para>
224+
/// When the <c>ModelContextProtocol</c> package is used, this property is populated automatically from an
225+
/// <c>IExceptionSummarizer</c> registered in the dependency injection container (for example, via
226+
/// <c>services.AddExceptionSummarizer()</c>) if it has not already been set.
227+
/// </para>
228+
/// <para>
229+
/// If the delegate throws or returns <see langword="null"/>, the raw <see cref="Exception"/> is logged instead,
230+
/// so a faulty summarizer can never fail the session.
231+
/// </para>
232+
/// </remarks>
233+
public Func<Exception, string>? ExceptionSummarizer { get; set; }
234+
208235
/// <summary>
209236
/// Gets or sets custom request handlers to register with the server.
210237
/// </summary>

0 commit comments

Comments
 (0)