Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
19 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
e5d0cbb
Fix tests: Skip non-serializable data tests on .NET Framework
Copilot Dec 2, 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
41 changes: 39 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,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)
{
Expand Down Expand Up @@ -769,6 +780,32 @@ private static TimeSpan GetElapsed(long startingTimestamp) =>
return null;
}

/// <summary>
/// Converts the Exception.Data dictionary to a serializable Dictionary&lt;string, object?&gt;.
/// Returns null if the data dictionary is empty or contains no string keys.
/// </summary>
/// <remarks>
/// Only entries with string keys are included in the result. Entries with non-string keys are ignored.
/// </remarks>
private static Dictionary<string, object?>? ConvertExceptionData(System.Collections.IDictionary data)
{
if (data.Count == 0)
{
return null;
}

var result = new Dictionary<string, object?>(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);

Expand Down
147 changes: 147 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -671,6 +672,152 @@ 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
var dataDict = Assert.IsType<Dictionary<string, object?>>(error.Error.Data);
Assert.True(dataDict.ContainsKey("uri"));
Assert.Equal(ResourceUri, dataDict["uri"]);

await transport.DisposeAsync();
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<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
);

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

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

/// <summary>
/// A test transport that simulates JSON serialization failure for non-serializable data.
/// </summary>
private sealed class SerializingTestServerTransport : ITransport
{
private readonly TestServerTransport _inner = new();

public bool IsConnected => _inner.IsConnected;
public ChannelReader<JsonRpcMessage> MessageReader => _inner.MessageReader;
public string? SessionId => _inner.SessionId;
public Action<JsonRpcMessage>? 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<McpServerOptions>? configureOptions, Action<McpServer, JsonNode?> assertResult)
{
await using var transport = new TestServerTransport();
Expand Down