Allow JsonSerializerOptions parameters for user-defined and inputs and hardcode everything else to the source generator. (#182)

* Allow user-defined JsonSerializerOptions for user-defined and inputs and hardcode everything else to the source generator.

* Add validation for user-defined JsonSerializerOptions.

* Remove out of date comment.

* Address feedback and add tests
This commit is contained in:
Eirik Tsarpalis 2025-04-02 18:43:54 +01:00 • committed by GitHub
commit f1af251c32
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 632 additions and 372 deletions

View file

@ -40,12 +40,14 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
throw new InvalidOperationException($"Sampling capability was set but it did not provide a handler.");
}
SetRequestHandler<CreateMessageRequestParams, CreateMessageResult>(
SetRequestHandler(
RequestMethods.SamplingCreateMessage,
(request, cancellationToken) => samplingHandler(
request,
request?.Meta?.ProgressToken is { } token ? new TokenProgress(this, token) : NullProgress.Instance,
cancellationToken));
cancellationToken),
McpJsonUtilities.JsonContext.Default.CreateMessageRequestParams,
McpJsonUtilities.JsonContext.Default.CreateMessageResult);
}
if (options.Capabilities?.Roots is { } rootsCapability)
@ -55,9 +57,11 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
throw new InvalidOperationException($"Roots capability was set but it did not provide a handler.");
}
SetRequestHandler<ListRootsRequestParams, ListRootsResult>(
SetRequestHandler(
RequestMethods.RootsList,
(request, cancellationToken) => rootsHandler(request, cancellationToken));
rootsHandler,
McpJsonUtilities.JsonContext.Default.ListRootsRequestParams,
McpJsonUtilities.JsonContext.Default.ListRootsResult);
}
}
@ -88,21 +92,20 @@ internal sealed class McpClient : McpJsonRpcEndpoint, IMcpClient
using var initializationCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
initializationCts.CancelAfter(_options.InitializationTimeout);
try
{
// Send initialize request
var initializeResponse = await SendRequestAsync<InitializeResult>(
new JsonRpcRequest
{
Method = RequestMethods.Initialize,
Params = new InitializeRequestParams()
{
ProtocolVersion = _options.ProtocolVersion,
Capabilities = _options.Capabilities ?? new ClientCapabilities(),
ClientInfo = _options.ClientInfo
}
},
initializationCts.Token).ConfigureAwait(false);
try
{
// Send initialize request
var initializeResponse = await this.SendRequestAsync(
RequestMethods.Initialize,
new InitializeRequestParams
{
ProtocolVersion = _options.ProtocolVersion,
Capabilities = _options.Capabilities ?? new ClientCapabilities(),
ClientInfo = _options.ClientInfo
},
McpJsonUtilities.JsonContext.Default.InitializeRequestParams,
McpJsonUtilities.JsonContext.Default.InitializeResult,
cancellationToken: initializationCts.Token).ConfigureAwait(false);
// Store server information
_logger.ServerCapabilitiesReceived(EndpointName,

View file

@ -1,33 +1,16 @@
using ModelContextProtocol.Protocol.Messages;
using Microsoft.Extensions.AI;
using ModelContextProtocol.Protocol.Messages;
using ModelContextProtocol.Protocol.Types;
using ModelContextProtocol.Utils;
using ModelContextProtocol.Utils.Json;
using Microsoft.Extensions.AI;
using System.Text.Json;
using System.Runtime.CompilerServices;
using System.Text.Json;
namespace ModelContextProtocol.Client;
/// <summary>Provides extension methods for interacting with an <see cref="IMcpClient"/>.</summary>
public static class McpClientExtensions
{
/// <summary>
/// Sends a notification to the server with parameters.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="method">The notification method name.</param>
/// <param name="parameters">The parameters to send with the notification.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
public static Task SendNotificationAsync(this IMcpClient client, string method, object? parameters = null, CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
Throw.IfNullOrWhiteSpace(method);
return client.SendMessageAsync(
new JsonRpcNotification { Method = method, Params = parameters },
cancellationToken);
}
/// <summary>
/// Sends a ping request to verify server connectivity.
/// </summary>
@ -38,34 +21,46 @@ public static class McpClientExtensions
{
Throw.IfNull(client);
return client.SendRequestAsync<dynamic>(
CreateRequest(RequestMethods.Ping, null),
cancellationToken);
return client.SendRequestAsync(
RequestMethods.Ping,
parameters: null,
McpJsonUtilities.JsonContext.Default.Object,
McpJsonUtilities.JsonContext.Default.Object,
cancellationToken: cancellationToken);
}
/// <summary>
/// Retrieves a list of available tools from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="serializerOptions">The serializer options governing tool parameter serialization.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A list of all available tools.</returns>
public static async Task<IList<McpClientTool>> ListToolsAsync(
this IMcpClient client, CancellationToken cancellationToken = default)
this IMcpClient client,
JsonSerializerOptions? serializerOptions = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
serializerOptions ??= McpJsonUtilities.DefaultOptions;
serializerOptions.MakeReadOnly();
List<McpClientTool>? tools = null;
string? cursor = null;
do
{
var toolResults = await client.SendRequestAsync<ListToolsResult>(
CreateRequest(RequestMethods.ToolsList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
var toolResults = await client.SendRequestAsync(
RequestMethods.ToolsList,
CreateCursorDictionary(cursor),
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListToolsResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
tools ??= new List<McpClientTool>(toolResults.Tools.Count);
foreach (var tool in toolResults.Tools)
{
tools.Add(new McpClientTool(client, tool));
tools.Add(new McpClientTool(client, tool, serializerOptions));
}
cursor = toolResults.NextCursor;
@ -79,6 +74,7 @@ public static class McpClientExtensions
/// Creates an enumerable for asynchronously enumerating all available tools from the server.
/// </summary>
/// <param name="client">The client.</param>
/// <param name="serializerOptions">The serializer options governing tool parameter serialization.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>An asynchronous sequence of all available tools.</returns>
/// <remarks>
@ -86,20 +82,28 @@ public static class McpClientExtensions
/// will result in requerying the server and yielding the sequence of available tools.
/// </remarks>
public static async IAsyncEnumerable<McpClientTool> EnumerateToolsAsync(
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
this IMcpClient client,
JsonSerializerOptions? serializerOptions = null,
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
serializerOptions ??= McpJsonUtilities.DefaultOptions;
serializerOptions.MakeReadOnly();
string? cursor = null;
do
{
var toolResults = await client.SendRequestAsync<ListToolsResult>(
CreateRequest(RequestMethods.ToolsList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
var toolResults = await client.SendRequestAsync(
RequestMethods.ToolsList,
CreateCursorDictionary(cursor),
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListToolsResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
foreach (var tool in toolResults.Tools)
{
yield return new McpClientTool(client, tool);
yield return new McpClientTool(client, tool, serializerOptions);
}
cursor = toolResults.NextCursor;
@ -122,9 +126,12 @@ public static class McpClientExtensions
string? cursor = null;
do
{
var promptResults = await client.SendRequestAsync<ListPromptsResult>(
CreateRequest(RequestMethods.PromptsList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
var promptResults = await client.SendRequestAsync(
RequestMethods.PromptsList,
CreateCursorDictionary(cursor),
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListPromptsResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
prompts ??= new List<McpClientPrompt>(promptResults.Prompts.Count);
foreach (var prompt in promptResults.Prompts)
@ -157,9 +164,12 @@ public static class McpClientExtensions
string? cursor = null;
do
{
var promptResults = await client.SendRequestAsync<ListPromptsResult>(
CreateRequest(RequestMethods.PromptsList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
var promptResults = await client.SendRequestAsync(
RequestMethods.PromptsList,
CreateCursorDictionary(cursor),
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListPromptsResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
foreach (var prompt in promptResults.Prompts)
{
@ -177,17 +187,29 @@ public static class McpClientExtensions
/// <param name="client">The client.</param>
/// <param name="name">The name of the prompt to retrieve</param>
/// <param name="arguments">Optional arguments for the prompt</param>
/// <param name="serializerOptions">The serialization options governing argument serialization.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task containing the prompt's content and messages.</returns>
public static Task<GetPromptResult> GetPromptAsync(
this IMcpClient client, string name, IReadOnlyDictionary<string, object?>? arguments = null, CancellationToken cancellationToken = default)
this IMcpClient client,
string name,
IReadOnlyDictionary<string, object?>? arguments = null,
JsonSerializerOptions? serializerOptions = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
Throw.IfNullOrWhiteSpace(name);
serializerOptions ??= McpJsonUtilities.DefaultOptions;
serializerOptions.MakeReadOnly();
return client.SendRequestAsync<GetPromptResult>(
CreateRequest(RequestMethods.PromptsGet, CreateParametersDictionary(name, arguments)),
cancellationToken);
var parametersTypeInfo = serializerOptions.GetTypeInfo<IReadOnlyDictionary<string, object?>>();
return client.SendRequestAsync(
RequestMethods.PromptsGet,
CreateParametersDictionary(name, arguments),
parametersTypeInfo,
McpJsonUtilities.JsonContext.Default.GetPromptResult,
cancellationToken: cancellationToken);
}
/// <summary>
@ -206,9 +228,12 @@ public static class McpClientExtensions
string? cursor = null;
do
{
var templateResults = await client.SendRequestAsync<ListResourceTemplatesResult>(
CreateRequest(RequestMethods.ResourcesTemplatesList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
var templateResults = await client.SendRequestAsync(
RequestMethods.ResourcesTemplatesList,
CreateCursorDictionary(cursor),
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (templates is null)
{
@ -244,9 +269,12 @@ public static class McpClientExtensions
string? cursor = null;
do
{
var templateResults = await client.SendRequestAsync<ListResourceTemplatesResult>(
CreateRequest(RequestMethods.ResourcesTemplatesList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
var templateResults = await client.SendRequestAsync(
RequestMethods.ResourcesTemplatesList,
CreateCursorDictionary(cursor),
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
foreach (var template in templateResults.ResourceTemplates)
{
@ -274,9 +302,12 @@ public static class McpClientExtensions
string? cursor = null;
do
{
var resourceResults = await client.SendRequestAsync<ListResourcesResult>(
CreateRequest(RequestMethods.ResourcesList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
var resourceResults = await client.SendRequestAsync(
RequestMethods.ResourcesList,
CreateCursorDictionary(cursor),
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListResourcesResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
if (resources is null)
{
@ -312,9 +343,12 @@ public static class McpClientExtensions
string? cursor = null;
do
{
var resourceResults = await client.SendRequestAsync<ListResourcesResult>(
CreateRequest(RequestMethods.ResourcesList, CreateCursorDictionary(cursor)),
cancellationToken).ConfigureAwait(false);
var resourceResults = await client.SendRequestAsync(
RequestMethods.ResourcesList,
CreateCursorDictionary(cursor),
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ListResourcesResult,
cancellationToken: cancellationToken).ConfigureAwait(false);
foreach (var resource in resourceResults.Resources)
{
@ -338,9 +372,12 @@ public static class McpClientExtensions
Throw.IfNull(client);
Throw.IfNullOrWhiteSpace(uri);
return client.SendRequestAsync<ReadResourceResult>(
CreateRequest(RequestMethods.ResourcesRead, new Dictionary<string, object?>() { ["uri"] = uri }),
cancellationToken);
return client.SendRequestAsync(
RequestMethods.ResourcesRead,
new Dictionary<string, object?> { ["uri"] = uri },
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.ReadResourceResult,
cancellationToken: cancellationToken);
}
/// <summary>
@ -362,13 +399,16 @@ public static class McpClientExtensions
throw new ArgumentException($"Invalid reference: {validationMessage}", nameof(reference));
}
return client.SendRequestAsync<CompleteResult>(
CreateRequest(RequestMethods.CompletionComplete, new Dictionary<string, object?>()
return client.SendRequestAsync(
RequestMethods.CompletionComplete,
new Dictionary<string, object?>
{
["ref"] = reference,
["argument"] = new Argument { Name = argumentName, Value = argumentValue }
}),
cancellationToken);
},
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.CompleteResult,
cancellationToken: cancellationToken);
}
/// <summary>
@ -382,9 +422,12 @@ public static class McpClientExtensions
Throw.IfNull(client);
Throw.IfNullOrWhiteSpace(uri);
return client.SendRequestAsync<EmptyResult>(
CreateRequest(RequestMethods.ResourcesSubscribe, new Dictionary<string, object?>() { ["uri"] = uri }),
cancellationToken);
return client.SendRequestAsync(
RequestMethods.ResourcesSubscribe,
new Dictionary<string, object?> { ["uri"] = uri },
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.EmptyResult,
cancellationToken: cancellationToken);
}
/// <summary>
@ -398,9 +441,12 @@ public static class McpClientExtensions
Throw.IfNull(client);
Throw.IfNullOrWhiteSpace(uri);
return client.SendRequestAsync<EmptyResult>(
CreateRequest(RequestMethods.ResourcesUnsubscribe, new Dictionary<string, object?>() { ["uri"] = uri }),
cancellationToken);
return client.SendRequestAsync(
RequestMethods.ResourcesUnsubscribe,
new Dictionary<string, object?> { ["uri"] = uri },
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.EmptyResult,
cancellationToken: cancellationToken);
}
/// <summary>
@ -409,17 +455,29 @@ public static class McpClientExtensions
/// <param name="client">The client.</param>
/// <param name="toolName">The name of the tool to call.</param>
/// <param name="arguments">Optional arguments for the tool.</param>
/// <param name="serializerOptions">The serialization options governing argument serialization.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task containing the tool's response.</returns>
public static Task<CallToolResponse> CallToolAsync(
this IMcpClient client, string toolName, IReadOnlyDictionary<string, object?>? arguments = null, CancellationToken cancellationToken = default)
this IMcpClient client,
string toolName,
IReadOnlyDictionary<string, object?>? arguments = null,
JsonSerializerOptions? serializerOptions = null,
CancellationToken cancellationToken = default)
{
Throw.IfNull(client);
Throw.IfNull(toolName);
serializerOptions ??= McpJsonUtilities.DefaultOptions;
serializerOptions.MakeReadOnly();
return client.SendRequestAsync<CallToolResponse>(
CreateRequest(RequestMethods.ToolsCall, CreateParametersDictionary(toolName, arguments)),
cancellationToken);
var parametersTypeInfo = serializerOptions.GetTypeInfo<IReadOnlyDictionary<string, object?>>();
return client.SendRequestAsync(
RequestMethods.ToolsCall,
CreateParametersDictionary(toolName, arguments),
parametersTypeInfo,
McpJsonUtilities.JsonContext.Default.CallToolResponse,
cancellationToken: cancellationToken);
}
/// <summary>
@ -569,18 +627,14 @@ public static class McpClientExtensions
{
Throw.IfNull(client);
return client.SendRequestAsync<EmptyResult>(
CreateRequest(RequestMethods.LoggingSetLevel, new Dictionary<string, object?>() { ["level"] = level }),
cancellationToken);
return client.SendRequestAsync(
RequestMethods.LoggingSetLevel,
new Dictionary<string, object?> { ["level"] = level },
McpJsonUtilities.JsonContext.Default.DictionaryStringObject,
McpJsonUtilities.JsonContext.Default.EmptyResult,
cancellationToken: cancellationToken);
}
private static JsonRpcRequest CreateRequest(string method, IReadOnlyDictionary<string, object?>? parameters) =>
new()
{
Method = method,
Params = parameters
};
private static Dictionary<string, object?>? CreateCursorDictionary(string? cursor) =>
cursor != null ? new() { ["cursor"] = cursor } : null;
@ -599,36 +653,4 @@ public static class McpClientExtensions
return parameters;
}
/// <summary>Provides an AI function that calls a tool through <see cref="IMcpClient"/>.</summary>
private sealed class McpAIFunction(IMcpClient client, Tool tool) : AIFunction
{
/// <inheritdoc/>
public override string Name => tool.Name;
/// <inheritdoc/>
public override string Description => tool.Description ?? string.Empty;
/// <inheritdoc/>
public override JsonElement JsonSchema => tool.InputSchema;
/// <inheritdoc/>
public override JsonSerializerOptions JsonSerializerOptions => McpJsonUtilities.DefaultOptions;
/// <inheritdoc/>
protected async override Task<object?> InvokeCoreAsync(
IEnumerable<KeyValuePair<string, object?>> arguments, CancellationToken cancellationToken)
{
IReadOnlyDictionary<string, object?> argDict =
arguments as IReadOnlyDictionary<string, object?> ??
#if NET
arguments.ToDictionary();
#else
arguments.ToDictionary(kv => kv.Key, kv => kv.Value);
#endif
CallToolResponse result = await client.CallToolAsync(tool.Name, argDict, cancellationToken).ConfigureAwait(false);
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResponse);
}
}
}

View file

@ -1,4 +1,5 @@
using ModelContextProtocol.Protocol.Types;
using System.Text.Json;
namespace ModelContextProtocol.Client;
@ -20,17 +21,19 @@ public sealed class McpClientPrompt
/// Retrieves a specific prompt with optional arguments.
/// </summary>
/// <param name="arguments">Optional arguments for the prompt</param>
/// <param name="serializerOptions">The serialization options governing argument serialization.</param>
/// <param name="cancellationToken">A token to cancel the operation.</param>
/// <returns>A task containing the prompt's content and messages.</returns>
public async ValueTask<GetPromptResult> GetAsync(
IEnumerable<KeyValuePair<string, object?>>? arguments = null,
JsonSerializerOptions? serializerOptions = null,
CancellationToken cancellationToken = default)
{
IReadOnlyDictionary<string, object?>? argDict =
arguments as IReadOnlyDictionary<string, object?> ??
arguments?.ToDictionary();
return await _client.GetPromptAsync(ProtocolPrompt.Name, argDict, cancellationToken).ConfigureAwait(false);
return await _client.GetPromptAsync(ProtocolPrompt.Name, argDict, serializerOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
}
/// <summary>Gets the name of the prompt.</summary>

View file

@ -10,10 +10,11 @@ public sealed class McpClientTool : AIFunction
{
private readonly IMcpClient _client;
internal McpClientTool(IMcpClient client, Tool tool)
internal McpClientTool(IMcpClient client, Tool tool, JsonSerializerOptions serializerOptions)
{
_client = client;
ProtocolTool = tool;
JsonSerializerOptions = serializerOptions;
}
/// <summary>Gets the protocol <see cref="Tool"/> type for this instance.</summary>
@ -29,7 +30,7 @@ public sealed class McpClientTool : AIFunction
public override JsonElement JsonSchema => ProtocolTool.InputSchema;
/// <inheritdoc/>
public override JsonSerializerOptions JsonSerializerOptions => McpJsonUtilities.DefaultOptions;
public override JsonSerializerOptions JsonSerializerOptions { get; }
/// <inheritdoc/>
protected async override Task<object?> InvokeCoreAsync(
@ -39,7 +40,7 @@ public sealed class McpClientTool : AIFunction
arguments as IReadOnlyDictionary<string, object?> ??
arguments.ToDictionary();
CallToolResponse result = await _client.CallToolAsync(ProtocolTool.Name, argDict, cancellationToken).ConfigureAwait(false);
CallToolResponse result = await _client.CallToolAsync(ProtocolTool.Name, argDict, JsonSerializerOptions, cancellationToken: cancellationToken).ConfigureAwait(false);
return JsonSerializer.SerializeToElement(result, McpJsonUtilities.JsonContext.Default.CallToolResponse);
}
}