From c629834f139d52d7fb50e719cb4085e9f0868276 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 25 Nov 2025 15:47:36 +0000
Subject: [PATCH 01/16] Initial plan
From 12d56839c9268cefef76a91b83bae17f323cbb94 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 25 Nov 2025 16:00:21 +0000
Subject: [PATCH 02/16] Add support for data to McpProtocolException in
JSON-RPC errors
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../McpSessionHandler.cs | 25 +++++++-
.../Server/McpServerTests.cs | 60 +++++++++++++++++++
2 files changed, 84 insertions(+), 1 deletion(-)
diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs
index fcd7980d9..dd567494d 100644
--- a/src/ModelContextProtocol.Core/McpSessionHandler.cs
+++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs
@@ -186,10 +186,10 @@ ex is OperationCanceledException &&
{
Code = (int)mcpProtocolException.ErrorCode,
Message = mcpProtocolException.Message,
+ Data = ConvertExceptionData(mcpProtocolException.Data),
} : ex is McpException mcpException ?
new()
{
-
Code = (int)McpErrorCode.InternalError,
Message = mcpException.Message,
} :
@@ -769,6 +769,29 @@ private static TimeSpan GetElapsed(long startingTimestamp) =>
return null;
}
+ ///
+ /// Converts the Exception.Data dictionary to a serializable Dictionary<string, object?>.
+ /// Returns null if the data dictionary is empty.
+ ///
+ private static Dictionary? ConvertExceptionData(System.Collections.IDictionary data)
+ {
+ if (data.Count == 0)
+ {
+ return null;
+ }
+
+ var result = new Dictionary(data.Count);
+ foreach (System.Collections.DictionaryEntry entry in data)
+ {
+ if (entry.Key is string key)
+ {
+ result[key] = entry.Value;
+ }
+ }
+
+ return result.Count > 0 ? result : null;
+ }
+
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} message processing canceled.")]
private partial void LogEndpointMessageProcessingCanceled(string endpointName);
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
index ab2537b6b..75248e99f 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
@@ -671,6 +671,66 @@ await transport.SendMessageAsync(
await runTask;
}
+ [Fact]
+ public async Task Can_Handle_Call_Tool_Requests_With_McpProtocolException_And_Data()
+ {
+ const string errorMessage = "Resource not found";
+ const McpErrorCode errorCode = (McpErrorCode)(-32002);
+ const string resourceUri = "file:///path/to/resource";
+
+ await using var transport = new TestServerTransport();
+ var options = CreateOptions(new ServerCapabilities { Tools = new() });
+ options.Handlers.CallToolHandler = async (request, ct) =>
+ {
+ throw new McpProtocolException(errorMessage, errorCode)
+ {
+ Data =
+ {
+ { "uri", resourceUri }
+ }
+ };
+ };
+ options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
+
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+
+ var receivedMessage = new TaskCompletionSource();
+
+ transport.OnMessageSent = (message) =>
+ {
+ if (message is JsonRpcError error && error.Id.ToString() == "55")
+ receivedMessage.SetResult(error);
+ };
+
+ await transport.SendMessageAsync(
+ new JsonRpcRequest
+ {
+ Method = RequestMethods.ToolsCall,
+ Id = new RequestId(55)
+ },
+ TestContext.Current.CancellationToken
+ );
+
+ var error = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ Assert.NotNull(error);
+ Assert.NotNull(error.Error);
+ Assert.Equal((int)errorCode, error.Error.Code);
+ Assert.Equal(errorMessage, error.Error.Message);
+ Assert.NotNull(error.Error.Data);
+
+ // Verify the data contains the uri
+ var dataJson = JsonSerializer.Serialize(error.Error.Data, McpJsonUtilities.DefaultOptions);
+ var dataObject = JsonSerializer.Deserialize(dataJson, McpJsonUtilities.DefaultOptions);
+ Assert.NotNull(dataObject);
+ Assert.True(dataObject.ContainsKey("uri"));
+ Assert.Equal(resourceUri, dataObject["uri"]?.GetValue());
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action? configureOptions, Action assertResult)
{
await using var transport = new TestServerTransport();
From 64f2c58a70d0e5266e86ba186b8de39319a1050d Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 25 Nov 2025 16:08:11 +0000
Subject: [PATCH 03/16] Address code review feedback
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
src/ModelContextProtocol.Core/McpSessionHandler.cs | 5 ++++-
tests/ModelContextProtocol.Tests/Server/McpServerTests.cs | 8 +++-----
2 files changed, 7 insertions(+), 6 deletions(-)
diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs
index dd567494d..f072f4f1b 100644
--- a/src/ModelContextProtocol.Core/McpSessionHandler.cs
+++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs
@@ -771,8 +771,11 @@ private static TimeSpan GetElapsed(long startingTimestamp) =>
///
/// Converts the Exception.Data dictionary to a serializable Dictionary<string, object?>.
- /// Returns null if the data dictionary is empty.
+ /// Returns null if the data dictionary is empty or contains no string keys.
///
+ ///
+ /// Only entries with string keys are included in the result. Entries with non-string keys are ignored.
+ ///
private static Dictionary? ConvertExceptionData(System.Collections.IDictionary data)
{
if (data.Count == 0)
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
index 75248e99f..6c095bb60 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
@@ -721,11 +721,9 @@ await transport.SendMessageAsync(
Assert.NotNull(error.Error.Data);
// Verify the data contains the uri
- var dataJson = JsonSerializer.Serialize(error.Error.Data, McpJsonUtilities.DefaultOptions);
- var dataObject = JsonSerializer.Deserialize(dataJson, McpJsonUtilities.DefaultOptions);
- Assert.NotNull(dataObject);
- Assert.True(dataObject.ContainsKey("uri"));
- Assert.Equal(resourceUri, dataObject["uri"]?.GetValue());
+ var dataDict = Assert.IsType>(error.Error.Data);
+ Assert.True(dataDict.ContainsKey("uri"));
+ Assert.Equal(resourceUri, dataDict["uri"]);
await transport.DisposeAsync();
await runTask;
From 9b8cf68960c4a029acb59015608cc862d5676fae Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 25 Nov 2025 16:16:32 +0000
Subject: [PATCH 04/16] Use PascalCasing for const variables in test
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../Server/McpServerTests.cs | 16 ++++++++--------
1 file changed, 8 insertions(+), 8 deletions(-)
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
index 6c095bb60..b1875d786 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
@@ -674,19 +674,19 @@ await transport.SendMessageAsync(
[Fact]
public async Task Can_Handle_Call_Tool_Requests_With_McpProtocolException_And_Data()
{
- const string errorMessage = "Resource not found";
- const McpErrorCode errorCode = (McpErrorCode)(-32002);
- const string resourceUri = "file:///path/to/resource";
+ const string ErrorMessage = "Resource not found";
+ const McpErrorCode ErrorCode = (McpErrorCode)(-32002);
+ const string ResourceUri = "file:///path/to/resource";
await using var transport = new TestServerTransport();
var options = CreateOptions(new ServerCapabilities { Tools = new() });
options.Handlers.CallToolHandler = async (request, ct) =>
{
- throw new McpProtocolException(errorMessage, errorCode)
+ throw new McpProtocolException(ErrorMessage, ErrorCode)
{
Data =
{
- { "uri", resourceUri }
+ { "uri", ResourceUri }
}
};
};
@@ -716,14 +716,14 @@ await transport.SendMessageAsync(
var error = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
Assert.NotNull(error);
Assert.NotNull(error.Error);
- Assert.Equal((int)errorCode, error.Error.Code);
- Assert.Equal(errorMessage, error.Error.Message);
+ Assert.Equal((int)ErrorCode, error.Error.Code);
+ Assert.Equal(ErrorMessage, error.Error.Message);
Assert.NotNull(error.Error.Data);
// Verify the data contains the uri
var dataDict = Assert.IsType>(error.Error.Data);
Assert.True(dataDict.ContainsKey("uri"));
- Assert.Equal(resourceUri, dataDict["uri"]);
+ Assert.Equal(ResourceUri, dataDict["uri"]);
await transport.DisposeAsync();
await runTask;
From 580743d1277c7bd3bfa51f231d25998f517d7db1 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 25 Nov 2025 17:04:34 +0000
Subject: [PATCH 05/16] Add test for non-serializable Exception.Data and fix
exception handling
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../McpSessionHandler.cs | 13 ++-
.../Server/McpServerTests.cs | 89 +++++++++++++++++++
2 files changed, 101 insertions(+), 1 deletion(-)
diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs
index f072f4f1b..6ff54d2be 100644
--- a/src/ModelContextProtocol.Core/McpSessionHandler.cs
+++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs
@@ -206,7 +206,18 @@ ex is OperationCanceledException &&
Error = detail,
Context = new JsonRpcMessageContext { RelatedTransport = request.Context?.RelatedTransport },
};
- await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false);
+
+ try
+ {
+ await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false);
+ }
+ catch (Exception sendException) when ((sendException is JsonException || sendException is NotSupportedException) && detail.Data is not null)
+ {
+ // If serialization fails (e.g., non-serializable data in Exception.Data),
+ // retry without the data to ensure the client receives an error response.
+ detail.Data = null;
+ await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false);
+ }
}
else if (ex is not OperationCanceledException)
{
diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
index b1875d786..36373186e 100644
--- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
+++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
@@ -6,6 +6,7 @@
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Nodes;
+using System.Threading.Channels;
namespace ModelContextProtocol.Tests.Server;
@@ -729,6 +730,94 @@ await transport.SendMessageAsync(
await runTask;
}
+ [Fact]
+ public async Task Can_Handle_Call_Tool_Requests_With_McpProtocolException_And_NonSerializableData()
+ {
+ const string ErrorMessage = "Resource not found";
+ const McpErrorCode ErrorCode = (McpErrorCode)(-32002);
+
+ await using var transport = new SerializingTestServerTransport();
+ var options = CreateOptions(new ServerCapabilities { Tools = new() });
+ options.Handlers.CallToolHandler = async (request, ct) =>
+ {
+ throw new McpProtocolException(ErrorMessage, ErrorCode)
+ {
+ Data =
+ {
+ // Add a non-serializable object (an object with circular reference)
+ { "nonSerializable", new NonSerializableObject() }
+ }
+ };
+ };
+ options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
+
+ await using var server = McpServer.Create(transport, options, LoggerFactory);
+
+ var runTask = server.RunAsync(TestContext.Current.CancellationToken);
+
+ var receivedMessage = new TaskCompletionSource();
+
+ transport.OnMessageSent = (message) =>
+ {
+ if (message is JsonRpcError error && error.Id.ToString() == "55")
+ receivedMessage.SetResult(error);
+ };
+
+ await transport.SendMessageAsync(
+ new JsonRpcRequest
+ {
+ Method = RequestMethods.ToolsCall,
+ Id = new RequestId(55)
+ },
+ TestContext.Current.CancellationToken
+ );
+
+ // Client should still receive an error response, even though the data couldn't be serialized
+ var error = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
+ Assert.NotNull(error);
+ Assert.NotNull(error.Error);
+ Assert.Equal((int)ErrorCode, error.Error.Code);
+ Assert.Equal(ErrorMessage, error.Error.Message);
+ // Data should be null since it couldn't be serialized
+ Assert.Null(error.Error.Data);
+
+ await transport.DisposeAsync();
+ await runTask;
+ }
+
+ ///
+ /// A class that cannot be serialized by System.Text.Json due to circular reference.
+ ///
+ private sealed class NonSerializableObject
+ {
+ public NonSerializableObject() => Self = this;
+ public NonSerializableObject Self { get; set; }
+ }
+
+ ///
+ /// A test transport that simulates JSON serialization failure for non-serializable data.
+ ///
+ private sealed class SerializingTestServerTransport : ITransport
+ {
+ private readonly TestServerTransport _inner = new();
+
+ public bool IsConnected => _inner.IsConnected;
+ public ChannelReader MessageReader => _inner.MessageReader;
+ public string? SessionId => _inner.SessionId;
+ public Action? OnMessageSent { get => _inner.OnMessageSent; set => _inner.OnMessageSent = value; }
+
+ public Task SendMessageAsync(JsonRpcMessage message, CancellationToken cancellationToken = default)
+ {
+ // Serialize the message to verify it can be serialized (this will throw JsonException if not)
+ // We serialize synchronously before any async operations to ensure the exception propagates correctly
+ _ = JsonSerializer.Serialize(message, McpJsonUtilities.DefaultOptions);
+
+ return _inner.SendMessageAsync(message, cancellationToken);
+ }
+
+ public ValueTask DisposeAsync() => _inner.DisposeAsync();
+ }
+
private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action? configureOptions, Action assertResult)
{
await using var transport = new TestServerTransport();
From adcffc6927dea79d7c3f2ae8acab7dc3dd2b6b03 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 25 Nov 2025 21:59:56 +0000
Subject: [PATCH 06/16] Address PR feedback: improve catch syntax and fix XML
doc
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
src/ModelContextProtocol.Core/McpSessionHandler.cs | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs
index 6ff54d2be..aac07344a 100644
--- a/src/ModelContextProtocol.Core/McpSessionHandler.cs
+++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs
@@ -211,7 +211,7 @@ ex is OperationCanceledException &&
{
await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false);
}
- catch (Exception sendException) when ((sendException is JsonException || sendException is NotSupportedException) && detail.Data is not null)
+ catch (Exception sendException) when (sendException is JsonException or NotSupportedException && detail.Data is not null)
{
// If serialization fails (e.g., non-serializable data in Exception.Data),
// retry without the data to ensure the client receives an error response.
@@ -781,7 +781,7 @@ private static TimeSpan GetElapsed(long startingTimestamp) =>
}
///
- /// Converts the Exception.Data dictionary to a serializable Dictionary<string, object?>.
+ /// Converts the dictionary to a serializable .
/// Returns null if the data dictionary is empty or contains no string keys.
///
///
From 688a956b53d29178c9c658b86bd13d1295db94a8 Mon Sep 17 00:00:00 2001
From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com>
Date: Tue, 25 Nov 2025 22:22:12 +0000
Subject: [PATCH 07/16] Serialize Exception.Data upfront to prevent stream
corruption
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
---
.../McpSessionHandler.cs | 44 ++++---
.../McpProtocolExceptionDataTests.cs | 113 ++++++++++++++++++
.../Server/McpServerTests.cs | 95 +--------------
3 files changed, 144 insertions(+), 108 deletions(-)
create mode 100644 tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs
diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs
index aac07344a..b85e5776e 100644
--- a/src/ModelContextProtocol.Core/McpSessionHandler.cs
+++ b/src/ModelContextProtocol.Core/McpSessionHandler.cs
@@ -207,17 +207,7 @@ ex is OperationCanceledException &&
Context = new JsonRpcMessageContext { RelatedTransport = request.Context?.RelatedTransport },
};
- try
- {
- await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false);
- }
- catch (Exception sendException) when (sendException is JsonException or NotSupportedException && detail.Data is not null)
- {
- // If serialization fails (e.g., non-serializable data in Exception.Data),
- // retry without the data to ensure the client receives an error response.
- detail.Data = null;
- await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false);
- }
+ await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false);
}
else if (ex is not OperationCanceledException)
{
@@ -782,28 +772,50 @@ private static TimeSpan GetElapsed(long startingTimestamp) =>
///
/// Converts the dictionary to a serializable .
- /// Returns null if the data dictionary is empty or contains no string keys.
+ /// 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)
+ private static Dictionary? ConvertExceptionData(System.Collections.IDictionary data)
{
if (data.Count == 0)
{
return null;
}
- var result = new Dictionary(data.Count);
+ var typeInfo = McpJsonUtilities.DefaultOptions.GetTypeInfo
public class McpProtocolExceptionDataTests : ClientServerTestBase
{
+ public static bool IsNotNetFramework => !PlatformDetection.IsNetFramework;
+
public McpProtocolExceptionDataTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
@@ -99,7 +101,7 @@ public async Task Exception_With_Serializable_Data_Propagates_To_Client()
Assert.Equal(404.0, exception.Data["code"]); // Numbers are stored as double
}
- [Fact]
+ [Fact(Skip = "Non-serializable test data not supported on .NET Framework", SkipUnless = nameof(IsNotNetFramework))]
public async Task Exception_With_NonSerializable_Data_Still_Propagates_Error_To_Client()
{
await using McpClient client = await CreateMcpClientForServer();
@@ -130,7 +132,7 @@ public async Task Exception_With_NonSerializable_Data_Still_Propagates_Error_To_
Assert.Equal("file:///path/to/resource", exception.Data["uri"]);
}
- [Fact]
+ [Fact(Skip = "Non-serializable test data not supported on .NET Framework", SkipUnless = nameof(IsNotNetFramework))]
public async Task Exception_With_Only_NonSerializable_Data_Still_Propagates_Error_To_Client()
{
await using McpClient client = await CreateMcpClientForServer();