diff --git a/src/ModelContextProtocol.Core/McpSessionHandler.cs b/src/ModelContextProtocol.Core/McpSessionHandler.cs index fcd7980d..cf2f1a93 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, } : @@ -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) @@ -452,7 +453,19 @@ public async Task 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); + + // Populate exception.Data with the error data if present. + // When deserializing JSON, Data will be a JsonElement. + if (error.Error.Data is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object) + { + foreach (var property in jsonElement.EnumerateObject()) + { + exception.Data[property.Name] = property.Value; + } + } + + throw exception; } if (response is JsonRpcResponse success) @@ -769,6 +782,54 @@ private static TimeSpan GetElapsed(long startingTimestamp) => return null; } + /// + /// Converts the dictionary to a serializable . + /// Returns null if the data dictionary is empty or contains no string keys with serializable values. + /// + /// + /// + /// Only entries with string keys are included in the result. Entries with non-string keys are ignored. + /// + /// + /// Each value is serialized to a to ensure it can be safely included in the + /// JSON-RPC error response. Values that cannot be serialized are silently skipped. + /// + /// + private static Dictionary? ConvertExceptionData(System.Collections.IDictionary data) + { + if (data.Count == 0) + { + return null; + } + + var typeInfo = McpJsonUtilities.DefaultOptions.GetTypeInfo(); + + Dictionary? result = null; + foreach (System.Collections.DictionaryEntry entry in data) + { + if (entry.Key is string key) + { + try + { + // Serialize each value upfront to catch any serialization issues + // before attempting to send the message. If the value is already a + // JsonElement, use it directly. + var element = entry.Value is JsonElement je + ? je + : JsonSerializer.SerializeToElement(entry.Value, typeInfo); + result ??= new(data.Count); + result[key] = element; + } + catch (Exception ex) when (ex is JsonException or NotSupportedException) + { + // Skip non-serializable values silently + } + } + } + + return result?.Count > 0 ? result : null; + } + [LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} message processing canceled.")] private partial void LogEndpointMessageProcessingCanceled(string endpointName); diff --git a/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs b/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs new file mode 100644 index 00000000..05f00448 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs @@ -0,0 +1,157 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using ModelContextProtocol.Tests.Utils; +using System.Text.Json; + +namespace ModelContextProtocol.Tests; + +/// +/// Tests for McpProtocolException.Data propagation to JSON-RPC error responses. +/// +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(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); + + // 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(exception.Data["uri"]); + Assert.Equal("file:///path/to/resource", uriValue.GetString()); + + var codeValue = Assert.IsType(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(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); + + // 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(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(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); + } + + /// + /// 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; } + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index ab2537b6..8ca4709b 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -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(); + + 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>(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? configureOptions, Action assertResult) { await using var transport = new TestServerTransport();