Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
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