Skip to content

Commit 5d9c2b0

Browse files
Address feedback and add tests
1 parent becdd3e commit 5d9c2b0

File tree

10 files changed

+146
-78
lines changed

10 files changed

+146
-78
lines changed

src/ModelContextProtocol/Client/McpClientExtensions.cs

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,13 +33,19 @@ public static Task PingAsync(this IMcpClient client, CancellationToken cancellat
3333
/// Retrieves a list of available tools from the server.
3434
/// </summary>
3535
/// <param name="client">The client.</param>
36+
/// <param name="serializerOptions">The serializer options governing tool parameter serialization.</param>
3637
/// <param name="cancellationToken">A token to cancel the operation.</param>
3738
/// <returns>A list of all available tools.</returns>
3839
public static async Task<IList<McpClientTool>> ListToolsAsync(
39-
this IMcpClient client, CancellationToken cancellationToken = default)
40+
this IMcpClient client,
41+
JsonSerializerOptions? serializerOptions = null,
42+
CancellationToken cancellationToken = default)
4043
{
4144
Throw.IfNull(client);
4245

46+
serializerOptions ??= McpJsonUtilities.DefaultOptions;
47+
serializerOptions.MakeReadOnly();
48+
4349
List<McpClientTool>? tools = null;
4450
string? cursor = null;
4551
do
@@ -54,7 +60,7 @@ public static async Task<IList<McpClientTool>> ListToolsAsync(
5460
tools ??= new List<McpClientTool>(toolResults.Tools.Count);
5561
foreach (var tool in toolResults.Tools)
5662
{
57-
tools.Add(new McpClientTool(client, tool));
63+
tools.Add(new McpClientTool(client, tool, serializerOptions));
5864
}
5965

6066
cursor = toolResults.NextCursor;
@@ -68,17 +74,23 @@ public static async Task<IList<McpClientTool>> ListToolsAsync(
6874
/// Creates an enumerable for asynchronously enumerating all available tools from the server.
6975
/// </summary>
7076
/// <param name="client">The client.</param>
77+
/// <param name="serializerOptions">The serializer options governing tool parameter serialization.</param>
7178
/// <param name="cancellationToken">A token to cancel the operation.</param>
7279
/// <returns>An asynchronous sequence of all available tools.</returns>
7380
/// <remarks>
7481
/// Every iteration through the returned <see cref="IAsyncEnumerable{McpClientTool}"/>
7582
/// will result in requerying the server and yielding the sequence of available tools.
7683
/// </remarks>
7784
public static async IAsyncEnumerable<McpClientTool> EnumerateToolsAsync(
78-
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
85+
this IMcpClient client,
86+
JsonSerializerOptions? serializerOptions = null,
87+
[EnumeratorCancellation] CancellationToken cancellationToken = default)
7988
{
8089
Throw.IfNull(client);
8190

91+
serializerOptions ??= McpJsonUtilities.DefaultOptions;
92+
serializerOptions.MakeReadOnly();
93+
8294
string? cursor = null;
8395
do
8496
{
@@ -91,7 +103,7 @@ public static async IAsyncEnumerable<McpClientTool> EnumerateToolsAsync(
91103

92104
foreach (var tool in toolResults.Tools)
93105
{
94-
yield return new McpClientTool(client, tool);
106+
yield return new McpClientTool(client, tool, serializerOptions);
95107
}
96108

97109
cursor = toolResults.NextCursor;
@@ -188,7 +200,8 @@ public static Task<GetPromptResult> GetPromptAsync(
188200
Throw.IfNull(client);
189201
Throw.IfNullOrWhiteSpace(name);
190202
serializerOptions ??= McpJsonUtilities.DefaultOptions;
191-
McpJsonUtilities.ValidateSerializerOptions(serializerOptions);
203+
serializerOptions.MakeReadOnly();
204+
192205
var parametersTypeInfo = serializerOptions.GetTypeInfo<IReadOnlyDictionary<string, object?>>();
193206

194207
return client.SendRequestAsync(
@@ -455,7 +468,8 @@ public static Task<CallToolResponse> CallToolAsync(
455468
Throw.IfNull(client);
456469
Throw.IfNull(toolName);
457470
serializerOptions ??= McpJsonUtilities.DefaultOptions;
458-
McpJsonUtilities.ValidateSerializerOptions(serializerOptions);
471+
serializerOptions.MakeReadOnly();
472+
459473
var parametersTypeInfo = serializerOptions.GetTypeInfo<IReadOnlyDictionary<string, object?>>();
460474

461475
return client.SendRequestAsync(

src/ModelContextProtocol/Client/McpClientTool.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ public sealed class McpClientTool : AIFunction
1010
{
1111
private readonly IMcpClient _client;
1212

13-
internal McpClientTool(IMcpClient client, Tool tool)
13+
internal McpClientTool(IMcpClient client, Tool tool, JsonSerializerOptions serializerOptions)
1414
{
1515
_client = client;
1616
ProtocolTool = tool;
17+
JsonSerializerOptions = serializerOptions;
1718
}
1819

1920
/// <summary>Gets the protocol <see cref="Tool"/> type for this instance.</summary>
@@ -29,7 +30,7 @@ internal McpClientTool(IMcpClient client, Tool tool)
2930
public override JsonElement JsonSchema => ProtocolTool.InputSchema;
3031

3132
/// <inheritdoc/>
32-
public override JsonSerializerOptions JsonSerializerOptions => McpJsonUtilities.DefaultOptions;
33+
public override JsonSerializerOptions JsonSerializerOptions { get; }
3334

3435
/// <inheritdoc/>
3536
protected async override Task<object?> InvokeCoreAsync(
@@ -39,7 +40,7 @@ internal McpClientTool(IMcpClient client, Tool tool)
3940
arguments as IReadOnlyDictionary<string, object?> ??
4041
arguments.ToDictionary();
4142

42-
CallToolResponse result = await _client.CallToolAsync(ProtocolTool.Name, argDict, cancellationToken: cancellationToken).ConfigureAwait(false);
43+
CallToolResponse result = await _client.CallToolAsync(ProtocolTool.Name, argDict, JsonSerializerOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
4344
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResponse);
4445
}
4546
}

src/ModelContextProtocol/McpEndpointExtensions.cs

Lines changed: 45 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,35 @@ namespace ModelContextProtocol;
1010
/// <summary>Provides extension methods for interacting with an <see cref="IMcpEndpoint"/>.</summary>
1111
public static class McpEndpointExtensions
1212
{
13+
/// <summary>
14+
/// Sends a JSON-RPC request and attempts to deserialize the result to <typeparamref name="TResult"/>.
15+
/// </summary>
16+
/// <typeparam name="TParameters">The type of the request parameters to serialize from.</typeparam>
17+
/// <typeparam name="TResult">The type of the result to deserialize to.</typeparam>
18+
/// <param name="endpoint">The MCP client or server instance.</param>
19+
/// <param name="method">The JSON-RPC method name to invoke.</param>
20+
/// <param name="parameters">Object representing the request parameters.</param>
21+
/// <param name="requestId">The request id for the request.</param>
22+
/// <param name="serializerOptions">The options governing request serialization.</param>
23+
/// <param name="cancellationToken">A token to cancel the operation.</param>
24+
/// <returns>A task that represents the asynchronous operation. The task result contains the deserialized result.</returns>
25+
public static Task<TResult> SendRequestAsync<TParameters, TResult>(
26+
this IMcpEndpoint endpoint,
27+
string method,
28+
TParameters parameters,
29+
JsonSerializerOptions? serializerOptions = null,
30+
RequestId? requestId = null,
31+
CancellationToken cancellationToken = default)
32+
where TResult : notnull
33+
{
34+
serializerOptions ??= McpJsonUtilities.DefaultOptions;
35+
serializerOptions.MakeReadOnly();
36+
37+
JsonTypeInfo<TParameters> paramsTypeInfo = serializerOptions.GetTypeInfo<TParameters>();
38+
JsonTypeInfo<TResult> resultTypeInfo = serializerOptions.GetTypeInfo<TResult>();
39+
return SendRequestAsync(endpoint, method, parameters, paramsTypeInfo, resultTypeInfo, requestId, cancellationToken);
40+
}
41+
1342
/// <summary>
1443
/// Sends a JSON-RPC request and attempts to deserialize the result to <typeparamref name="TResult"/>.
1544
/// </summary>
@@ -23,7 +52,7 @@ public static class McpEndpointExtensions
2352
/// <param name="requestId">The request id for the request.</param>
2453
/// <param name="cancellationToken">A token to cancel the operation.</param>
2554
/// <returns>A task that represents the asynchronous operation. The task result contains the deserialized result.</returns>
26-
public static async Task<TResult> SendRequestAsync<TParameters, TResult>(
55+
internal static async Task<TResult> SendRequestAsync<TParameters, TResult>(
2756
this IMcpEndpoint endpoint,
2857
string method,
2958
TParameters parameters,
@@ -53,34 +82,6 @@ public static async Task<TResult> SendRequestAsync<TParameters, TResult>(
5382
return JsonSerializer.Deserialize(response.Result, resultTypeInfo) ?? throw new JsonException("Unexpected JSON result in response.");
5483
}
5584

56-
/// <summary>
57-
/// Sends a JSON-RPC request and attempts to deserialize the result to <typeparamref name="TResult"/>.
58-
/// </summary>
59-
/// <typeparam name="TParameters">The type of the request parameters to serialize from.</typeparam>
60-
/// <typeparam name="TResult">The type of the result to deserialize to.</typeparam>
61-
/// <param name="endpoint">The MCP client or server instance.</param>
62-
/// <param name="method">The JSON-RPC method name to invoke.</param>
63-
/// <param name="parameters">Object representing the request parameters.</param>
64-
/// <param name="serializerOptions">The options governing request serialization.</param>
65-
/// <param name="requestId">The request id for the request.</param>
66-
/// <param name="cancellationToken">A token to cancel the operation.</param>
67-
/// <returns>A task that represents the asynchronous operation. The task result contains the deserialized result.</returns>
68-
public static Task<TResult> SendRequestAsync<TParameters, TResult>(
69-
this IMcpEndpoint endpoint,
70-
string method,
71-
TParameters parameters,
72-
JsonSerializerOptions? serializerOptions = null,
73-
RequestId? requestId = null,
74-
CancellationToken cancellationToken = default)
75-
where TResult : notnull
76-
{
77-
serializerOptions ??= McpJsonUtilities.DefaultOptions;
78-
McpJsonUtilities.ValidateSerializerOptions(serializerOptions);
79-
JsonTypeInfo<TParameters> paramsTypeInfo = serializerOptions.GetTypeInfo<TParameters>();
80-
JsonTypeInfo<TResult> resultTypeInfo = serializerOptions.GetTypeInfo<TResult>();
81-
return SendRequestAsync(endpoint, method, parameters, paramsTypeInfo, resultTypeInfo, requestId, cancellationToken);
82-
}
83-
8485
/// <summary>
8586
/// Sends a notification to the server with parameters.
8687
/// </summary>
@@ -100,21 +101,20 @@ public static Task SendNotificationAsync(this IMcpEndpoint client, string method
100101
/// <param name="endpoint">The MCP client or server instance.</param>
101102
/// <param name="method">The JSON-RPC method name to invoke.</param>
102103
/// <param name="parameters">Object representing the request parameters.</param>
103-
/// <param name="parametersTypeInfo">The type information for request parameter serialization.</param>
104+
/// <param name="serializerOptions">The options governing request serialization.</param>
104105
/// <param name="cancellationToken">A token to cancel the operation.</param>
105106
public static Task SendNotificationAsync<TParameters>(
106107
this IMcpEndpoint endpoint,
107108
string method,
108109
TParameters parameters,
109-
JsonTypeInfo<TParameters> parametersTypeInfo,
110+
JsonSerializerOptions? serializerOptions = null,
110111
CancellationToken cancellationToken = default)
111112
{
112-
Throw.IfNull(endpoint);
113-
Throw.IfNullOrWhiteSpace(method);
114-
Throw.IfNull(parametersTypeInfo);
113+
serializerOptions ??= McpJsonUtilities.DefaultOptions;
114+
serializerOptions.MakeReadOnly();
115115

116-
JsonNode? parametersJson = JsonSerializer.SerializeToNode(parameters, parametersTypeInfo);
117-
return endpoint.SendMessageAsync(new JsonRpcNotification { Method = method, Params = parametersJson }, cancellationToken);
116+
JsonTypeInfo<TParameters> parametersTypeInfo = serializerOptions.GetTypeInfo<TParameters>();
117+
return SendNotificationAsync(endpoint, method, parameters, parametersTypeInfo, cancellationToken);
118118
}
119119

120120
/// <summary>
@@ -123,19 +123,21 @@ public static Task SendNotificationAsync<TParameters>(
123123
/// <param name="endpoint">The MCP client or server instance.</param>
124124
/// <param name="method">The JSON-RPC method name to invoke.</param>
125125
/// <param name="parameters">Object representing the request parameters.</param>
126-
/// <param name="serializerOptions">The options governing request serialization.</param>
126+
/// <param name="parametersTypeInfo">The type information for request parameter serialization.</param>
127127
/// <param name="cancellationToken">A token to cancel the operation.</param>
128-
public static Task SendNotificationAsync<TParameters>(
128+
internal static Task SendNotificationAsync<TParameters>(
129129
this IMcpEndpoint endpoint,
130130
string method,
131131
TParameters parameters,
132-
JsonSerializerOptions? serializerOptions = null,
132+
JsonTypeInfo<TParameters> parametersTypeInfo,
133133
CancellationToken cancellationToken = default)
134134
{
135-
serializerOptions ??= McpJsonUtilities.DefaultOptions;
136-
McpJsonUtilities.ValidateSerializerOptions(serializerOptions);
137-
JsonTypeInfo<TParameters> parametersTypeInfo = serializerOptions.GetTypeInfo<TParameters>();
138-
return SendNotificationAsync(endpoint, method, parameters, parametersTypeInfo, cancellationToken);
135+
Throw.IfNull(endpoint);
136+
Throw.IfNullOrWhiteSpace(method);
137+
Throw.IfNull(parametersTypeInfo);
138+
139+
JsonNode? parametersJson = JsonSerializer.SerializeToNode(parameters, parametersTypeInfo);
140+
return endpoint.SendMessageAsync(new JsonRpcNotification { Method = method, Params = parametersJson }, cancellationToken);
139141
}
140142

141143
/// <summary>Notifies the connected endpoint of progress.</summary>

src/ModelContextProtocol/Utils/Json/McpJsonUtilities.cs

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -74,16 +74,6 @@ private static JsonSerializerOptions CreateDefaultOptions()
7474
internal static JsonTypeInfo<T> GetTypeInfo<T>(this JsonSerializerOptions options) =>
7575
(JsonTypeInfo<T>)options.GetTypeInfo(typeof(T));
7676

77-
internal static void ValidateSerializerOptions(JsonSerializerOptions options)
78-
{
79-
if (options.WriteIndented)
80-
{
81-
throw new InvalidOperationException("JsonSerializerOptions.WriteIndented is not supported for JSON-RPC workloads.");
82-
}
83-
84-
options.MakeReadOnly();
85-
}
86-
8777
internal static JsonElement DefaultMcpToolSchema { get; } = ParseJsonElement("""{"type":"object"}"""u8);
8878
internal static object? AsObject(this JsonElement element) => element.ValueKind is JsonValueKind.Null ? null : element;
8979

tests/ModelContextProtocol.Tests/Client/McpClientExtensionsTests.cs

Lines changed: 63 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using ModelContextProtocol.Client;
33
using ModelContextProtocol.Protocol.Transport;
4+
using ModelContextProtocol.Protocol.Types;
45
using ModelContextProtocol.Server;
56
using ModelContextProtocol.Tests.Utils;
67
using System.IO.Pipelines;
8+
using System.Text.Json;
9+
using System.Text.Json.Serialization.Metadata;
710

811
namespace ModelContextProtocol.Tests.Client;
912

@@ -72,7 +75,7 @@ public async Task ListToolsAsync_AllToolsReturned()
7275
{
7376
IMcpClient client = await CreateMcpClientForServer();
7477

75-
var tools = await client.ListToolsAsync(TestContext.Current.CancellationToken);
78+
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
7679
Assert.Equal(12, tools.Count);
7780
var echo = tools.Single(t => t.Name == "Method4");
7881
var result = await echo.InvokeAsync(new Dictionary<string, object?>() { ["i"] = 42 }, TestContext.Current.CancellationToken);
@@ -98,7 +101,7 @@ public async Task EnumerateToolsAsync_AllToolsReturned()
98101
{
99102
IMcpClient client = await CreateMcpClientForServer();
100103

101-
await foreach (var tool in client.EnumerateToolsAsync(TestContext.Current.CancellationToken))
104+
await foreach (var tool in client.EnumerateToolsAsync(cancellationToken: TestContext.Current.CancellationToken))
102105
{
103106
if (tool.Name == "Method4")
104107
{
@@ -110,4 +113,62 @@ public async Task EnumerateToolsAsync_AllToolsReturned()
110113

111114
Assert.Fail("Couldn't find target method");
112115
}
116+
117+
[Fact]
118+
public async Task EnumerateToolsAsync_FlowsJsonSerializerOptions()
119+
{
120+
JsonSerializerOptions options = new(JsonSerializerOptions.Default);
121+
IMcpClient client = await CreateMcpClientForServer();
122+
bool hasTools = false;
123+
124+
await foreach (var tool in client.EnumerateToolsAsync(options, TestContext.Current.CancellationToken))
125+
{
126+
Assert.Same(options, tool.JsonSerializerOptions);
127+
hasTools = true;
128+
}
129+
130+
foreach (var tool in await client.ListToolsAsync(options, TestContext.Current.CancellationToken))
131+
{
132+
Assert.Same(options, tool.JsonSerializerOptions);
133+
}
134+
135+
Assert.True(hasTools);
136+
}
137+
138+
[Fact]
139+
public async Task EnumerateToolsAsync_HonorsJsonSerializerOptions()
140+
{
141+
JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
142+
IMcpClient client = await CreateMcpClientForServer();
143+
144+
var tool = (await client.ListToolsAsync(emptyOptions, TestContext.Current.CancellationToken)).First();
145+
await Assert.ThrowsAsync<NotSupportedException>(() => tool.InvokeAsync(new Dictionary<string, object?> { ["i"] = 42 }, TestContext.Current.CancellationToken));
146+
}
147+
148+
[Fact]
149+
public async Task SendRequestAsync_HonorsJsonSerializerOptions()
150+
{
151+
JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
152+
IMcpClient client = await CreateMcpClientForServer();
153+
154+
await Assert.ThrowsAsync<NotSupportedException>(() => client.SendRequestAsync<CallToolRequestParams, CallToolResponse>("Method4", new() { Name = "tool" }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken));
155+
}
156+
157+
[Fact]
158+
public async Task SendNotificationAsync_HonorsJsonSerializerOptions()
159+
{
160+
JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
161+
IMcpClient client = await CreateMcpClientForServer();
162+
163+
await Assert.ThrowsAsync<NotSupportedException>(() => client.SendNotificationAsync("Method4", new { Value = 42 }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken));
164+
}
165+
166+
[Fact]
167+
public async Task GetPromptsAsync_HonorsJsonSerializerOptions()
168+
{
169+
JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() };
170+
IMcpClient client = await CreateMcpClientForServer();
171+
172+
await Assert.ThrowsAsync<NotSupportedException>(() => client.GetPromptAsync("Prompt", new Dictionary<string, object?> { ["i"] = 42 }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken));
173+
}
113174
}

0 commit comments

Comments
 (0)