Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
65 changes: 63 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,19 @@ 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);

// 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)
Expand Down Expand Up @@ -769,6 +782,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
157 changes: 157 additions & 0 deletions tests/ModelContextProtocol.Tests/McpProtocolExceptionDataTests.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Tests for McpProtocolException.Data propagation to JSON-RPC error responses.
/// </summary>
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);

// 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);

// 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; }
}
}
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
Loading