Skip to content

Commit db0f4d1

Browse files
authored
Add support for data to McpProtocolException (#1028)
1 parent 136755f commit db0f4d1

File tree

4 files changed

+311
-2
lines changed

4 files changed

+311
-2
lines changed

src/ModelContextProtocol.Core/McpSessionHandler.cs

Lines changed: 84 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -186,10 +186,10 @@ ex is OperationCanceledException &&
186186
{
187187
Code = (int)mcpProtocolException.ErrorCode,
188188
Message = mcpProtocolException.Message,
189+
Data = ConvertExceptionData(mcpProtocolException.Data),
189190
} : ex is McpException mcpException ?
190191
new()
191192
{
192-
193193
Code = (int)McpErrorCode.InternalError,
194194
Message = mcpException.Message,
195195
} :
@@ -206,6 +206,7 @@ ex is OperationCanceledException &&
206206
Error = detail,
207207
Context = new JsonRpcMessageContext { RelatedTransport = request.Context?.RelatedTransport },
208208
};
209+
209210
await SendMessageAsync(errorMessage, cancellationToken).ConfigureAwait(false);
210211
}
211212
else if (ex is not OperationCanceledException)
@@ -452,7 +453,40 @@ public async Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, Canc
452453
if (response is JsonRpcError error)
453454
{
454455
LogSendingRequestFailed(EndpointName, request.Method, error.Error.Message, error.Error.Code);
455-
throw new McpProtocolException($"Request failed (remote): {error.Error.Message}", (McpErrorCode)error.Error.Code);
456+
var exception = new McpProtocolException($"Request failed (remote): {error.Error.Message}", (McpErrorCode)error.Error.Code);
457+
458+
// Populate exception.Data with the error data if present.
459+
// When deserializing JSON, Data will be a JsonElement.
460+
// We extract primitive values (strings, numbers, bools) for broader compatibility,
461+
// as JsonElement is not [Serializable] and cannot be stored in Exception.Data on .NET Framework.
462+
if (error.Error.Data is JsonElement jsonElement && jsonElement.ValueKind == JsonValueKind.Object)
463+
{
464+
foreach (var property in jsonElement.EnumerateObject())
465+
{
466+
object? value = property.Value.ValueKind switch
467+
{
468+
JsonValueKind.String => property.Value.GetString(),
469+
JsonValueKind.Number => property.Value.GetDouble(),
470+
JsonValueKind.True => true,
471+
JsonValueKind.False => false,
472+
JsonValueKind.Null => null,
473+
#if NET
474+
// Objects and arrays are stored as JsonElement on .NET Core only
475+
_ => property.Value,
476+
#else
477+
// Skip objects/arrays on .NET Framework as JsonElement is not serializable
478+
_ => (object?)null,
479+
#endif
480+
};
481+
482+
if (value is not null || property.Value.ValueKind == JsonValueKind.Null)
483+
{
484+
exception.Data[property.Name] = value;
485+
}
486+
}
487+
}
488+
489+
throw exception;
456490
}
457491

458492
if (response is JsonRpcResponse success)
@@ -769,6 +803,54 @@ private static TimeSpan GetElapsed(long startingTimestamp) =>
769803
return null;
770804
}
771805

806+
/// <summary>
807+
/// Converts the <see cref="Exception.Data"/> dictionary to a serializable <see cref="Dictionary{TKey, TValue}"/>.
808+
/// Returns null if the data dictionary is empty or contains no string keys with serializable values.
809+
/// </summary>
810+
/// <remarks>
811+
/// <para>
812+
/// Only entries with string keys are included in the result. Entries with non-string keys are ignored.
813+
/// </para>
814+
/// <para>
815+
/// Each value is serialized to a <see cref="JsonElement"/> to ensure it can be safely included in the
816+
/// JSON-RPC error response. Values that cannot be serialized are silently skipped.
817+
/// </para>
818+
/// </remarks>
819+
private static Dictionary<string, JsonElement>? ConvertExceptionData(System.Collections.IDictionary data)
820+
{
821+
if (data.Count == 0)
822+
{
823+
return null;
824+
}
825+
826+
var typeInfo = McpJsonUtilities.DefaultOptions.GetTypeInfo<object?>();
827+
828+
Dictionary<string, JsonElement>? result = null;
829+
foreach (System.Collections.DictionaryEntry entry in data)
830+
{
831+
if (entry.Key is string key)
832+
{
833+
try
834+
{
835+
// Serialize each value upfront to catch any serialization issues
836+
// before attempting to send the message. If the value is already a
837+
// JsonElement, use it directly.
838+
var element = entry.Value is JsonElement je
839+
? je
840+
: JsonSerializer.SerializeToElement(entry.Value, typeInfo);
841+
result ??= new(data.Count);
842+
result[key] = element;
843+
}
844+
catch (Exception ex) when (ex is JsonException or NotSupportedException)
845+
{
846+
// Skip non-serializable values silently
847+
}
848+
}
849+
}
850+
851+
return result?.Count > 0 ? result : null;
852+
}
853+
772854
[LoggerMessage(Level = LogLevel.Information, Message = "{EndpointName} message processing canceled.")]
773855
private partial void LogEndpointMessageProcessingCanceled(string endpointName);
774856

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using ModelContextProtocol.Client;
3+
using ModelContextProtocol.Protocol;
4+
using ModelContextProtocol.Server;
5+
using ModelContextProtocol.Tests.Utils;
6+
using System.Text.Json;
7+
8+
namespace ModelContextProtocol.Tests;
9+
10+
/// <summary>
11+
/// Tests for McpProtocolException.Data propagation to JSON-RPC error responses.
12+
/// </summary>
13+
/// <remarks>
14+
/// Primitive values (strings, numbers, bools) are extracted from JsonElements and stored directly,
15+
/// which works on all platforms including .NET Framework. Complex objects and arrays are stored as
16+
/// JsonElement on .NET Core, but skipped on .NET Framework (where JsonElement is not serializable).
17+
/// </remarks>
18+
public class McpProtocolExceptionDataTests : ClientServerTestBase
19+
{
20+
public static bool IsNotNetFramework => !PlatformDetection.IsNetFramework;
21+
22+
public McpProtocolExceptionDataTests(ITestOutputHelper testOutputHelper)
23+
: base(testOutputHelper)
24+
{
25+
}
26+
27+
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
28+
{
29+
mcpServerBuilder.WithCallToolHandler((request, cancellationToken) =>
30+
{
31+
var toolName = request.Params?.Name;
32+
33+
switch (toolName)
34+
{
35+
case "throw_with_serializable_data":
36+
throw new McpProtocolException("Resource not found", (McpErrorCode)(-32002))
37+
{
38+
Data =
39+
{
40+
{ "uri", "file:///path/to/resource" },
41+
{ "code", 404 }
42+
}
43+
};
44+
45+
case "throw_with_nonserializable_data":
46+
throw new McpProtocolException("Resource not found", (McpErrorCode)(-32002))
47+
{
48+
Data =
49+
{
50+
// Circular reference - cannot be serialized
51+
{ "nonSerializable", new NonSerializableObject() },
52+
// This one should still be included
53+
{ "uri", "file:///path/to/resource" }
54+
}
55+
};
56+
57+
case "throw_with_only_nonserializable_data":
58+
throw new McpProtocolException("Resource not found", (McpErrorCode)(-32002))
59+
{
60+
Data =
61+
{
62+
// Only non-serializable data - should result in null data
63+
{ "nonSerializable", new NonSerializableObject() }
64+
}
65+
};
66+
67+
default:
68+
throw new McpProtocolException($"Unknown tool: '{toolName}'", McpErrorCode.InvalidParams);
69+
}
70+
});
71+
}
72+
73+
[Fact]
74+
public async Task Exception_With_Serializable_Data_Propagates_To_Client()
75+
{
76+
await using McpClient client = await CreateMcpClientForServer();
77+
78+
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
79+
await client.CallToolAsync("throw_with_serializable_data", cancellationToken: TestContext.Current.CancellationToken));
80+
81+
Assert.Equal("Request failed (remote): Resource not found", exception.Message);
82+
Assert.Equal((McpErrorCode)(-32002), exception.ErrorCode);
83+
84+
// Verify the data was propagated to the exception
85+
// The Data collection should contain the expected keys
86+
var hasUri = false;
87+
var hasCode = false;
88+
foreach (System.Collections.DictionaryEntry entry in exception.Data)
89+
{
90+
if (entry.Key is string key)
91+
{
92+
if (key == "uri") hasUri = true;
93+
if (key == "code") hasCode = true;
94+
}
95+
}
96+
Assert.True(hasUri, "Exception.Data should contain 'uri' key");
97+
Assert.True(hasCode, "Exception.Data should contain 'code' key");
98+
99+
// Verify the values - primitives are extracted as their native types (string, double, bool)
100+
Assert.Equal("file:///path/to/resource", exception.Data["uri"]);
101+
Assert.Equal(404.0, exception.Data["code"]); // Numbers are stored as double
102+
}
103+
104+
[Fact(Skip = "Non-serializable test data not supported on .NET Framework", SkipUnless = nameof(IsNotNetFramework))]
105+
public async Task Exception_With_NonSerializable_Data_Still_Propagates_Error_To_Client()
106+
{
107+
await using McpClient client = await CreateMcpClientForServer();
108+
109+
// The tool throws McpProtocolException with non-serializable data in Exception.Data.
110+
// The server should still send a proper error response to the client, with non-serializable
111+
// values filtered out.
112+
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
113+
await client.CallToolAsync("throw_with_nonserializable_data", cancellationToken: TestContext.Current.CancellationToken));
114+
115+
Assert.Equal("Request failed (remote): Resource not found", exception.Message);
116+
Assert.Equal((McpErrorCode)(-32002), exception.ErrorCode);
117+
118+
// Verify that only the serializable data was propagated (non-serializable was filtered out)
119+
var hasUri = false;
120+
var hasNonSerializable = false;
121+
foreach (System.Collections.DictionaryEntry entry in exception.Data)
122+
{
123+
if (entry.Key is string key)
124+
{
125+
if (key == "uri") hasUri = true;
126+
if (key == "nonSerializable") hasNonSerializable = true;
127+
}
128+
}
129+
Assert.True(hasUri, "Exception.Data should contain 'uri' key");
130+
Assert.False(hasNonSerializable, "Exception.Data should not contain 'nonSerializable' key");
131+
132+
Assert.Equal("file:///path/to/resource", exception.Data["uri"]);
133+
}
134+
135+
[Fact(Skip = "Non-serializable test data not supported on .NET Framework", SkipUnless = nameof(IsNotNetFramework))]
136+
public async Task Exception_With_Only_NonSerializable_Data_Still_Propagates_Error_To_Client()
137+
{
138+
await using McpClient client = await CreateMcpClientForServer();
139+
140+
// When all data is non-serializable, the error should still be sent (with null data)
141+
var exception = await Assert.ThrowsAsync<McpProtocolException>(async () =>
142+
await client.CallToolAsync("throw_with_only_nonserializable_data", cancellationToken: TestContext.Current.CancellationToken));
143+
144+
Assert.Equal("Request failed (remote): Resource not found", exception.Message);
145+
Assert.Equal((McpErrorCode)(-32002), exception.ErrorCode);
146+
147+
// When all data is non-serializable, the Data collection should be empty
148+
// (the server's ConvertExceptionData returns null when no serializable data exists)
149+
Assert.Empty(exception.Data);
150+
}
151+
152+
/// <summary>
153+
/// A class that cannot be serialized by System.Text.Json due to circular reference.
154+
/// </summary>
155+
private sealed class NonSerializableObject
156+
{
157+
public NonSerializableObject() => Self = this;
158+
public NonSerializableObject Self { get; set; }
159+
}
160+
}

tests/ModelContextProtocol.Tests/PlatformDetection.cs

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,4 +6,13 @@ internal static class PlatformDetection
66
{
77
public static bool IsMonoRuntime { get; } = Type.GetType("Mono.Runtime") is not null;
88
public static bool IsWindows { get; } = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
9+
10+
// On .NET Framework, Exception.Data requires values to be serializable with [Serializable].
11+
// JsonElement is not marked as serializable, so certain features are not available on that platform.
12+
public static bool IsNetFramework { get; } =
13+
#if NET
14+
false;
15+
#else
16+
true;
17+
#endif
918
}

tests/ModelContextProtocol.Tests/Server/McpServerTests.cs

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -671,6 +671,64 @@ await transport.SendMessageAsync(
671671
await runTask;
672672
}
673673

674+
[Fact]
675+
public async Task Can_Handle_Call_Tool_Requests_With_McpProtocolException_And_Data()
676+
{
677+
const string ErrorMessage = "Resource not found";
678+
const McpErrorCode ErrorCode = (McpErrorCode)(-32002);
679+
const string ResourceUri = "file:///path/to/resource";
680+
681+
await using var transport = new TestServerTransport();
682+
var options = CreateOptions(new ServerCapabilities { Tools = new() });
683+
options.Handlers.CallToolHandler = async (request, ct) =>
684+
{
685+
throw new McpProtocolException(ErrorMessage, ErrorCode)
686+
{
687+
Data =
688+
{
689+
{ "uri", ResourceUri }
690+
}
691+
};
692+
};
693+
options.Handlers.ListToolsHandler = (request, ct) => throw new NotImplementedException();
694+
695+
await using var server = McpServer.Create(transport, options, LoggerFactory);
696+
697+
var runTask = server.RunAsync(TestContext.Current.CancellationToken);
698+
699+
var receivedMessage = new TaskCompletionSource<JsonRpcError>();
700+
701+
transport.OnMessageSent = (message) =>
702+
{
703+
if (message is JsonRpcError error && error.Id.ToString() == "55")
704+
receivedMessage.SetResult(error);
705+
};
706+
707+
await transport.SendMessageAsync(
708+
new JsonRpcRequest
709+
{
710+
Method = RequestMethods.ToolsCall,
711+
Id = new RequestId(55)
712+
},
713+
TestContext.Current.CancellationToken
714+
);
715+
716+
var error = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
717+
Assert.NotNull(error);
718+
Assert.NotNull(error.Error);
719+
Assert.Equal((int)ErrorCode, error.Error.Code);
720+
Assert.Equal(ErrorMessage, error.Error.Message);
721+
Assert.NotNull(error.Error.Data);
722+
723+
// Verify the data contains the uri (values are now JsonElements after serialization)
724+
var dataDict = Assert.IsType<Dictionary<string, JsonElement>>(error.Error.Data);
725+
Assert.True(dataDict.ContainsKey("uri"));
726+
Assert.Equal(ResourceUri, dataDict["uri"].GetString());
727+
728+
await transport.DisposeAsync();
729+
await runTask;
730+
}
731+
674732
private async Task Can_Handle_Requests(ServerCapabilities? serverCapabilities, string method, Action<McpServerOptions>? configureOptions, Action<McpServer, JsonNode?> assertResult)
675733
{
676734
await using var transport = new TestServerTransport();

0 commit comments

Comments
 (0)