From 0d321f8a3c3587d73a7563ce3a76479bdf9227b2 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Thu, 6 Nov 2025 17:18:58 -0800 Subject: [PATCH 01/13] Add RequestOptions with meta to high-level client methods --- samples/EverythingServer/Program.cs | 2 +- .../EverythingServer/Tools/SampleLlmTool.cs | 2 +- .../Tools/SampleLlmTool.cs | 2 +- .../Client/McpClient.Methods.cs | 124 +++++++++++------- .../Client/McpClientPrompt.cs | 2 +- .../Client/McpClientResource.cs | 4 +- .../Client/McpClientResourceTemplate.cs | 2 +- .../Client/McpClientTool.cs | 10 +- .../McpJsonUtilities.cs | 1 + .../McpSession.Methods.cs | 3 + .../Protocol/PingRequestParams.cs | 11 ++ .../Protocol/RequestParams.cs | 18 +-- src/ModelContextProtocol.Core/README.md | 6 +- .../RequestOptions.cs | 62 +++++++++ .../Server/McpServer.Methods.cs | 30 ++--- .../TokenProgress.cs | 2 +- .../HttpServerIntegrationTests.cs | 10 +- .../Program.cs | 2 +- .../Program.cs | 2 +- .../Client/McpClientResourceTemplateTests.cs | 38 +++--- .../Client/McpClientTests.cs | 14 +- .../ClientIntegrationTests.cs | 24 ++-- .../McpServerBuilderExtensionsPromptsTests.cs | 8 +- ...cpServerBuilderExtensionsResourcesTests.cs | 10 +- .../McpServerBuilderExtensionsToolsTests.cs | 7 +- ...erverResourceCapabilityIntegrationTests.cs | 4 +- .../McpServerResourceRoutingTests.cs | 10 +- .../Configuration/McpServerScopedTests.cs | 2 +- .../Server/EmptyCollectionTests.cs | 14 +- 29 files changed, 264 insertions(+), 162 deletions(-) create mode 100644 src/ModelContextProtocol.Core/Protocol/PingRequestParams.cs create mode 100644 src/ModelContextProtocol.Core/RequestOptions.cs diff --git a/samples/EverythingServer/Program.cs b/samples/EverythingServer/Program.cs index acaa7a37c..86e46ec6b 100644 --- a/samples/EverythingServer/Program.cs +++ b/samples/EverythingServer/Program.cs @@ -75,7 +75,7 @@ await ctx.Server.SampleAsync([ new ChatMessage(ChatRole.System, "You are a helpful test server"), new ChatMessage(ChatRole.User, $"Resource {uri}, context: A new subscription was started"), ], - options: new ChatOptions + chatOptions: new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f, diff --git a/samples/EverythingServer/Tools/SampleLlmTool.cs b/samples/EverythingServer/Tools/SampleLlmTool.cs index 48c5184b3..8a23ca499 100644 --- a/samples/EverythingServer/Tools/SampleLlmTool.cs +++ b/samples/EverythingServer/Tools/SampleLlmTool.cs @@ -15,7 +15,7 @@ public static async Task SampleLLM( CancellationToken cancellationToken) { var samplingParams = CreateRequestSamplingParams(prompt ?? string.Empty, "sampleLLM", maxTokens); - var sampleResult = await server.SampleAsync(samplingParams, cancellationToken); + var sampleResult = await server.SampleAsync(samplingParams, cancellationToken: cancellationToken); return $"LLM sampling result: {sampleResult.Content.OfType().FirstOrDefault()?.Text}"; } diff --git a/samples/TestServerWithHosting/Tools/SampleLlmTool.cs b/samples/TestServerWithHosting/Tools/SampleLlmTool.cs index 7d4c61784..cd90220c9 100644 --- a/samples/TestServerWithHosting/Tools/SampleLlmTool.cs +++ b/samples/TestServerWithHosting/Tools/SampleLlmTool.cs @@ -18,7 +18,7 @@ public static async Task SampleLLM( CancellationToken cancellationToken) { var samplingParams = CreateRequestSamplingParams(prompt ?? string.Empty, "sampleLLM", maxTokens); - var sampleResult = await thisServer.SampleAsync(samplingParams, cancellationToken); + var sampleResult = await thisServer.SampleAsync(samplingParams, cancellationToken: cancellationToken); return $"LLM sampling result: {sampleResult.Content.OfType().FirstOrDefault()?.Text}"; } diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index b96f5ef11..5ec7df464 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -4,6 +4,7 @@ using ModelContextProtocol.Server; using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Client; @@ -80,31 +81,32 @@ public static async Task ResumeSessionAsync( /// /// Sends a ping request to verify server connectivity. /// + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A task that completes when the ping is successful. /// The server cannot be reached or returned an error response. - public Task PingAsync(CancellationToken cancellationToken = default) + /// Thrown when the server cannot be reached or returns an error response. + public ValueTask PingAsync(RequestOptions? options = null, CancellationToken cancellationToken = default) { - var opts = McpJsonUtilities.DefaultOptions; - opts.MakeReadOnly(); - return SendRequestAsync( + return SendRequestAsync( RequestMethods.Ping, - parameters: null, - serializerOptions: opts, - cancellationToken: cancellationToken).AsTask(); + new PingRequestParams { Meta = options?.Meta }, + McpJsonUtilities.JsonContext.Default.PingRequestParams, + McpJsonUtilities.JsonContext.Default.PingResult, + cancellationToken: cancellationToken); } /// /// Retrieves a list of available tools from the server. /// - /// The serializer options governing tool parameter serialization. If null, the default options are used. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A list of all available tools as instances. public async ValueTask> ListToolsAsync( - JsonSerializerOptions? serializerOptions = null, + RequestOptions? options = null, CancellationToken cancellationToken = default) { - serializerOptions ??= McpJsonUtilities.DefaultOptions; + var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; serializerOptions.MakeReadOnly(); List? tools = null; @@ -113,7 +115,7 @@ public async ValueTask> ListToolsAsync( { var toolResults = await SendRequestAsync( RequestMethods.ToolsList, - new() { Cursor = cursor }, + new() { Cursor = cursor, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -134,14 +136,14 @@ public async ValueTask> ListToolsAsync( /// /// Creates an enumerable for asynchronously enumerating all available tools from the server. /// - /// The serializer options governing tool parameter serialization. If null, the default options are used. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// An asynchronous sequence of all available tools as instances. public async IAsyncEnumerable EnumerateToolsAsync( - JsonSerializerOptions? serializerOptions = null, + RequestOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - serializerOptions ??= McpJsonUtilities.DefaultOptions; + var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; serializerOptions.MakeReadOnly(); string? cursor = null; @@ -149,7 +151,7 @@ public async IAsyncEnumerable EnumerateToolsAsync( { var toolResults = await SendRequestAsync( RequestMethods.ToolsList, - new() { Cursor = cursor }, + new() { Cursor = cursor, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.ListToolsRequestParams, McpJsonUtilities.JsonContext.Default.ListToolsResult, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -167,9 +169,11 @@ public async IAsyncEnumerable EnumerateToolsAsync( /// /// Retrieves a list of available prompts from the server. /// + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A list of all available prompts as instances. public async ValueTask> ListPromptsAsync( + RequestOptions? options = null, CancellationToken cancellationToken = default) { List? prompts = null; @@ -178,7 +182,7 @@ public async ValueTask> ListPromptsAsync( { var promptResults = await SendRequestAsync( RequestMethods.PromptsList, - new() { Cursor = cursor }, + new() { Cursor = cursor, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.ListPromptsRequestParams, McpJsonUtilities.JsonContext.Default.ListPromptsResult, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -199,9 +203,11 @@ public async ValueTask> ListPromptsAsync( /// /// Creates an enumerable for asynchronously enumerating all available prompts from the server. /// + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// An asynchronous sequence of all available prompts as instances. public async IAsyncEnumerable EnumeratePromptsAsync( + RequestOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { string? cursor = null; @@ -209,7 +215,7 @@ public async IAsyncEnumerable EnumeratePromptsAsync( { var promptResults = await SendRequestAsync( RequestMethods.PromptsList, - new() { Cursor = cursor }, + new() { Cursor = cursor, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.ListPromptsRequestParams, McpJsonUtilities.JsonContext.Default.ListPromptsResult, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -229,23 +235,23 @@ public async IAsyncEnumerable EnumeratePromptsAsync( /// /// The name of the prompt to retrieve. /// Optional arguments for the prompt. The dictionary keys are parameter names, and the values are the argument values. - /// The serialization options governing argument serialization. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A task containing the prompt's result with content and messages. public ValueTask GetPromptAsync( string name, IReadOnlyDictionary? arguments = null, - JsonSerializerOptions? serializerOptions = null, + RequestOptions? options = null, CancellationToken cancellationToken = default) { Throw.IfNullOrWhiteSpace(name); - serializerOptions ??= McpJsonUtilities.DefaultOptions; + var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; serializerOptions.MakeReadOnly(); return SendRequestAsync( RequestMethods.PromptsGet, - new() { Name = name, Arguments = ToArgumentsDictionary(arguments, serializerOptions) }, + new() { Name = name, Arguments = ToArgumentsDictionary(arguments, serializerOptions), Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.GetPromptRequestParams, McpJsonUtilities.JsonContext.Default.GetPromptResult, cancellationToken: cancellationToken); @@ -254,9 +260,11 @@ public ValueTask GetPromptAsync( /// /// Retrieves a list of available resource templates from the server. /// + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A list of all available resource templates as instances. public async ValueTask> ListResourceTemplatesAsync( + RequestOptions? options = null, CancellationToken cancellationToken = default) { List? resourceTemplates = null; @@ -266,7 +274,7 @@ public async ValueTask> ListResourceTemplatesAs { var templateResults = await SendRequestAsync( RequestMethods.ResourcesTemplatesList, - new() { Cursor = cursor }, + new() { Cursor = cursor, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -287,9 +295,11 @@ public async ValueTask> ListResourceTemplatesAs /// /// Creates an enumerable for asynchronously enumerating all available resource templates from the server. /// + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// An asynchronous sequence of all available resource templates as instances. public async IAsyncEnumerable EnumerateResourceTemplatesAsync( + RequestOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { string? cursor = null; @@ -297,7 +307,7 @@ public async IAsyncEnumerable EnumerateResourceTempla { var templateResults = await SendRequestAsync( RequestMethods.ResourcesTemplatesList, - new() { Cursor = cursor }, + new() { Cursor = cursor, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourceTemplatesResult, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -315,9 +325,11 @@ public async IAsyncEnumerable EnumerateResourceTempla /// /// Retrieves a list of available resources from the server. /// + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A list of all available resources as instances. public async ValueTask> ListResourcesAsync( + RequestOptions? options = null, CancellationToken cancellationToken = default) { List? resources = null; @@ -327,7 +339,7 @@ public async ValueTask> ListResourcesAsync( { var resourceResults = await SendRequestAsync( RequestMethods.ResourcesList, - new() { Cursor = cursor }, + new() { Cursor = cursor, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourcesResult, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -348,9 +360,11 @@ public async ValueTask> ListResourcesAsync( /// /// Creates an enumerable for asynchronously enumerating all available resources from the server. /// + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// An asynchronous sequence of all available resources as instances. public async IAsyncEnumerable EnumerateResourcesAsync( + RequestOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { string? cursor = null; @@ -358,7 +372,7 @@ public async IAsyncEnumerable EnumerateResourcesAsync( { var resourceResults = await SendRequestAsync( RequestMethods.ResourcesList, - new() { Cursor = cursor }, + new() { Cursor = cursor, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourcesResult, cancellationToken: cancellationToken).ConfigureAwait(false); @@ -377,15 +391,16 @@ public async IAsyncEnumerable EnumerateResourcesAsync( /// Reads a resource from the server. /// /// The URI of the resource. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . public ValueTask ReadResourceAsync( - string uri, CancellationToken cancellationToken = default) + string uri, RequestOptions? options = null, CancellationToken cancellationToken = default) { Throw.IfNullOrWhiteSpace(uri); return SendRequestAsync( RequestMethods.ResourcesRead, - new() { Uri = uri }, + new() { Uri = uri, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams, McpJsonUtilities.JsonContext.Default.ReadResourceResult, cancellationToken: cancellationToken); @@ -395,13 +410,14 @@ public ValueTask ReadResourceAsync( /// Reads a resource from the server. /// /// The URI of the resource. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . public ValueTask ReadResourceAsync( - Uri uri, CancellationToken cancellationToken = default) + Uri uri, RequestOptions? options = null, CancellationToken cancellationToken = default) { Throw.IfNull(uri); - return ReadResourceAsync(uri.ToString(), cancellationToken); + return ReadResourceAsync(uri.ToString(), options, cancellationToken); } /// @@ -409,16 +425,17 @@ public ValueTask ReadResourceAsync( /// /// The URI template of the resource. /// Arguments to use to format . + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . public ValueTask ReadResourceAsync( - string uriTemplate, IReadOnlyDictionary arguments, CancellationToken cancellationToken = default) + string uriTemplate, IReadOnlyDictionary arguments, RequestOptions? options = null, CancellationToken cancellationToken = default) { Throw.IfNullOrWhiteSpace(uriTemplate); Throw.IfNull(arguments); return SendRequestAsync( RequestMethods.ResourcesRead, - new() { Uri = UriTemplate.FormatUri(uriTemplate, arguments) }, + new() { Uri = UriTemplate.FormatUri(uriTemplate, arguments), Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.ReadResourceRequestParams, McpJsonUtilities.JsonContext.Default.ReadResourceResult, cancellationToken: cancellationToken); @@ -453,15 +470,16 @@ public ValueTask CompleteAsync(Reference reference, string argum /// Subscribes to a resource on the server to receive notifications when it changes. /// /// The URI of the resource to which to subscribe. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A task that represents the asynchronous operation. - public Task SubscribeToResourceAsync(string uri, CancellationToken cancellationToken = default) + public Task SubscribeToResourceAsync(string uri, RequestOptions? options = null, CancellationToken cancellationToken = default) { Throw.IfNullOrWhiteSpace(uri); return SendRequestAsync( RequestMethods.ResourcesSubscribe, - new() { Uri = uri }, + new() { Uri = uri, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.SubscribeRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); @@ -471,28 +489,30 @@ public Task SubscribeToResourceAsync(string uri, CancellationToken cancellationT /// Subscribes to a resource on the server to receive notifications when it changes. /// /// The URI of the resource to which to subscribe. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A task that represents the asynchronous operation. - public Task SubscribeToResourceAsync(Uri uri, CancellationToken cancellationToken = default) + public Task SubscribeToResourceAsync(Uri uri, RequestOptions? options = null, CancellationToken cancellationToken = default) { Throw.IfNull(uri); - return SubscribeToResourceAsync(uri.ToString(), cancellationToken); + return SubscribeToResourceAsync(uri.ToString(), options, cancellationToken); } /// /// Unsubscribes from a resource on the server to stop receiving notifications about its changes. /// /// The URI of the resource to unsubscribe from. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A task that represents the asynchronous operation. - public Task UnsubscribeFromResourceAsync(string uri, CancellationToken cancellationToken = default) + public Task UnsubscribeFromResourceAsync(string uri, RequestOptions? options = null, CancellationToken cancellationToken = default) { Throw.IfNullOrWhiteSpace(uri); return SendRequestAsync( RequestMethods.ResourcesUnsubscribe, - new() { Uri = uri }, + new() { Uri = uri, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.UnsubscribeRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); @@ -502,13 +522,14 @@ public Task UnsubscribeFromResourceAsync(string uri, CancellationToken cancellat /// Unsubscribes from a resource on the server to stop receiving notifications about its changes. /// /// The URI of the resource to unsubscribe from. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A task that represents the asynchronous operation. - public Task UnsubscribeFromResourceAsync(Uri uri, CancellationToken cancellationToken = default) + public Task UnsubscribeFromResourceAsync(Uri uri, RequestOptions? options = null, CancellationToken cancellationToken = default) { Throw.IfNull(uri); - return UnsubscribeFromResourceAsync(uri.ToString(), cancellationToken); + return UnsubscribeFromResourceAsync(uri.ToString(), options, cancellationToken); } /// @@ -517,23 +538,23 @@ public Task UnsubscribeFromResourceAsync(Uri uri, CancellationToken cancellation /// The name of the tool to call on the server. /// An optional dictionary of arguments to pass to the tool. /// An optional progress reporter for server notifications. - /// The JSON serializer options. + /// Optional request options including metadata, serialization settings, and progress tracking. /// A cancellation token. /// The from the tool execution. public ValueTask CallToolAsync( string toolName, IReadOnlyDictionary? arguments = null, IProgress? progress = null, - JsonSerializerOptions? serializerOptions = null, + RequestOptions? options = null, CancellationToken cancellationToken = default) { Throw.IfNull(toolName); - serializerOptions ??= McpJsonUtilities.DefaultOptions; + var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; serializerOptions.MakeReadOnly(); if (progress is not null) { - return SendRequestWithProgressAsync(toolName, arguments, progress, serializerOptions, cancellationToken); + return SendRequestWithProgressAsync(toolName, arguments, progress, options?.Meta, serializerOptions, cancellationToken); } return SendRequestAsync( @@ -542,6 +563,7 @@ public ValueTask CallToolAsync( { Name = toolName, Arguments = ToArgumentsDictionary(arguments, serializerOptions), + Meta = options?.Meta, }, McpJsonUtilities.JsonContext.Default.CallToolRequestParams, McpJsonUtilities.JsonContext.Default.CallToolResult, @@ -551,6 +573,7 @@ async ValueTask SendRequestWithProgressAsync( string toolName, IReadOnlyDictionary? arguments, IProgress progress, + JsonObject? meta, JsonSerializerOptions serializerOptions, CancellationToken cancellationToken) { @@ -568,13 +591,16 @@ async ValueTask SendRequestWithProgressAsync( return default; }).ConfigureAwait(false); + var metaWithProgress = meta is not null ? new JsonObject(meta) : new JsonObject(); + metaWithProgress["progressToken"] = JsonValue.Create(progressToken.Token as string); + return await SendRequestAsync( RequestMethods.ToolsCall, new() { Name = toolName, Arguments = ToArgumentsDictionary(arguments, serializerOptions), - ProgressToken = progressToken, + Meta = metaWithProgress, }, McpJsonUtilities.JsonContext.Default.CallToolRequestParams, McpJsonUtilities.JsonContext.Default.CallToolResult, @@ -586,13 +612,14 @@ async ValueTask SendRequestWithProgressAsync( /// Sets the logging level for the server to control which log messages are sent to the client. /// /// The minimum severity level of log messages to receive from the server. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A task representing the asynchronous operation. - public Task SetLoggingLevel(LoggingLevel level, CancellationToken cancellationToken = default) + public Task SetLoggingLevel(LoggingLevel level, RequestOptions? options = null, CancellationToken cancellationToken = default) { return SendRequestAsync( RequestMethods.LoggingSetLevel, - new() { Level = level }, + new() { Level = level, Meta = options?.Meta }, McpJsonUtilities.JsonContext.Default.SetLevelRequestParams, McpJsonUtilities.JsonContext.Default.EmptyResult, cancellationToken: cancellationToken).AsTask(); @@ -602,10 +629,11 @@ public Task SetLoggingLevel(LoggingLevel level, CancellationToken cancellationTo /// Sets the logging level for the server to control which log messages are sent to the client. /// /// The minimum severity level of log messages to receive from the server. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A task representing the asynchronous operation. - public Task SetLoggingLevel(LogLevel level, CancellationToken cancellationToken = default) => - SetLoggingLevel(McpServerImpl.ToLoggingLevel(level), cancellationToken); + public Task SetLoggingLevel(LogLevel level, RequestOptions? options = null, CancellationToken cancellationToken = default) => + SetLoggingLevel(McpServerImpl.ToLoggingLevel(level), options, cancellationToken); /// Converts a dictionary with values to a dictionary with values. private static Dictionary? ToArgumentsDictionary( diff --git a/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs b/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs index 354cd314c..ccd335d5a 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs @@ -97,6 +97,6 @@ public async ValueTask GetAsync( arguments as IReadOnlyDictionary ?? arguments?.ToDictionary(); - return await _client.GetPromptAsync(ProtocolPrompt.Name, argDict, serializerOptions, cancellationToken: cancellationToken).ConfigureAwait(false); + return await _client.GetPromptAsync(ProtocolPrompt.Name, argDict, new RequestOptions(){JsonSerializerOptions = serializerOptions}, cancellationToken).ConfigureAwait(false); } } diff --git a/src/ModelContextProtocol.Core/Client/McpClientResource.cs b/src/ModelContextProtocol.Core/Client/McpClientResource.cs index fe3fb1c1b..e86958649 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientResource.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientResource.cs @@ -79,10 +79,10 @@ public McpClientResource(McpClient client, Resource resource) /// A containing the resource's result with content and messages. /// /// - /// This is a convenience method that internally calls . + /// This is a convenience method that internally calls . /// /// public ValueTask ReadAsync( CancellationToken cancellationToken = default) => - _client.ReadResourceAsync(Uri, cancellationToken); + _client.ReadResourceAsync(Uri, cancellationToken: cancellationToken); } diff --git a/src/ModelContextProtocol.Core/Client/McpClientResourceTemplate.cs b/src/ModelContextProtocol.Core/Client/McpClientResourceTemplate.cs index ddbdfe158..932027586 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientResourceTemplate.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientResourceTemplate.cs @@ -84,5 +84,5 @@ public McpClientResourceTemplate(McpClient client, ResourceTemplate resourceTemp public ValueTask ReadAsync( IReadOnlyDictionary arguments, CancellationToken cancellationToken = default) => - _client.ReadResourceAsync(UriTemplate, arguments, cancellationToken); + _client.ReadResourceAsync(UriTemplate, arguments, cancellationToken: cancellationToken); } diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index d07608212..b6244e6c4 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientTool.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientTool.cs @@ -193,7 +193,15 @@ public ValueTask CallAsync( IProgress? progress = null, JsonSerializerOptions? serializerOptions = null, CancellationToken cancellationToken = default) => - _client.CallToolAsync(ProtocolTool.Name, arguments, progress, serializerOptions, cancellationToken); + _client.CallToolAsync( + ProtocolTool.Name, + arguments, + progress, + serializerOptions is null ? null : new RequestOptions + { + JsonSerializerOptions = serializerOptions + }, + cancellationToken); /// /// Creates a new instance of the tool but modified to return the specified name from its property. diff --git a/src/ModelContextProtocol.Core/McpJsonUtilities.cs b/src/ModelContextProtocol.Core/McpJsonUtilities.cs index 1915c7617..ab89bb23e 100644 --- a/src/ModelContextProtocol.Core/McpJsonUtilities.cs +++ b/src/ModelContextProtocol.Core/McpJsonUtilities.cs @@ -132,6 +132,7 @@ internal static bool IsValidMcpToolSchema(JsonElement element) [JsonSerializable(typeof(ListRootsResult))] [JsonSerializable(typeof(ListToolsRequestParams))] [JsonSerializable(typeof(ListToolsResult))] + [JsonSerializable(typeof(PingRequestParams))] [JsonSerializable(typeof(PingResult))] [JsonSerializable(typeof(ReadResourceRequestParams))] [JsonSerializable(typeof(ReadResourceResult))] diff --git a/src/ModelContextProtocol.Core/McpSession.Methods.cs b/src/ModelContextProtocol.Core/McpSession.Methods.cs index 79a6e1a0b..49b6d5913 100644 --- a/src/ModelContextProtocol.Core/McpSession.Methods.cs +++ b/src/ModelContextProtocol.Core/McpSession.Methods.cs @@ -150,6 +150,7 @@ internal Task SendNotificationAsync( /// /// The token that identifies the operation for which progress is being reported. /// The progress update to send, containing information such as percentage complete or status message. + /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . /// A task representing the completion of the notification operation (not the operation being tracked). /// The current session instance is . @@ -166,6 +167,7 @@ internal Task SendNotificationAsync( public Task NotifyProgressAsync( ProgressToken progressToken, ProgressNotificationValue progress, + RequestOptions? options = null, CancellationToken cancellationToken = default) { return SendNotificationAsync( @@ -174,6 +176,7 @@ public Task NotifyProgressAsync( { ProgressToken = progressToken, Progress = progress, + Meta = options?.Meta, }, McpJsonUtilities.JsonContext.Default.ProgressNotificationParams, cancellationToken); diff --git a/src/ModelContextProtocol.Core/Protocol/PingRequestParams.cs b/src/ModelContextProtocol.Core/Protocol/PingRequestParams.cs new file mode 100644 index 000000000..e8557f55f --- /dev/null +++ b/src/ModelContextProtocol.Core/Protocol/PingRequestParams.cs @@ -0,0 +1,11 @@ +namespace ModelContextProtocol.Protocol; + +/// +/// Represents the parameters used with a request to verify +/// server connectivity. +/// +/// +/// The server responds with a . +/// See the schema for details. +/// +public sealed class PingRequestParams : RequestParams; \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs index b45f55f7a..0a0586a71 100644 --- a/src/ModelContextProtocol.Core/Protocol/RequestParams.cs +++ b/src/ModelContextProtocol.Core/Protocol/RequestParams.cs @@ -26,7 +26,7 @@ private protected RequestParams() public JsonObject? Meta { get; set; } /// - /// Gets or sets an opaque token that will be attached to any subsequent progress notifications. + /// Gets the opaque token that will be attached to any subsequent progress notifications. /// [JsonIgnore] public ProgressToken? ProgressToken @@ -48,21 +48,5 @@ public ProgressToken? ProgressToken return null; } - set - { - if (value is null) - { - Meta?.Remove("progressToken"); - } - else - { - (Meta ??= [])["progressToken"] = value.Value.Token switch - { - string s => JsonValue.Create(s), - long l => JsonValue.Create(l), - _ => throw new InvalidOperationException("ProgressToken must be a string or a long.") - }; - } - } } } diff --git a/src/ModelContextProtocol.Core/README.md b/src/ModelContextProtocol.Core/README.md index 111c3e76f..69913a150 100644 --- a/src/ModelContextProtocol.Core/README.md +++ b/src/ModelContextProtocol.Core/README.md @@ -50,7 +50,7 @@ foreach (var tool in await client.ListToolsAsync()) var result = await client.CallToolAsync( "echo", new Dictionary() { ["message"] = "Hello MCP!" }, - cancellationToken:CancellationToken.None); + cancellationToken: CancellationToken.None); // echo always returns one and only one text content object Console.WriteLine(result.Content.First(c => c.Type == "text").Text); @@ -83,13 +83,13 @@ using System.ComponentModel; var serverOptions = new McpServerOptions(); // Add tools directly -serverOptions.Capabilities.Tools = new() +serverOptions.Capabilities.Tools = new() { ListChanged = true, ToolCollection = [ McpServerTool.Create((string message) => $"hello {message}", new() { - Name = "echo", + Name = "echo", Description = "Echoes the message back to the client." }) ] diff --git a/src/ModelContextProtocol.Core/RequestOptions.cs b/src/ModelContextProtocol.Core/RequestOptions.cs new file mode 100644 index 000000000..b5f60241b --- /dev/null +++ b/src/ModelContextProtocol.Core/RequestOptions.cs @@ -0,0 +1,62 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol; + +/// +/// Contains optional parameters for MCP requests. +/// +public sealed class RequestOptions +{ + /// + /// Optional metadata to include in the request. + /// + private JsonObject? _meta; + + /// + /// Optional metadata to include in the request. + /// When getting, automatically includes the progress token if set. + /// + public JsonObject? Meta + { + get + { + if (ProgressToken == null) + { + return _meta; + } + + // Clone existing metadata or create a new one + var meta = _meta?.DeepClone() as JsonObject ?? new JsonObject(); + + // Add progress token to metadata + meta["progressToken"] = ProgressToken.Value.Token switch + { + string s => JsonValue.Create(s), + long l => JsonValue.Create(l), + _ => null + }; + + return meta; + } + set => _meta = value; + } + + /// + /// The serializer options governing tool parameter serialization. If null, the default options are used. + /// + public JsonSerializerOptions? JsonSerializerOptions { get; set; } + + /// + /// The progress token for tracking long-running operations. + /// + public ProgressToken? ProgressToken { get; set; } + + /// + /// Initializes a new instance of the class. + /// + public RequestOptions() + { + } +} \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs index f8b7b7781..317ae83bd 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.Methods.cs @@ -68,19 +68,19 @@ public ValueTask SampleAsync( /// Requests to sample an LLM via the client using the provided chat messages and options. /// /// The messages to send as part of the request. - /// The options to use for the request, including model parameters and constraints. + /// The options to use for the request, including model parameters and constraints. /// The to monitor for cancellation requests. The default is . /// A task containing the chat response from the model. /// is . /// The client does not support sampling. public async Task SampleAsync( - IEnumerable messages, ChatOptions? options = default, CancellationToken cancellationToken = default) + IEnumerable messages, ChatOptions? chatOptions = default, CancellationToken cancellationToken = default) { Throw.IfNull(messages); StringBuilder? systemPrompt = null; - if (options?.Instructions is { } instructions) + if (chatOptions?.Instructions is { } instructions) { (systemPrompt ??= new()).Append(instructions); } @@ -126,15 +126,15 @@ public async Task SampleAsync( } ModelPreferences? modelPreferences = null; - if (options?.ModelId is { } modelId) + if (chatOptions?.ModelId is { } modelId) { modelPreferences = new() { Hints = [new() { Name = modelId }] }; } IList? tools = null; - if (options?.Tools is { Count: > 0 }) + if (chatOptions?.Tools is { Count: > 0 }) { - foreach (var tool in options.Tools) + foreach (var tool in chatOptions.Tools) { if (tool is AIFunctionDeclaration af) { @@ -149,7 +149,7 @@ public async Task SampleAsync( } } - ToolChoice? toolChoice = options?.ToolMode switch + ToolChoice? toolChoice = chatOptions?.ToolMode switch { NoneChatToolMode => new() { Mode = ToolChoice.ModeNone }, AutoChatToolMode => new() { Mode = ToolChoice.ModeAuto }, @@ -159,15 +159,15 @@ public async Task SampleAsync( var result = await SampleAsync(new() { - MaxTokens = options?.MaxOutputTokens ?? ServerOptions.MaxSamplingOutputTokens, + MaxTokens = chatOptions?.MaxOutputTokens ?? ServerOptions.MaxSamplingOutputTokens, Messages = samplingMessages, ModelPreferences = modelPreferences, - StopSequences = options?.StopSequences?.ToArray(), + StopSequences = chatOptions?.StopSequences?.ToArray(), SystemPrompt = systemPrompt?.ToString(), - Temperature = options?.Temperature, + Temperature = chatOptions?.Temperature, ToolChoice = toolChoice, Tools = tools, - Meta = options?.AdditionalProperties?.ToJsonObject(), + Meta = chatOptions?.AdditionalProperties?.ToJsonObject(), }, cancellationToken).ConfigureAwait(false); List responseContents = []; @@ -472,14 +472,14 @@ private sealed class SamplingChatClient(McpServer server) : IChatClient private readonly McpServer _server = server; /// - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => - _server.SampleAsync(messages, options, cancellationToken); + public Task GetResponseAsync(IEnumerable messages, ChatOptions? chatOptions = null, CancellationToken cancellationToken = default) => + _server.SampleAsync(messages, chatOptions, cancellationToken); /// async IAsyncEnumerable IChatClient.GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) + IEnumerable messages, ChatOptions? chatOptions, [EnumeratorCancellation] CancellationToken cancellationToken) { - var response = await GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false); + var response = await GetResponseAsync(messages, chatOptions, cancellationToken).ConfigureAwait(false); foreach (var update in response.ToChatResponseUpdates()) { yield return update; diff --git a/src/ModelContextProtocol.Core/TokenProgress.cs b/src/ModelContextProtocol.Core/TokenProgress.cs index 6b7a91e00..d41822c23 100644 --- a/src/ModelContextProtocol.Core/TokenProgress.cs +++ b/src/ModelContextProtocol.Core/TokenProgress.cs @@ -11,6 +11,6 @@ internal sealed class TokenProgress(McpSession session, ProgressToken progressTo /// public void Report(ProgressNotificationValue value) { - _ = session.NotifyProgressAsync(progressToken, value, CancellationToken.None); + _ = session.NotifyProgressAsync(progressToken, value, cancellationToken: CancellationToken.None); } } diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs index acf5d469e..c2ac09c79 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.AspNetCore.Tests/HttpServerIntegrationTests.cs @@ -35,7 +35,7 @@ public async Task ConnectAndPing_Sse_TestServer() // Act await using var client = await GetClientAsync(); - await client.PingAsync(TestContext.Current.CancellationToken); + await client.PingAsync(null, TestContext.Current.CancellationToken); // Assert Assert.NotNull(client); @@ -139,7 +139,7 @@ public async Task ListResources_Sse_TestServer() // act await using var client = await GetClientAsync(); - IList allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + IList allResources = await client.ListResourcesAsync(null, TestContext.Current.CancellationToken); // The everything server provides 100 test resources Assert.Equal(100, allResources.Count); @@ -155,7 +155,7 @@ public async Task ReadResource_Sse_TextResource() // Odd numbered resources are text in the everything server (despite the docs saying otherwise) // 1 is index 0, which is "even" in the 0-based index // We copied this oddity to the test server - var result = await client.ReadResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken); + var result = await client.ReadResourceAsync("test://static/resource/1", null, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Contents); @@ -174,7 +174,7 @@ public async Task ReadResource_Sse_BinaryResource() // Even numbered resources are binary in the everything server (despite the docs saying otherwise) // 2 is index 1, which is "odd" in the 0-based index // We copied this oddity to the test server - var result = await client.ReadResourceAsync("test://static/resource/2", TestContext.Current.CancellationToken); + var result = await client.ReadResourceAsync("test://static/resource/2", null, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Contents); @@ -190,7 +190,7 @@ public async Task ListPrompts_Sse_TestServer() // act await using var client = await GetClientAsync(); - var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken); + var prompts = await client.ListPromptsAsync(null, TestContext.Current.CancellationToken); // assert Assert.NotNull(prompts); diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index fe5c725ad..66fef57bb 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -193,7 +193,7 @@ private static void ConfigureTools(McpServerOptions options, string? cliArg) throw new McpProtocolException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams); } var sampleResult = await request.Server.SampleAsync(CreateRequestSamplingParams(prompt.ToString(), "sampleLLM", Convert.ToInt32(maxTokens.GetRawText())), - cancellationToken); + cancellationToken: cancellationToken); return new CallToolResult { diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index 2b6c9b852..0eb799ed4 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -187,7 +187,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st throw new McpProtocolException("Missing required arguments 'prompt' and 'maxTokens'", McpErrorCode.InvalidParams); } var sampleResult = await request.Server.SampleAsync(CreateRequestSamplingParams(prompt.ToString(), "sampleLLM", Convert.ToInt32(maxTokens.ToString())), - cancellationToken); + cancellationToken: cancellationToken); return new CallToolResult { diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs index 5014022c7..693dab282 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientResourceTemplateTests.cs @@ -75,7 +75,7 @@ public async Task UriTemplate_InputsProduceExpectedOutputs( { await using McpClient client = await CreateMcpClientForServer(); - var result = await client.ReadResourceAsync(uriTemplate, variables, TestContext.Current.CancellationToken); + var result = await client.ReadResourceAsync(uriTemplate, variables, null, TestContext.Current.CancellationToken); Assert.NotNull(result); var actualUri = Assert.IsType(Assert.Single(result.Contents)).Text; @@ -110,13 +110,13 @@ internal partial class JsonContext7 : JsonSerializerContext; // The JSON from the test case files has been extracted below. // Copyright 2011- The Authors - // + // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at - // + // // http://www.apache.org/licenses/LICENSE-2.0 - // + // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @@ -255,7 +255,7 @@ internal partial class JsonContext7 : JsonSerializerContext; ["X{.var:3}", "X.val"], ["X{.list}", "X.red,green,blue"], ["X{.list*}", "X.red.green.blue"], - ["X{.keys}", [ + ["X{.keys}", [ "X.comma,%2C,dot,.,semi,%3B", "X.comma,%2C,semi,%3B,dot,.", "X.dot,.,comma,%2C,semi,%3B", @@ -275,7 +275,7 @@ internal partial class JsonContext7 : JsonSerializerContext; "/semi,%3B,comma,%2C,dot,.", "/semi,%3B,dot,.,comma,%2C" ]], - ["{/keys*}", [ + ["{/keys*}", [ "/comma=%2C/dot=./semi=%3B", "/comma=%2C/semi=%3B/dot=.", "/dot=./comma=%2C/semi=%3B", @@ -286,7 +286,7 @@ internal partial class JsonContext7 : JsonSerializerContext; ["{;hello:5}", ";hello=Hello"], ["{;list}", ";list=red,green,blue"], ["{;list*}", ";list=red;list=green;list=blue"], - ["{;keys}", [ + ["{;keys}", [ ";keys=comma,%2C,dot,.,semi,%3B", ";keys=comma,%2C,semi,%3B,dot,.", ";keys=dot,.,comma,%2C,semi,%3B", @@ -294,7 +294,7 @@ internal partial class JsonContext7 : JsonSerializerContext; ";keys=semi,%3B,comma,%2C,dot,.", ";keys=semi,%3B,dot,.,comma,%2C" ]], - ["{;keys*}", [ + ["{;keys*}", [ ";comma=%2C;dot=.;semi=%3B", ";comma=%2C;semi=%3B;dot=.", ";dot=.;comma=%2C;semi=%3B", @@ -305,7 +305,7 @@ internal partial class JsonContext7 : JsonSerializerContext; ["{?var:3}", "?var=val"], ["{?list}", "?list=red,green,blue"], ["{?list*}", "?list=red&list=green&list=blue"], - ["{?keys}", [ + ["{?keys}", [ "?keys=comma,%2C,dot,.,semi,%3B", "?keys=comma,%2C,semi,%3B,dot,.", "?keys=dot,.,comma,%2C,semi,%3B", @@ -313,7 +313,7 @@ internal partial class JsonContext7 : JsonSerializerContext; "?keys=semi,%3B,comma,%2C,dot,.", "?keys=semi,%3B,dot,.,comma,%2C" ]], - ["{?keys*}", [ + ["{?keys*}", [ "?comma=%2C&dot=.&semi=%3B", "?comma=%2C&semi=%3B&dot=.", "?dot=.&comma=%2C&semi=%3B", @@ -324,7 +324,7 @@ internal partial class JsonContext7 : JsonSerializerContext; ["{&var:3}", "&var=val"], ["{&list}", "&list=red,green,blue"], ["{&list*}", "&list=red&list=green&list=blue"], - ["{&keys}", [ + ["{&keys}", [ "&keys=comma,%2C,dot,.,semi,%3B", "&keys=comma,%2C,semi,%3B,dot,.", "&keys=dot,.,comma,%2C,semi,%3B", @@ -332,7 +332,7 @@ internal partial class JsonContext7 : JsonSerializerContext; "&keys=semi,%3B,comma,%2C,dot,.", "&keys=semi,%3B,dot,.,comma,%2C" ]], - ["{&keys*}", [ + ["{&keys*}", [ "&comma=%2C&dot=.&semi=%3B", "&comma=%2C&semi=%3B&dot=.", "&dot=.&comma=%2C&semi=%3B", @@ -636,7 +636,7 @@ internal partial class JsonContext7 : JsonSerializerContext; "/semi,%3B,comma,%2C,dot,.", "/semi,%3B,dot,.,comma,%2C" ]], - ["{/keys*}", [ + ["{/keys*}", [ "/comma=%2C/dot=./semi=%3B", "/comma=%2C/semi=%3B/dot=.", "/dot=./comma=%2C/semi=%3B", @@ -679,7 +679,7 @@ internal partial class JsonContext7 : JsonSerializerContext; ["{;x,y,undef}", ";x=1024;y=768"], ["{;list}", ";list=red,green,blue"], ["{;list*}", ";list=red;list=green;list=blue"], - ["{;keys}", [ + ["{;keys}", [ ";keys=comma,%2C,dot,.,semi,%3B", ";keys=comma,%2C,semi,%3B,dot,.", ";keys=dot,.,comma,%2C,semi,%3B", @@ -687,7 +687,7 @@ internal partial class JsonContext7 : JsonSerializerContext; ";keys=semi,%3B,comma,%2C,dot,.", ";keys=semi,%3B,dot,.,comma,%2C" ]], - ["{;keys*}", [ + ["{;keys*}", [ ";comma=%2C;dot=.;semi=%3B", ";comma=%2C;semi=%3B;dot=.", ";dot=.;comma=%2C;semi=%3B", @@ -727,7 +727,7 @@ internal partial class JsonContext7 : JsonSerializerContext; ["{?var:3}", "?var=val"], ["{?list}", "?list=red,green,blue"], ["{?list*}", "?list=red&list=green&list=blue"], - ["{?keys}", [ + ["{?keys}", [ "?keys=comma,%2C,dot,.,semi,%3B", "?keys=comma,%2C,semi,%3B,dot,.", "?keys=dot,.,comma,%2C,semi,%3B", @@ -735,7 +735,7 @@ internal partial class JsonContext7 : JsonSerializerContext; "?keys=semi,%3B,comma,%2C,dot,.", "?keys=semi,%3B,dot,.,comma,%2C" ]], - ["{?keys*}", [ + ["{?keys*}", [ "?comma=%2C&dot=.&semi=%3B", "?comma=%2C&semi=%3B&dot=.", "?dot=.&comma=%2C&semi=%3B", @@ -775,7 +775,7 @@ internal partial class JsonContext7 : JsonSerializerContext; ["{&x,y,undef}", "&x=1024&y=768"], ["{&list}", "&list=red,green,blue"], ["{&list*}", "&list=red&list=green&list=blue"], - ["{&keys}", [ + ["{&keys}", [ "&keys=comma,%2C,dot,.,semi,%3B", "&keys=comma,%2C,semi,%3B,dot,.", "&keys=dot,.,comma,%2C,semi,%3B", @@ -783,7 +783,7 @@ internal partial class JsonContext7 : JsonSerializerContext; "&keys=semi,%3B,comma,%2C,dot,.", "&keys=semi,%3B,dot,.,comma,%2C" ]], - ["{&keys*}", [ + ["{&keys*}", [ "&comma=%2C&dot=.&semi=%3B", "&comma=%2C&semi=%3B&dot=.", "&dot=.&comma=%2C&semi=%3B", diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs index 604c31b8b..308f0abed 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientTests.cs @@ -311,13 +311,13 @@ public async Task EnumerateToolsAsync_FlowsJsonSerializerOptions() await using McpClient client = await CreateMcpClientForServer(); bool hasTools = false; - await foreach (var tool in client.EnumerateToolsAsync(options, TestContext.Current.CancellationToken)) + await foreach (var tool in client.EnumerateToolsAsync(new RequestOptions { JsonSerializerOptions = options }, TestContext.Current.CancellationToken)) { Assert.Same(options, tool.JsonSerializerOptions); hasTools = true; } - foreach (var tool in await client.ListToolsAsync(options, TestContext.Current.CancellationToken)) + foreach (var tool in await client.ListToolsAsync(new RequestOptions { JsonSerializerOptions = options }, TestContext.Current.CancellationToken)) { Assert.Same(options, tool.JsonSerializerOptions); } @@ -331,7 +331,7 @@ public async Task EnumerateToolsAsync_HonorsJsonSerializerOptions() JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; await using McpClient client = await CreateMcpClientForServer(); - var tool = (await client.ListToolsAsync(emptyOptions, TestContext.Current.CancellationToken)).First(); + var tool = (await client.ListToolsAsync(new RequestOptions { JsonSerializerOptions = emptyOptions }, TestContext.Current.CancellationToken)).First(); await Assert.ThrowsAsync(async () => await tool.InvokeAsync(new() { ["i"] = 42 }, TestContext.Current.CancellationToken)); } @@ -359,7 +359,7 @@ public async Task GetPromptsAsync_HonorsJsonSerializerOptions() JsonSerializerOptions emptyOptions = new() { TypeInfoResolver = JsonTypeInfoResolver.Combine() }; await using McpClient client = await CreateMcpClientForServer(); - await Assert.ThrowsAsync(async () => await client.GetPromptAsync("Prompt", new Dictionary { ["i"] = 42 }, emptyOptions, cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.GetPromptAsync("Prompt", new Dictionary { ["i"] = 42 }, new RequestOptions { JsonSerializerOptions = emptyOptions }, cancellationToken: TestContext.Current.CancellationToken)); } [Fact] @@ -368,7 +368,7 @@ public async Task WithName_ChangesToolName() JsonSerializerOptions options = new(JsonSerializerOptions.Default); await using McpClient client = await CreateMcpClientForServer(); - var tool = (await client.ListToolsAsync(options, TestContext.Current.CancellationToken)).First(); + var tool = (await client.ListToolsAsync(new RequestOptions { JsonSerializerOptions = options }, TestContext.Current.CancellationToken)).First(); var originalName = tool.Name; var renamedTool = tool.WithName("RenamedTool"); @@ -382,7 +382,7 @@ public async Task WithDescription_ChangesToolDescription() { JsonSerializerOptions options = new(JsonSerializerOptions.Default); await using McpClient client = await CreateMcpClientForServer(); - var tool = (await client.ListToolsAsync(options, TestContext.Current.CancellationToken)).FirstOrDefault(); + var tool = (await client.ListToolsAsync(new RequestOptions { JsonSerializerOptions = options }, TestContext.Current.CancellationToken)).FirstOrDefault(); var originalDescription = tool?.Description; var redescribedTool = tool?.WithDescription("ToolWithNewDescription"); Assert.NotNull(redescribedTool); @@ -456,7 +456,7 @@ public async Task AsClientLoggerProvider_MessagesSentToClient() Assert.False(logger.IsEnabled(LogLevel.Error)); Assert.False(logger.IsEnabled(LogLevel.Critical)); - await client.SetLoggingLevel(LoggingLevel.Info, TestContext.Current.CancellationToken); + await client.SetLoggingLevel(LoggingLevel.Info, options: null, TestContext.Current.CancellationToken); DateTime start = DateTime.UtcNow; while (Server.LoggingLevel is null) diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index ff6f56e24..0b29b1786 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -34,7 +34,7 @@ public async Task ConnectAndPing_Stdio(string clientId) // Act await using var client = await _fixture.CreateClientAsync(clientId); - await client.PingAsync(TestContext.Current.CancellationToken); + await client.PingAsync(null, TestContext.Current.CancellationToken); // Assert Assert.NotNull(client); @@ -141,7 +141,7 @@ public async Task ListPrompts_Stdio(string clientId) // act await using var client = await _fixture.CreateClientAsync(clientId); - var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken); + var prompts = await client.ListPromptsAsync(null, TestContext.Current.CancellationToken); // assert Assert.NotEmpty(prompts); @@ -193,7 +193,7 @@ public async Task GetPrompt_NonExistent_ThrowsException(string clientId) // act await using var client = await _fixture.CreateClientAsync(clientId); - await Assert.ThrowsAsync(async () => + await Assert.ThrowsAsync(async () => await client.GetPromptAsync("non_existent_prompt", null, cancellationToken: TestContext.Current.CancellationToken)); } @@ -206,7 +206,7 @@ public async Task ListResourceTemplates_Stdio(string clientId) // act await using var client = await _fixture.CreateClientAsync(clientId); - IList allResourceTemplates = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken); + IList allResourceTemplates = await client.ListResourceTemplatesAsync(null, TestContext.Current.CancellationToken); // The server provides a single test resource template Assert.Single(allResourceTemplates); @@ -221,7 +221,7 @@ public async Task ListResources_Stdio(string clientId) // act await using var client = await _fixture.CreateClientAsync(clientId); - IList allResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + IList allResources = await client.ListResourcesAsync(null, TestContext.Current.CancellationToken); // The server provides 100 test resources Assert.Equal(100, allResources.Count); @@ -237,7 +237,7 @@ public async Task ReadResource_Stdio_TextResource(string clientId) await using var client = await _fixture.CreateClientAsync(clientId); // Odd numbered resources are text in the everything server (despite the docs saying otherwise) // 1 is index 0, which is "even" in the 0-based index - var result = await client.ReadResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken); + var result = await client.ReadResourceAsync("test://static/resource/1", null, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Contents); @@ -256,7 +256,7 @@ public async Task ReadResource_Stdio_BinaryResource(string clientId) await using var client = await _fixture.CreateClientAsync(clientId); // Even numbered resources are binary in the everything server (despite the docs saying otherwise) // 2 is index 1, which is "odd" in the 0-based index - var result = await client.ReadResourceAsync("test://static/resource/2", TestContext.Current.CancellationToken); + var result = await client.ReadResourceAsync("test://static/resource/2", null, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Contents); @@ -290,7 +290,7 @@ public async Task SubscribeResource_Stdio() } }); - await client.SubscribeToResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken); + await client.SubscribeToResourceAsync("test://static/resource/1", null, TestContext.Current.CancellationToken); await tcs.Task; } @@ -319,13 +319,13 @@ public async Task UnsubscribeResource_Stdio() ] } }); - await client.SubscribeToResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken); + await client.SubscribeToResourceAsync("test://static/resource/1", null, TestContext.Current.CancellationToken); // wait until we received a notification await receivedNotification.Task; // unsubscribe - await client.UnsubscribeFromResourceAsync("test://static/resource/1", TestContext.Current.CancellationToken); + await client.UnsubscribeFromResourceAsync("test://static/resource/1", null, TestContext.Current.CancellationToken); } [Theory] @@ -408,7 +408,7 @@ public async Task Sampling_Stdio(string clientId) //[Theory] //[MemberData(nameof(GetClients))] //public async Task Roots_Stdio_EverythingServer(string clientId) - //{ + //{ // var rootsHandlerCalls = 0; // var testRoots = new List // { @@ -573,7 +573,7 @@ public async Task SetLoggingLevel_ReceivesLoggingMessages(string clientId) }); // act - await client.SetLoggingLevel(LoggingLevel.Debug, TestContext.Current.CancellationToken); + await client.SetLoggingLevel(LoggingLevel.Debug, options: null, TestContext.Current.CancellationToken); // assert await receivedNotification.Task; diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs index 8fdeacb9b..40aa343b8 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsPromptsTests.cs @@ -100,7 +100,7 @@ public async Task Can_List_And_Call_Registered_Prompts() { await using McpClient client = await CreateMcpClientForServer(); - var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken); + var prompts = await client.ListPromptsAsync(null, TestContext.Current.CancellationToken); Assert.Equal(6, prompts.Count); var prompt = prompts.First(t => t.Name == "returns_chat_messages"); @@ -129,7 +129,7 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() { await using McpClient client = await CreateMcpClientForServer(); - var prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken); + var prompts = await client.ListPromptsAsync(null, TestContext.Current.CancellationToken); Assert.Equal(6, prompts.Count); Channel listChanged = Channel.CreateUnbounded(); @@ -150,7 +150,7 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() serverPrompts.Add(newPrompt); await notificationRead; - prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken); + prompts = await client.ListPromptsAsync(null, TestContext.Current.CancellationToken); Assert.Equal(7, prompts.Count); Assert.Contains(prompts, t => t.Name == "NewPrompt"); @@ -160,7 +160,7 @@ public async Task Can_Be_Notified_Of_Prompt_Changes() await notificationRead; } - prompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken); + prompts = await client.ListPromptsAsync(null, TestContext.Current.CancellationToken); Assert.Equal(6, prompts.Count); Assert.DoesNotContain(prompts, t => t.Name == "NewPrompt"); } diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 4dd2c3d4b..da5361641 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -129,7 +129,7 @@ public async Task Can_List_And_Call_Registered_Resources() Assert.NotNull(client.ServerCapabilities.Resources); - var resources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + var resources = await client.ListResourcesAsync(null, TestContext.Current.CancellationToken); Assert.Equal(5, resources.Count); var resource = resources.First(t => t.Name == "some_neat_direct_resource"); @@ -146,7 +146,7 @@ public async Task Can_List_And_Call_Registered_ResourceTemplates() { await using McpClient client = await CreateMcpClientForServer(); - var resources = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken); + var resources = await client.ListResourceTemplatesAsync(null, TestContext.Current.CancellationToken); Assert.Equal(3, resources.Count); var resource = resources.First(t => t.Name == "some_neat_templated_resource"); @@ -163,7 +163,7 @@ public async Task Can_Be_Notified_Of_Resource_Changes() { await using McpClient client = await CreateMcpClientForServer(); - var resources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + var resources = await client.ListResourcesAsync(null, TestContext.Current.CancellationToken); Assert.Equal(5, resources.Count); Channel listChanged = Channel.CreateUnbounded(); @@ -184,7 +184,7 @@ public async Task Can_Be_Notified_Of_Resource_Changes() serverResources.Add(newResource); await notificationRead; - resources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + resources = await client.ListResourcesAsync(null, TestContext.Current.CancellationToken); Assert.Equal(6, resources.Count); Assert.Contains(resources, t => t.Name == "NewResource"); @@ -194,7 +194,7 @@ public async Task Can_Be_Notified_Of_Resource_Changes() await notificationRead; } - resources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + resources = await client.ListResourcesAsync(null, TestContext.Current.CancellationToken); Assert.Equal(5, resources.Count); Assert.DoesNotContain(resources, t => t.Name == "NewResource"); } diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs index 3739b7ae3..4fe51fc86 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsToolsTests.cs @@ -12,6 +12,7 @@ using System.ComponentModel; using System.IO.Pipelines; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.Json.Serialization; using System.Text.RegularExpressions; using System.Threading.Channels; @@ -686,7 +687,11 @@ public async Task HandlesIProgressParameter() new CallToolRequestParams { Name = progressTool.ProtocolTool.Name, - ProgressToken = new("abc123"), + // Set the progress token in Meta + Meta = new JsonObject + { + ["progressToken"] = "abc123", + } }, cancellationToken: TestContext.Current.CancellationToken); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceCapabilityIntegrationTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceCapabilityIntegrationTests.cs index c518b7030..0f457aae2 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceCapabilityIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceCapabilityIntegrationTests.cs @@ -41,7 +41,7 @@ public async Task Client_CanListResources_WhenSubscribeCapabilityIsManuallySet() Assert.True(client.ServerCapabilities.Resources.Subscribe, "Server should advertise Subscribe capability when manually set"); // The resources should be exposed and listable - var resources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + var resources = await client.ListResourcesAsync(null, TestContext.Current.CancellationToken); Assert.NotEmpty(resources); Assert.Contains(resources, r => r.Name == "test_resource"); } @@ -56,7 +56,7 @@ public async Task Client_CanListResources_WhenCapabilitySetViaAddMcpServerCallba Assert.NotNull(client.ServerCapabilities.Resources); // The resources should be exposed and listable - var resources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + var resources = await client.ListResourcesAsync(null, TestContext.Current.CancellationToken); Assert.NotEmpty(resources); } diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs index 63eb9fb52..ca82c648b 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -23,19 +23,19 @@ public async Task MultipleTemplatedResources_MatchesCorrectResource() // Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/821. await using McpClient client = await CreateMcpClientForServer(); - var nonTemplatedResult = await client.ReadResourceAsync("test://resource/non-templated", TestContext.Current.CancellationToken); + var nonTemplatedResult = await client.ReadResourceAsync("test://resource/non-templated", null, TestContext.Current.CancellationToken); Assert.Equal("static", ((TextResourceContents)nonTemplatedResult.Contents[0]).Text); - var templatedResult = await client.ReadResourceAsync("test://resource/12345", TestContext.Current.CancellationToken); + var templatedResult = await client.ReadResourceAsync("test://resource/12345", null, TestContext.Current.CancellationToken); Assert.Equal("template: 12345", ((TextResourceContents)templatedResult.Contents[0]).Text); - var exactTemplatedResult = await client.ReadResourceAsync("test://resource/{id}", TestContext.Current.CancellationToken); + var exactTemplatedResult = await client.ReadResourceAsync("test://resource/{id}", null, TestContext.Current.CancellationToken); Assert.Equal("template: {id}", ((TextResourceContents)exactTemplatedResult.Contents[0]).Text); - var paramsResult = await client.ReadResourceAsync("test://params?a1=a&a2=b&a3=c", TestContext.Current.CancellationToken); + var paramsResult = await client.ReadResourceAsync("test://params?a1=a&a2=b&a3=c", null, TestContext.Current.CancellationToken); Assert.Equal("params: a, b, c", ((TextResourceContents)paramsResult.Contents[0]).Text); - var mcpEx = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync("test://params{?a1,a2,a3}", TestContext.Current.CancellationToken)); + var mcpEx = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync("test://params{?a1,a2,a3}", null, TestContext.Current.CancellationToken)); Assert.Equal(McpErrorCode.InvalidParams, mcpEx.ErrorCode); Assert.Equal("Request failed (remote): Unknown resource URI: 'test://params{?a1,a2,a3}'", mcpEx.Message); } diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerScopedTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerScopedTests.cs index 5ddc3c54a..574eec3ee 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerScopedTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerScopedTests.cs @@ -24,7 +24,7 @@ public async Task InjectScopedServiceAsArgument() { await using McpClient client = await CreateMcpClientForServer(); - var tools = await client.ListToolsAsync(McpServerScopedTestsJsonContext.Default.Options, TestContext.Current.CancellationToken); + var tools = await client.ListToolsAsync(new RequestOptions { JsonSerializerOptions = McpServerScopedTestsJsonContext.Default.Options }, TestContext.Current.CancellationToken); var tool = tools.First(t => t.Name == "echo_complex"); Assert.DoesNotContain("\"complex\"", JsonSerializer.Serialize(tool.JsonSchema, McpJsonUtilities.DefaultOptions)); diff --git a/tests/ModelContextProtocol.Tests/Server/EmptyCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/EmptyCollectionTests.cs index 882fd5045..2618f0103 100644 --- a/tests/ModelContextProtocol.Tests/Server/EmptyCollectionTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/EmptyCollectionTests.cs @@ -31,7 +31,7 @@ public async Task EmptyResourceCollection_CanAddResourcesDynamically() var client = await CreateMcpClientForServer(); // Initially, the resource collection is empty - var initialResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + var initialResources = await client.ListResourcesAsync(options: null, TestContext.Current.CancellationToken); Assert.Empty(initialResources); // Add a resource dynamically @@ -40,7 +40,7 @@ public async Task EmptyResourceCollection_CanAddResourcesDynamically() new() { UriTemplate = "test://resource/1" })); // The resource should now be listed - var updatedResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + var updatedResources = await client.ListResourcesAsync(options: null, TestContext.Current.CancellationToken); Assert.Single(updatedResources); Assert.Equal("test://resource/1", updatedResources[0].Uri); } @@ -71,7 +71,7 @@ public async Task EmptyPromptCollection_CanAddPromptsDynamically() var client = await CreateMcpClientForServer(); // Initially, the prompt collection is empty - var initialPrompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken); + var initialPrompts = await client.ListPromptsAsync(options: null, TestContext.Current.CancellationToken); Assert.Empty(initialPrompts); // Add a prompt dynamically @@ -80,7 +80,7 @@ public async Task EmptyPromptCollection_CanAddPromptsDynamically() new() { Name = "test_prompt", Description = "A test prompt" })); // The prompt should now be listed - var updatedPrompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken); + var updatedPrompts = await client.ListPromptsAsync(options: null, TestContext.Current.CancellationToken); Assert.Single(updatedPrompts); Assert.Equal("test_prompt", updatedPrompts[0].Name); } @@ -96,7 +96,7 @@ public async Task EmptyResourceCollection_CanCallReadResourceAfterAddingDynamica new() { UriTemplate = "test://resource/dynamic" })); // Read the resource - var result = await client.ReadResourceAsync("test://resource/dynamic", TestContext.Current.CancellationToken); + var result = await client.ReadResourceAsync("test://resource/dynamic", options: null, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Contents); Assert.IsType(result.Contents[0]); @@ -159,7 +159,7 @@ public async Task ListFails() var client = await CreateMcpClientForServer(); await Assert.ThrowsAsync(async () => await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync(async () => await client.ListPromptsAsync(TestContext.Current.CancellationToken)); - await Assert.ThrowsAsync(async () => await client.ListResourcesAsync(TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken)); } } From 65b53f6acc7617c32f17ce882f78f9a31f0e3100 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Wed, 26 Nov 2025 20:03:55 -0800 Subject: [PATCH 02/13] Little fixes for rebase to latest main --- tests/ModelContextProtocol.ConformanceServer/Program.cs | 2 +- .../Tools/ConformanceTools.cs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/ModelContextProtocol.ConformanceServer/Program.cs b/tests/ModelContextProtocol.ConformanceServer/Program.cs index 419937511..e9c810fba 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Program.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Program.cs @@ -47,7 +47,7 @@ await ctx.Server.SampleAsync([ new ChatMessage(ChatRole.System, "You are a helpful test server"), new ChatMessage(ChatRole.User, $"Resource {uri}, context: A new subscription was started"), ], - options: new ChatOptions + chatOptions: new ChatOptions { MaxOutputTokens = 100, Temperature = 0.7f, diff --git a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs index f624e5ca3..177de5c60 100644 --- a/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs +++ b/tests/ModelContextProtocol.ConformanceServer/Tools/ConformanceTools.cs @@ -141,7 +141,7 @@ public static async Task ToolWithProgress( { Progress = 0, Total = 100, - }, cancellationToken); + }, cancellationToken: cancellationToken); await Task.Delay(50, cancellationToken); @@ -149,7 +149,7 @@ public static async Task ToolWithProgress( { Progress = 50, Total = 100, - }, cancellationToken); + }, cancellationToken: cancellationToken); await Task.Delay(50, cancellationToken); @@ -157,7 +157,7 @@ public static async Task ToolWithProgress( { Progress = 100, Total = 100, - }, cancellationToken); + }, cancellationToken: cancellationToken); } return progressToken?.ToString() ?? "No progress token provided"; From 5e003996fabeedebd79ae897ce509ba8a3e861e0 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Fri, 28 Nov 2025 09:01:49 -0800 Subject: [PATCH 03/13] Add tests for meta in requests --- .../Client/McpClientMetaTests.cs | 66 +++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs new file mode 100644 index 000000000..e04c928dc --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -0,0 +1,66 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Client; + +public class McpClientMetaTests : ClientServerTestBase +{ + public McpClientMetaTests(ITestOutputHelper outputHelper) + : base(outputHelper) + { + } + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + services.Configure(o => + { + o.ServerInfo = new Implementation + { + Name = "test-server", + Version = "1.0.0", + Description = "A test server for unit testing", + WebsiteUrl = "https://example.com", + }; + o.ToolCollection = new (); + }); + } + + [Fact] + public async Task ToolCallWithMetaFields() + { + Server.ServerOptions.ToolCollection?.Add(McpServerTool.Create( + async (RequestContext context) => + { + // Access the foo property of _meta field from the request parameters + var metaFoo = context.Params?.Meta?["foo"]?.ToString(); + + // Assert that the meta foo is correctly passed + Assert.NotNull(metaFoo); + + return $"Meta foo is {metaFoo}"; + }, + new () { Name = "echo_meta" })); + + await using McpClient client = await CreateMcpClientForServer(); + + var requestOptions = new RequestOptions() + { + Meta = new JsonObject() + { + { "foo", "barbaz" } + } + }; + + var result = await client.CallToolAsync("echo_meta", options: requestOptions, cancellationToken: TestContext.Current.CancellationToken); + // Assert.Contains("barbaz", result?.ToString()); + Assert.NotNull(result); + Assert.Null(result.IsError); + + var textContent = result.Content.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.Contains("barbaz", textContent.Text); + } +} \ No newline at end of file From acf8e8575498578c94b7e09147d4435cd286a36b Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Fri, 28 Nov 2025 09:29:31 -0800 Subject: [PATCH 04/13] Another meta test --- .../Client/McpClientMetaTests.cs | 47 +++++++++++++++++-- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index e04c928dc..e759809de 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -25,6 +25,8 @@ protected override void ConfigureServices(ServiceCollection services, IMcpServer WebsiteUrl = "https://example.com", }; o.ToolCollection = new (); + o.ResourceCollection = new (); + o.PromptCollection = new (); }); } @@ -42,7 +44,7 @@ public async Task ToolCallWithMetaFields() return $"Meta foo is {metaFoo}"; }, - new () { Name = "echo_meta" })); + new () { Name = "meta_tool" })); await using McpClient client = await CreateMcpClientForServer(); @@ -50,17 +52,52 @@ public async Task ToolCallWithMetaFields() { Meta = new JsonObject() { - { "foo", "barbaz" } + { "foo", "bar baz" } } }; - var result = await client.CallToolAsync("echo_meta", options: requestOptions, cancellationToken: TestContext.Current.CancellationToken); - // Assert.Contains("barbaz", result?.ToString()); + var result = await client.CallToolAsync("meta_tool", options: requestOptions, cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result); Assert.Null(result.IsError); var textContent = result.Content.OfType().FirstOrDefault(); Assert.NotNull(textContent); - Assert.Contains("barbaz", textContent.Text); + Assert.Contains("bar baz", textContent.Text); + } + + [Fact] + public async Task ResourceGetWithMetaFields() + { + Server.ServerOptions.ResourceCollection?.Add(McpServerResource.Create( + (RequestContext context) => + { + // Access the foo property of _meta field from the request parameters + var metaFoo = context.Params?.Meta?["foo"]?.ToString(); + + // Assert that the meta foo is correctly passed + Assert.NotNull(metaFoo); + + return $"Resource with Meta foo is {metaFoo}"; + }, + new () { UriTemplate = "test://meta_resource" })); + + await using McpClient client = await CreateMcpClientForServer(); + + var requestOptions = new RequestOptions() + { + Meta = new JsonObject() + { + { "foo", "bar baz" } + } + }; + + var result = await client.ReadResourceAsync("test://meta_resource", options: requestOptions, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + + var textContent = result.Contents.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.Contains("bar baz", textContent.Text); } } \ No newline at end of file From 80fbe01e6179e604607f24bb06dafb26e95c6037 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Fri, 28 Nov 2025 09:38:04 -0800 Subject: [PATCH 05/13] Another meta test --- .../Client/McpClientMetaTests.cs | 40 ++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index e759809de..df18b6e32 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -67,7 +67,7 @@ public async Task ToolCallWithMetaFields() } [Fact] - public async Task ResourceGetWithMetaFields() + public async Task ResourceReadWithMetaFields() { Server.ServerOptions.ResourceCollection?.Add(McpServerResource.Create( (RequestContext context) => @@ -100,4 +100,42 @@ public async Task ResourceGetWithMetaFields() Assert.NotNull(textContent); Assert.Contains("bar baz", textContent.Text); } + + + [Fact] + public async Task PromptGettWithMetaFields() + { + Server.ServerOptions.PromptCollection?.Add(McpServerPrompt.Create( + (RequestContext context) => + { + // Access the foo property of _meta field from the request parameters + var metaFoo = context.Params?.Meta?["foo"]?.ToString(); + + // Assert that the meta foo is correctly passed + Assert.NotNull(metaFoo); + + return $"Prompt with Meta foo is {metaFoo}"; + }, + new () { Name = "meta_prompt" })); + + await using McpClient client = await CreateMcpClientForServer(); + + var requestOptions = new RequestOptions() + { + Meta = new JsonObject() + { + { "foo", "bar baz" } + } + }; + + var result = await client.GetPromptAsync("meta_prompt", options: requestOptions, cancellationToken: TestContext.Current.CancellationToken); + + Assert.NotNull(result); + Assert.NotEmpty(result.Messages); + var message = result.Messages.First(); + Assert.NotNull(message.Content); + var textContent = message.Content as TextContentBlock; + Assert.NotNull(textContent); + Assert.Contains("bar baz", textContent.Text); + } } \ No newline at end of file From 2b54bc87dbd2d530dce285ef8fb018ac68c55b24 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Tue, 2 Dec 2025 16:54:51 -0500 Subject: [PATCH 06/13] Apply suggestions from code review Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/ModelContextProtocol.Core/Client/McpClient.Methods.cs | 7 ++++++- src/ModelContextProtocol.Core/Client/McpClientPrompt.cs | 2 +- .../Client/McpClientMetaTests.cs | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 5ec7df464..368ceddac 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -592,7 +592,12 @@ async ValueTask SendRequestWithProgressAsync( }).ConfigureAwait(false); var metaWithProgress = meta is not null ? new JsonObject(meta) : new JsonObject(); - metaWithProgress["progressToken"] = JsonValue.Create(progressToken.Token as string); + metaWithProgress["progressToken"] = progressToken.Token switch + { + string s => JsonValue.Create(s), + long l => JsonValue.Create(l), + _ => throw new InvalidOperationException("ProgressToken must be a string or long") + }; return await SendRequestAsync( RequestMethods.ToolsCall, diff --git a/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs b/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs index ccd335d5a..1274c2a4f 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientPrompt.cs @@ -97,6 +97,6 @@ public async ValueTask GetAsync( arguments as IReadOnlyDictionary ?? arguments?.ToDictionary(); - return await _client.GetPromptAsync(ProtocolPrompt.Name, argDict, new RequestOptions(){JsonSerializerOptions = serializerOptions}, cancellationToken).ConfigureAwait(false); + return await _client.GetPromptAsync(ProtocolPrompt.Name, argDict, new RequestOptions() { JsonSerializerOptions = serializerOptions }, cancellationToken).ConfigureAwait(false); } } diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index df18b6e32..a4255ddbd 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -13,7 +13,7 @@ public McpClientMetaTests(ITestOutputHelper outputHelper) { } - protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) { services.Configure(o => { From 03c724def3084e9f1b2be99914d9b5b4e9fb6873 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Tue, 2 Dec 2025 16:17:40 -0800 Subject: [PATCH 07/13] Address PR review comments --- src/ModelContextProtocol.Core/Client/McpClient.Methods.cs | 3 +-- src/ModelContextProtocol.Core/RequestOptions.cs | 4 ++-- tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs | 2 +- 3 files changed, 4 insertions(+), 5 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 368ceddac..5d8f4ffb5 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -83,9 +83,8 @@ public static async Task ResumeSessionAsync( /// /// Optional request options including metadata, serialization settings, and progress tracking. /// The to monitor for cancellation requests. The default is . - /// A task that completes when the ping is successful. + /// A task containing the ping result. /// The server cannot be reached or returned an error response. - /// Thrown when the server cannot be reached or returns an error response. public ValueTask PingAsync(RequestOptions? options = null, CancellationToken cancellationToken = default) { return SendRequestAsync( diff --git a/src/ModelContextProtocol.Core/RequestOptions.cs b/src/ModelContextProtocol.Core/RequestOptions.cs index b5f60241b..9f1548efa 100644 --- a/src/ModelContextProtocol.Core/RequestOptions.cs +++ b/src/ModelContextProtocol.Core/RequestOptions.cs @@ -33,8 +33,8 @@ public JsonObject? Meta // Add progress token to metadata meta["progressToken"] = ProgressToken.Value.Token switch { - string s => JsonValue.Create(s), - long l => JsonValue.Create(l), + string s => s, + long l => l, _ => null }; diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs index a4255ddbd..ddb30b720 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientMetaTests.cs @@ -103,7 +103,7 @@ public async Task ResourceReadWithMetaFields() [Fact] - public async Task PromptGettWithMetaFields() + public async Task PromptGetWithMetaFields() { Server.ServerOptions.PromptCollection?.Add(McpServerPrompt.Create( (RequestContext context) => From cbc531fa4d33a79f1713d2ff35a5bdddb5cd63c6 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Wed, 3 Dec 2025 10:10:37 -0800 Subject: [PATCH 08/13] Change McpClientTool.CallAsync to accept RequestOptions --- .../Client/McpClientTool.cs | 17 ++++++++++------- 1 file changed, 10 insertions(+), 7 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index b6244e6c4..1189ceb05 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientTool.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientTool.cs @@ -128,7 +128,11 @@ internal McpClientTool( protected async override ValueTask InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken) { - CallToolResult result = await CallAsync(arguments, _progress, JsonSerializerOptions, cancellationToken).ConfigureAwait(false); + var options = JsonSerializerOptions is null ? null : new RequestOptions() + { + JsonSerializerOptions = JsonSerializerOptions, + }; + CallToolResult result = await CallAsync(arguments, _progress, options, cancellationToken).ConfigureAwait(false); // We want to translate the result content into AIContent, using AIContent as the exchange types, so // that downstream IChatClients can specialize handling based on the content (e.g. sending image content @@ -163,8 +167,8 @@ result.StructuredContent is null && /// value will result in a progress token being included in the call, and any resulting progress notifications during the operation /// routed to this instance. /// - /// - /// The JSON serialization options governing argument serialization. If , the default serialization options are used. + /// + /// Optional request options including metadata, serialization settings, and progress tracking. /// /// The to monitor for cancellation requests. The default is . /// @@ -191,15 +195,14 @@ result.StructuredContent is null && public ValueTask CallAsync( IReadOnlyDictionary? arguments = null, IProgress? progress = null, - JsonSerializerOptions? serializerOptions = null, + RequestOptions? options = null, CancellationToken cancellationToken = default) => _client.CallToolAsync( ProtocolTool.Name, arguments, progress, - serializerOptions is null ? null : new RequestOptions - { - JsonSerializerOptions = serializerOptions + options ?? new RequestOptions() { + JsonSerializerOptions = JsonSerializerOptions }, cancellationToken); From 8bc7a065521ef12b5449285f42b57eed4a359220 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Wed, 3 Dec 2025 11:00:42 -0800 Subject: [PATCH 09/13] Update src/ModelContextProtocol.Core/Client/McpClient.Methods.cs Co-authored-by: Stephen Toub --- src/ModelContextProtocol.Core/Client/McpClient.Methods.cs | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 5d8f4ffb5..064c10d76 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -105,9 +105,6 @@ public async ValueTask> ListToolsAsync( RequestOptions? options = null, CancellationToken cancellationToken = default) { - var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; - serializerOptions.MakeReadOnly(); - List? tools = null; string? cursor = null; do From 595b4b6c47d70702ffa6683a2cb5c7cb197bfc2a Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Wed, 3 Dec 2025 11:18:41 -0800 Subject: [PATCH 10/13] Address PR review feedback --- .../Client/McpClient.Methods.cs | 14 +++----------- src/ModelContextProtocol.Core/RequestOptions.cs | 14 +++++++------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 064c10d76..d5a16a5aa 100644 --- a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs +++ b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs @@ -119,7 +119,7 @@ public async ValueTask> ListToolsAsync( tools ??= new List(toolResults.Tools.Count); foreach (var tool in toolResults.Tools) { - tools.Add(new McpClientTool(this, tool, serializerOptions)); + tools.Add(new McpClientTool(this, tool, options?.JsonSerializerOptions)); } cursor = toolResults.NextCursor; @@ -139,9 +139,6 @@ public async IAsyncEnumerable EnumerateToolsAsync( RequestOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { - var serializerOptions = options?.JsonSerializerOptions ?? McpJsonUtilities.DefaultOptions; - serializerOptions.MakeReadOnly(); - string? cursor = null; do { @@ -154,7 +151,7 @@ public async IAsyncEnumerable EnumerateToolsAsync( foreach (var tool in toolResults.Tools) { - yield return new McpClientTool(this, tool, serializerOptions); + yield return new McpClientTool(this, tool, options?.JsonSerializerOptions); } cursor = toolResults.NextCursor; @@ -588,12 +585,7 @@ async ValueTask SendRequestWithProgressAsync( }).ConfigureAwait(false); var metaWithProgress = meta is not null ? new JsonObject(meta) : new JsonObject(); - metaWithProgress["progressToken"] = progressToken.Token switch - { - string s => JsonValue.Create(s), - long l => JsonValue.Create(l), - _ => throw new InvalidOperationException("ProgressToken must be a string or long") - }; + metaWithProgress["progressToken"] = progressToken.ToString(); return await SendRequestAsync( RequestMethods.ToolsCall, diff --git a/src/ModelContextProtocol.Core/RequestOptions.cs b/src/ModelContextProtocol.Core/RequestOptions.cs index 9f1548efa..97893a0f3 100644 --- a/src/ModelContextProtocol.Core/RequestOptions.cs +++ b/src/ModelContextProtocol.Core/RequestOptions.cs @@ -14,6 +14,13 @@ public sealed class RequestOptions /// private JsonObject? _meta; + /// + /// Initializes a new instance of the class. + /// + public RequestOptions() + { + } + /// /// Optional metadata to include in the request. /// When getting, automatically includes the progress token if set. @@ -52,11 +59,4 @@ public JsonObject? Meta /// The progress token for tracking long-running operations. /// public ProgressToken? ProgressToken { get; set; } - - /// - /// Initializes a new instance of the class. - /// - public RequestOptions() - { - } } \ No newline at end of file From 06af40ee45eda24679a7d127f8afc21b331559ee Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Wed, 3 Dec 2025 15:15:19 -0800 Subject: [PATCH 11/13] Use _meta as backing field for both Meta and ProgressToken --- .../RequestOptions.cs | 70 ++++++++++++++----- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/src/ModelContextProtocol.Core/RequestOptions.cs b/src/ModelContextProtocol.Core/RequestOptions.cs index 97893a0f3..85583b9fa 100644 --- a/src/ModelContextProtocol.Core/RequestOptions.cs +++ b/src/ModelContextProtocol.Core/RequestOptions.cs @@ -29,25 +29,35 @@ public JsonObject? Meta { get { - if (ProgressToken == null) + _meta ??= new JsonObject(); + return _meta; + } + set + { + // Preserve the progress token if set. + var progressToken = _meta?["progressToken"]; + if (value is null) { - return _meta; + if (progressToken is not null) + { + _meta = new JsonObject + { + ["progressToken"] = progressToken, + }; + } + else + { + _meta = null; + } } - - // Clone existing metadata or create a new one - var meta = _meta?.DeepClone() as JsonObject ?? new JsonObject(); - - // Add progress token to metadata - meta["progressToken"] = ProgressToken.Value.Token switch + else { - string s => s, - long l => l, - _ => null - }; - - return meta; + if (progressToken is not null) { + value["progressToken"] = progressToken; + } + _meta = value; + } } - set => _meta = value; } /// @@ -58,5 +68,33 @@ public JsonObject? Meta /// /// The progress token for tracking long-running operations. /// - public ProgressToken? ProgressToken { get; set; } + public ProgressToken? ProgressToken { + get + { + return _meta?["progressToken"] switch + { + JsonValue v when v.TryGetValue(out string? s) => new ProgressToken(s), + JsonValue v when v.TryGetValue(out long l) => new ProgressToken(l), + _ => null + }; + } + set + { + if (value?.Token is {} token) + { + _meta ??= new JsonObject(); + _meta["progressToken"] = token switch + { + null => _meta.Remove("progressToken"), + string s => s, + long l => l, + _ => throw new InvalidOperationException("ProgressToken must be a string or long"), + }; + } + else + { + _meta?.Remove("progressToken"); + } + } + } } \ No newline at end of file From fccab7ca116ef67a138765203c6a77b3d66cdad5 Mon Sep 17 00:00:00 2001 From: Mike Kistler Date: Thu, 4 Dec 2025 13:20:41 -0800 Subject: [PATCH 12/13] Add tests for RequestOptions and fix buggy Meta setter --- .../RequestOptions.cs | 7 +- .../RequestOptionsTests.cs | 448 ++++++++++++++++++ 2 files changed, 452 insertions(+), 3 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/RequestOptionsTests.cs diff --git a/src/ModelContextProtocol.Core/RequestOptions.cs b/src/ModelContextProtocol.Core/RequestOptions.cs index 85583b9fa..cd3c26a00 100644 --- a/src/ModelContextProtocol.Core/RequestOptions.cs +++ b/src/ModelContextProtocol.Core/RequestOptions.cs @@ -34,7 +34,7 @@ public JsonObject? Meta } set { - // Preserve the progress token if set. + // Capture the existing progressToken value if set. var progressToken = _meta?["progressToken"]; if (value is null) { @@ -52,7 +52,9 @@ public JsonObject? Meta } else { - if (progressToken is not null) { + if (value["progressToken"] is null && progressToken is not null) { + // Remove existing progressToken so it can be set into the new meta. + _meta?.Remove("progressToken"); value["progressToken"] = progressToken; } _meta = value; @@ -85,7 +87,6 @@ public ProgressToken? ProgressToken { _meta ??= new JsonObject(); _meta["progressToken"] = token switch { - null => _meta.Remove("progressToken"), string s => s, long l => l, _ => throw new InvalidOperationException("ProgressToken must be a string or long"), diff --git a/tests/ModelContextProtocol.Tests/RequestOptionsTests.cs b/tests/ModelContextProtocol.Tests/RequestOptionsTests.cs new file mode 100644 index 000000000..fe54900e8 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/RequestOptionsTests.cs @@ -0,0 +1,448 @@ +using System.Text.Json; +using System.Text.Json.Nodes; +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Tests; + +public static class RequestOptionsTests +{ + [Fact] + public static void RequestOptions_DefaultConstructor() + { + // Arrange & Act + var options = new RequestOptions(); + + // Assert + // ProgressToken and JsonSerializerOptions should be null by default + Assert.Null(options.ProgressToken); + Assert.Null(options.JsonSerializerOptions); + // Meta should return a new (empty) JsonObject when accessed + Assert.NotNull(options.Meta); + Assert.Empty(options.Meta); + } + + [Fact] + public static void RequestOptions_MetaSetter_SetsValue() + { + // Arrange + var options = new RequestOptions(); + var metaValue = new JsonObject + { + ["key"] = "value" + }; + + // Act + options.Meta = metaValue; + + // Assert + Assert.Same(metaValue, options.Meta); + Assert.Equal("value", options.Meta["key"]?.ToString()); + } + + [Fact] + public static void RequestOptions_Meta_SetsSingleField() + { + // Arrange + var options = new RequestOptions(); + + // Act + options.Meta!["key"] = "value"; + + // Assert + Assert.Equal("value", options.Meta!["key"]?.ToString()); + } + + [Fact] + public static void RequestOptions_MetaSetter_NullValue_RemovesMeta() + { + // Arrange + var options = new RequestOptions + { + Meta = new JsonObject { ["key"] = "value" } + }; + + // Act + options.Meta = null; + + // Assert - accessing Meta will create a new empty JsonObject + Assert.NotNull(options.Meta); + Assert.Empty(options.Meta); + } + + [Fact] + public static void RequestOptions_MetaSetter_WithProgressToken_InMeta() + { + // Arrange + var options = new RequestOptions(); + var newMeta = new JsonObject + { + ["custom"] = "data", + ["progressToken"] = "token" + }; + + // Act + options.Meta = newMeta; + + // Assert + Assert.Equal("token", options.ProgressToken?.ToString()); + Assert.Equal("data", options.Meta["custom"]?.ToString()); + Assert.True(options.Meta.ContainsKey("progressToken")); + } + + [Fact] + public static void RequestOptions_ProgressTokenGetter_ReturnsNull_WhenNotSet() + { + // Arrange + var options = new RequestOptions + { + Meta = new JsonObject { ["other"] = "value" } + }; + + // Act + var token = options.ProgressToken; + + // Assert + Assert.Null(token); + } + + [Fact] + public static void RequestOptions_ProgressTokenSetter_StringToken_SetsInMeta() + { + // Arrange + var options = new RequestOptions(); + var token = new ProgressToken("my-token"); + + // Act + options.ProgressToken = token; + + // Assert + Assert.Equal("my-token", options.ProgressToken?.ToString()); + Assert.NotNull(options.Meta); + Assert.Equal("my-token", options.Meta["progressToken"]?.ToString()); + } + + [Fact] + public static void RequestOptions_ProgressTokenSetter_LongToken_SetsInMeta() + { + // Arrange + var options = new RequestOptions(); + var token = new ProgressToken(42L); + + // Act + options.ProgressToken = token; + + // Assert + Assert.Equal("42", options.ProgressToken?.ToString()); + Assert.NotNull(options.Meta); + Assert.Equal(42L, options.Meta["progressToken"]?.AsValue().GetValue()); + } + + [Fact] + public static void RequestOptions_ProgressTokenSetter_Null_RemovesFromMeta() + { + // Arrange + var options = new RequestOptions + { + ProgressToken = new ProgressToken("token-to-remove") + }; + + // Act + options.ProgressToken = null; + + // Assert + Assert.Null(options.ProgressToken); + Assert.False(options.Meta!.ContainsKey("progressToken")); + } + + [Fact] + public static void RequestOptions_ProgressTokenSetter_Null_WhenNoProgressToken_DoesNothing() + { + // Arrange + var options = new RequestOptions(); + + // Act + options.ProgressToken = null; + + // Assert + Assert.Null(options.ProgressToken); + } + + [Fact] + public static void RequestOptions_ProgressTokenGetter_StringValue_ReturnsCorrectToken() + { + // Arrange + var options = new RequestOptions + { + Meta = new JsonObject { ["progressToken"] = "test-token" } + }; + + // Act + var token = options.ProgressToken; + + // Assert + Assert.NotNull(token); + Assert.Equal("test-token", token.Value.ToString()); + } + + [Fact] + public static void RequestOptions_ProgressTokenGetter_LongValue_ReturnsCorrectToken() + { + // Arrange + var options = new RequestOptions + { + Meta = new JsonObject { ["progressToken"] = 123L } + }; + + // Act + var token = options.ProgressToken; + + // Assert + Assert.NotNull(token); + Assert.Equal("123", token.Value.ToString()); + } + + [Fact] + public static void RequestOptions_ProgressTokenGetter_InvalidValue_ReturnsNull() + { + // Arrange + var options = new RequestOptions + { + Meta = new JsonObject { ["progressToken"] = new JsonObject() } + }; + + // Act + var token = options.ProgressToken; + + // Assert + Assert.Null(token); + } + + [Fact] + public static void RequestOptions_JsonSerializerOptions_GetSet() + { + // Arrange + var options = new RequestOptions(); + var serializerOptions = new JsonSerializerOptions { WriteIndented = true }; + + // Act + options.JsonSerializerOptions = serializerOptions; + + // Assert + Assert.Same(serializerOptions, options.JsonSerializerOptions); + } + + [Fact] + public static void RequestOptions_MetaAndProgressToken_WorkTogether() + { + // Arrange + var options = new RequestOptions(); + + // Act - set progress token first + options.ProgressToken = new ProgressToken("token1"); + options.Meta!["custom"] = "value1"; + + // Assert + Assert.Equal("token1", options.ProgressToken?.ToString()); + Assert.Equal("value1", options.Meta["custom"]?.ToString()); + Assert.True(options.Meta.ContainsKey("progressToken")); + } + + [Fact] + public static void RequestOptions_ProgressToken_OverwritesPreviousValue() + { + // Arrange + var options = new RequestOptions + { + ProgressToken = new ProgressToken("old-token") + }; + + // Act + options.ProgressToken = new ProgressToken("new-token"); + + // Assert + Assert.Equal("new-token", options.ProgressToken?.ToString()); + Assert.Equal("new-token", options.Meta!["progressToken"]?.ToString()); + } + + [Fact] + public static void RequestOptions_ProgressToken_StringToLong_ChangesType() + { + // Arrange + var options = new RequestOptions + { + ProgressToken = new ProgressToken("string-token") + }; + + // Act + options.ProgressToken = new ProgressToken(999L); + + // Assert + Assert.Equal("999", options.ProgressToken?.ToString()); + Assert.Equal(999L, options.Meta!["progressToken"]?.AsValue().GetValue()); + } + + [Fact] + public static void RequestOptions_Meta_MultipleProperties_Preserved() + { + // Arrange + var options = new RequestOptions + { + Meta = new JsonObject + { + ["prop1"] = "value1", + ["prop2"] = 42, + ["prop3"] = true + } + }; + + // Act + options.ProgressToken = new ProgressToken("my-token"); + + // Assert + Assert.Equal("value1", options.Meta["prop1"]?.ToString()); + Assert.Equal(42, options.Meta["prop2"]?.AsValue().GetValue()); + Assert.True(options.Meta["prop3"]?.AsValue().GetValue()); + Assert.Equal("my-token", options.Meta["progressToken"]?.ToString()); + } + + [Fact] + public static void RequestOptions_MetaSetter_ReplacesExistingMeta() + { + // Arrange + var options = new RequestOptions + { + Meta = new JsonObject + { + ["old1"] = "value1", + ["old2"] = "value2" + } + }; + + var newMeta = new JsonObject + { + ["new1"] = "newValue1", + ["new2"] = "newValue2" + }; + + // Act + options.Meta = newMeta; + + // Assert + Assert.False(options.Meta.ContainsKey("old1")); + Assert.False(options.Meta.ContainsKey("old2")); + Assert.Equal("newValue1", options.Meta["new1"]?.ToString()); + Assert.Equal("newValue2", options.Meta["new2"]?.ToString()); + } + + [Fact] + public static void RequestOptions_MetaSetter_NullWithNoProgressToken_ClearsMeta() + { + // Arrange + var options = new RequestOptions + { + Meta = new JsonObject { ["key"] = "value" } + }; + + // Act + options.Meta = null; + + // Assert - getter creates new empty object + Assert.NotNull(options.Meta); + Assert.Empty(options.Meta); + } + + [Fact] + public static void RequestOptions_ProgressTokenSetter_CreatesMetaIfNeeded() + { + // Arrange + var options = new RequestOptions(); + Assert.Null(options.ProgressToken); + + // Act + options.ProgressToken = new ProgressToken("create-meta"); + + // Assert + Assert.NotNull(options.ProgressToken); + Assert.Equal("create-meta", options.ProgressToken?.ToString()); + Assert.True(options.Meta!.ContainsKey("progressToken")); + } + + [Fact] + public static void RequestOptions_ComplexScenario_SetMetaThenProgressToken() + { + // Arrange + var options = new RequestOptions + { + Meta = new JsonObject + { + ["custom1"] = "value1", + ["custom2"] = 123 + } + }; + + // Act + options.ProgressToken = new ProgressToken("scenario-token"); + + // Assert + Assert.Equal("value1", options.Meta["custom1"]?.ToString()); + Assert.Equal(123, options.Meta["custom2"]?.AsValue().GetValue()); + Assert.Equal("scenario-token", options.ProgressToken?.ToString()); + Assert.Equal(3, options.Meta.Count); + } + + [Fact] + public static void RequestOptions_ComplexScenario_SetMetaWithProgressToken() + { + // Arrange + var options = new RequestOptions(); + + // Act + var newMeta = new JsonObject + { + ["data1"] = "info1", + ["data2"] = false, + ["progressToken"] = 456L + }; + options.Meta = newMeta; + + // Assert + Assert.Equal("info1", options.Meta["data1"]?.ToString()); + Assert.False(options.Meta["data2"]?.AsValue().GetValue()); + Assert.Equal(456L, options.ProgressToken?.Token as long?); + Assert.Equal(3, options.Meta.Count); + } + + [Fact] + public static void RequestOptions_ComplexScenario_SetMetaWithProgressUpdatesProgress() + { + // Arrange + RequestOptions options = new(); + + // Act + options.ProgressToken = new ProgressToken("token1"); + JsonObject meta = new() { ["progressToken"] = "token2" }; + options.Meta = meta; + + // Assert + Assert.Equal("token2", options.ProgressToken?.Token as string); + Assert.Equal("token2", options.Meta["progressToken"]?.ToString()); + } + + [Fact] + public static void RequestOptions_AllProperties_CanBeSetIndependently() + { + // Arrange + var options = new RequestOptions(); + var customOptions = new JsonSerializerOptions { WriteIndented = true }; + + // Act + options.JsonSerializerOptions = customOptions; + options.ProgressToken = new ProgressToken("independent"); + options.Meta!["field"] = "value"; + + // Assert + Assert.Same(customOptions, options.JsonSerializerOptions); + Assert.Equal("independent", options.ProgressToken?.ToString()); + Assert.Equal("value", options.Meta["field"]?.ToString()); + } +} \ No newline at end of file From 5cf093fe124f0b61a918470da2a29d65a0f43ee4 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 4 Dec 2025 17:11:07 -0500 Subject: [PATCH 13/13] Address my feedback --- .../RequestOptions.cs | 47 +++++++++---------- 1 file changed, 21 insertions(+), 26 deletions(-) diff --git a/src/ModelContextProtocol.Core/RequestOptions.cs b/src/ModelContextProtocol.Core/RequestOptions.cs index cd3c26a00..2b2c4943b 100644 --- a/src/ModelContextProtocol.Core/RequestOptions.cs +++ b/src/ModelContextProtocol.Core/RequestOptions.cs @@ -27,37 +27,31 @@ public RequestOptions() /// public JsonObject? Meta { - get - { - _meta ??= new JsonObject(); - return _meta; - } + get => _meta ??= []; set { // Capture the existing progressToken value if set. - var progressToken = _meta?["progressToken"]; - if (value is null) + var existingProgressToken = _meta?["progressToken"]; + + if (value is not null) { - if (progressToken is not null) + if (existingProgressToken is not null) { - _meta = new JsonObject - { - ["progressToken"] = progressToken, - }; + value["progressToken"] ??= existingProgressToken; } - else + + _meta = value; + } + else if (existingProgressToken is not null) + { + _meta = new() { - _meta = null; - } + ["progressToken"] = existingProgressToken, + }; } else { - if (value["progressToken"] is null && progressToken is not null) { - // Remove existing progressToken so it can be set into the new meta. - _meta?.Remove("progressToken"); - value["progressToken"] = progressToken; - } - _meta = value; + _meta = null; } } } @@ -70,21 +64,22 @@ public JsonObject? Meta /// /// The progress token for tracking long-running operations. /// - public ProgressToken? ProgressToken { + public ProgressToken? ProgressToken + { get { return _meta?["progressToken"] switch { - JsonValue v when v.TryGetValue(out string? s) => new ProgressToken(s), - JsonValue v when v.TryGetValue(out long l) => new ProgressToken(l), + JsonValue v when v.TryGetValue(out string? s) => new(s), + JsonValue v when v.TryGetValue(out long l) => new(l), _ => null }; } set { - if (value?.Token is {} token) + if (value?.Token is { } token) { - _meta ??= new JsonObject(); + _meta ??= []; _meta["progressToken"] = token switch { string s => s,