Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c629834
Initial plan
Copilot Nov 25, 2025
12d5683
Add support for data to McpProtocolException in JSON-RPC errors
Copilot Nov 25, 2025
64f2c58
Address code review feedback
Copilot Nov 25, 2025
9b8cf68
Use PascalCasing for const variables in test
Copilot Nov 25, 2025
580743d
Add test for non-serializable Exception.Data and fix exception handling
Copilot Nov 25, 2025
adcffc6
Address PR feedback: improve catch syntax and fix XML doc
Copilot Nov 25, 2025
688a956
Serialize Exception.Data upfront to prevent stream corruption
Copilot Nov 25, 2025
f34c302
Deserialize error data and populate exception.Data on client
Copilot Nov 25, 2025
c4d1500
Simplify error data deserialization to handle only JsonElement
Copilot Nov 25, 2025
4590f2d
Merge branch 'main' into copilot/add-data-support-to-mcpprotoexception
stephentoub Nov 25, 2025
cc2ca40
Merge branch 'main' into copilot/add-data-support-to-mcpprotoexception
stephentoub Nov 26, 2025
64deab3
Merge branch 'main' into copilot/add-data-support-to-mcpprotoexception
stephentoub Dec 1, 2025
23d25d8
Fix .NET Framework compatibility for Exception.Data with JsonElement
Copilot Dec 1, 2025
3209414
Use PlatformDetection class for .NET Framework detection
Copilot Dec 1, 2025
c8f8a27
Use #if NET to compile out error data population on .NET Framework
Copilot Dec 1, 2025
f757558
Make Exception.Data work on .NET Framework using SerializableJsonElem…
Copilot Dec 1, 2025
3082e35
Revert SerializableJsonElement wrapper, restore #if NET approach
Copilot Dec 1, 2025
a47be0d
Extract primitive values from JsonElements for .NET Framework compati…
Copilot Dec 1, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions src/ModelContextProtocol.Core/McpSessionHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
} :
Expand All @@ -206,6 +206,7 @@ ex is OperationCanceledException &&
Error = detail,
Context = new JsonRpcMessageContext { RelatedTransport = request.Context?.RelatedTransport },
};

await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false);
}
else if (ex is not OperationCanceledException)
Expand Down Expand Up @@ -452,7 +453,23 @@ public async Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, Canc
if (response is JsonRpcError error)
{
LogSendingRequestFailed(EndpointName, request.Method, error.Error.Message, error.Error.Code);
throw new McpProtocolException($"Request failed (remote): {error.Error.Message}", (McpErrorCode)error.Error.Code);
var exception = new McpProtocolException($"Request failed (remote): {error.Error.Message}", (McpErrorCode)error.Error.Code);

#if NET
// Populate exception.Data with the error data if present.
// When deserializing JSON, Data will be a JsonElement.
// Note: This is not supported on .NET Framework because Exception.Data uses ListDictionaryInternal
// which requires values to be marked with [Serializable], and JsonElement is not serializable.
if (error.Error.Data is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object)
{
foreach (var property in jsonElement.EnumerateObject())
{
exception.Data[property.Name] = property.Value;
}
}
#endif

throw exception;
}

if (response is JsonRpcResponse success)
Expand Down Expand Up @@ -769,6 +786,54 @@ private static TimeSpan GetElapsed(long startingTimestamp) =>
return null;
}

/// <summary>
/// Converts the <see cref="Exception.Data"/> dictionary to a serializable <see cref="Dictionary{TKey, TValue}"/>.
/// Returns null if the data dictionary is empty or contains no string keys with serializable values.
/// </summary>
/// <remarks>
/// <para>
/// Only entries with string keys are included in the result. Entries with non-string keys are ignored.
/// </para>
/// <para>
/// Each value is serialized to a <see cref="JsonElement"/> to ensure it can be safely included in the
/// JSON-RPC error response. Values that cannot be serialized are silently skipped.
/// </para>
/// </remarks>
private static Dictionary<string, JsonElement>? ConvertExceptionData(System.Collections.IDictionary data)
{
if (data.Count == 0)
{
return null;
}

var typeInfo = McpJsonUtilities.DefaultOptions.GetTypeInfo<object?>();

Dictionary<string, JsonElement>? 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);

Expand Down
174 changes: 174 additions & 0 deletions tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using ModelContextProtocol.Tests.Utils;
using System.Text.Json;

namespace ModelContextProtocol.Tests;

/// <summary>
/// Tests for McpProtocolException.Data propagation to JSON-RPC error responses.
/// </summary>
/// <remarks>
/// Note: On .NET Framework, Exception.Data requires values to be serializable with [Serializable].
/// Since JsonElement is not marked as serializable, the data population on the client side is skipped
/// on that platform. These tests verify the error message and code are correct regardless of platform.
/// </remarks>
public class McpProtocolExceptionDataTests : ClientServerTestBase
{
public McpProtocolExceptionDataTests(ITestOutputHelper testOutputHelper)
: base(testOutputHelper)
{
}

protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
mcpServerBuilder.WithCallToolHandler((request, cancellationToken) =>
{
var toolName = request.Params?.Name;

switch (toolName)
{
case "throw_with_serializable_data":
throw new McpProtocolException("Resource not found", (McpErrorCode)(-32002))
{
Data =
{
{ "uri", "file:///path/to/resource" },
{ "code", 404 }
}
};

case "throw_with_nonserializable_data":
throw new McpProtocolException("Resource not found", (McpErrorCode)(-32002))
{
Data =
{
// Circular reference - cannot be serialized
{ "nonSerializable", new NonSerializableObject() },
// This one should still be included
{ "uri", "file:///path/to/resource" }
}
};

case "throw_with_only_nonserializable_data":
throw new McpProtocolException("Resource not found", (McpErrorCode)(-32002))
{
Data =
{
// Only non-serializable data - should result in null data
{ "nonSerializable", new NonSerializableObject() }
}
};

default:
throw new McpProtocolException($"Unknown tool: '{toolName}'", McpErrorCode.InvalidParams);
}
});
}

[Fact]
public async Task Exception_With_Serializable_Data_Propagates_To_Client()
{
await using McpClient client = await CreateMcpClientForServer();

var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.CallToolAsync("throw_with_serializable_data", cancellationToken: TestContext.Current.CancellationToken));

Assert.Equal("Request failed (remote): Resource not found", exception.Message);
Assert.Equal((McpErrorCode)(-32002), exception.ErrorCode);

// Skip data verification on .NET Framework since JsonElement cannot be stored in Exception.Data
if (PlatformDetection.IsNetFramework)
{
return;
}

// Verify the data was propagated to the exception
// The Data collection should contain the expected keys
var hasUri = false;
var hasCode = false;
foreach (System.Collections.DictionaryEntry entry in exception.Data)
{
if (entry.Key is string key)
{
if (key == "uri") hasUri = true;
if (key == "code") hasCode = true;
}
}
Assert.True(hasUri, "Exception.Data should contain 'uri' key");
Assert.True(hasCode, "Exception.Data should contain 'code' key");

// Verify the values (they should be JsonElements)
var uriValue = Assert.IsType<JsonElement>(exception.Data["uri"]);
Assert.Equal("file:///path/to/resource", uriValue.GetString());

var codeValue = Assert.IsType<JsonElement>(exception.Data["code"]);
Assert.Equal(404, codeValue.GetInt32());
}

[Fact]
public async Task Exception_With_NonSerializable_Data_Still_Propagates_Error_To_Client()
{
await using McpClient client = await CreateMcpClientForServer();

// The tool throws McpProtocolException with non-serializable data in Exception.Data.
// The server should still send a proper error response to the client, with non-serializable
// values filtered out.
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.CallToolAsync("throw_with_nonserializable_data", cancellationToken: TestContext.Current.CancellationToken));

Assert.Equal("Request failed (remote): Resource not found", exception.Message);
Assert.Equal((McpErrorCode)(-32002), exception.ErrorCode);

// Skip data verification on .NET Framework since JsonElement cannot be stored in Exception.Data
if (PlatformDetection.IsNetFramework)
{
return;
}

// Verify that only the serializable data was propagated (non-serializable was filtered out)
var hasUri = false;
var hasNonSerializable = false;
foreach (System.Collections.DictionaryEntry entry in exception.Data)
{
if (entry.Key is string key)
{
if (key == "uri") hasUri = true;
if (key == "nonSerializable") hasNonSerializable = true;
}
}
Assert.True(hasUri, "Exception.Data should contain 'uri' key");
Assert.False(hasNonSerializable, "Exception.Data should not contain 'nonSerializable' key");

var uriValue = Assert.IsType<JsonElement>(exception.Data["uri"]);
Assert.Equal("file:///path/to/resource", uriValue.GetString());
}

[Fact]
public async Task Exception_With_Only_NonSerializable_Data_Still_Propagates_Error_To_Client()
{
await using McpClient client = await CreateMcpClientForServer();

// When all data is non-serializable, the error should still be sent (with null data)
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
await client.CallToolAsync("throw_with_only_nonserializable_data", cancellationToken: TestContext.Current.CancellationToken));

Assert.Equal("Request failed (remote): Resource not found", exception.Message);
Assert.Equal((McpErrorCode)(-32002), exception.ErrorCode);

// When all data is non-serializable, the Data collection should be empty
// (the server's ConvertExceptionData returns null when no serializable data exists)
Assert.Empty(exception.Data);
}

/// <summary>
/// A class that cannot be serialized by System.Text.Json due to circular reference.
/// </summary>
private sealed class NonSerializableObject
{
public NonSerializableObject() => Self = this;
public NonSerializableObject Self { get; set; }
}
}
9 changes: 9 additions & 0 deletions tests/ModelContextProtocol.Tests/PlatformDetection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,4 +6,13 @@ internal static class PlatformDetection
{
public static bool IsMonoRuntime { get; } = Type.GetType("Mono.Runtime") is not null;
public static bool IsWindows { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);

// On .NET Framework, Exception.Data requires values to be serializable with [Serializable].
// JsonElement is not marked as serializable, so certain features are not available on that platform.
public static bool IsNetFramework { get; } =
#if NET
false;
#else
true;
#endif
}
58 changes: 58 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -671,6 +671,64 @@ 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<JsonRpcError>();

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 (values are now JsonElements after serialization)
var dataDict = Assert.IsType<Dictionary<string, JsonElement>>(error.Error.Data);
Assert.True(dataDict.ContainsKey("uri"));
Assert.Equal(ResourceUri, dataDict["uri"].GetString());

await transport.DisposeAsync();
await runTask;
}

private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action<McpServerOptions>? configureOptions, Action<McpServer, JsonNode?> assertResult)
{
await using var transport = new TestServerTransport();
Expand Down