diff --git a/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs b/src/ModelContextProtocol.Core/Client/McpClient.Methods.cs index 6397d8e7..9f75d972 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; @@ -491,6 +492,7 @@ public Task UnsubscribeFromResourceAsync(Uri uri, CancellationToken cancellation /// An optional dictionary of arguments to pass to the tool. /// Optional progress reporter for server notifications. /// JSON serializer options. + /// Optional metadata to include in the request. This will be serialized as the _meta field in the JSON-RPC request parameters. /// A cancellation token. /// The from the tool execution. public ValueTask CallToolAsync( @@ -498,6 +500,7 @@ public ValueTask CallToolAsync( IReadOnlyDictionary? arguments = null, IProgress? progress = null, JsonSerializerOptions? serializerOptions = null, + JsonObject? meta = null, CancellationToken cancellationToken = default) { Throw.IfNull(toolName); @@ -506,7 +509,7 @@ public ValueTask CallToolAsync( if (progress is not null) { - return SendRequestWithProgressAsync(toolName, arguments, progress, serializerOptions, cancellationToken); + return SendRequestWithProgressAsync(toolName, arguments, progress, serializerOptions, meta, cancellationToken); } return SendRequestAsync( @@ -515,6 +518,7 @@ public ValueTask CallToolAsync( { Name = toolName, Arguments = ToArgumentsDictionary(arguments, serializerOptions), + Meta = meta, }, McpJsonUtilities.JsonContext.Default.CallToolRequestParams, McpJsonUtilities.JsonContext.Default.CallToolResult, @@ -525,6 +529,7 @@ async ValueTask SendRequestWithProgressAsync( IReadOnlyDictionary? arguments, IProgress progress, JsonSerializerOptions serializerOptions, + JsonObject? meta, CancellationToken cancellationToken) { ProgressToken progressToken = new(Guid.NewGuid().ToString("N")); @@ -541,14 +546,22 @@ async ValueTask SendRequestWithProgressAsync( return default; }).ConfigureAwait(false); + // Clone the meta object if provided, as we need to add the progress token to it without mutating the original + JsonObject? metaWithProgress = meta is not null ? JsonNode.Parse(meta.ToJsonString())?.AsObject() : null; + + var requestParams = new CallToolRequestParams + { + Name = toolName, + Arguments = ToArgumentsDictionary(arguments, serializerOptions), + Meta = metaWithProgress, + }; + + // Use the ProgressToken property setter which handles creating Meta if needed + requestParams.ProgressToken = progressToken; + return await SendRequestAsync( RequestMethods.ToolsCall, - new() - { - Name = toolName, - Arguments = ToArgumentsDictionary(arguments, serializerOptions), - ProgressToken = progressToken, - }, + requestParams, McpJsonUtilities.JsonContext.Default.CallToolRequestParams, McpJsonUtilities.JsonContext.Default.CallToolResult, cancellationToken: cancellationToken).ConfigureAwait(false); diff --git a/src/ModelContextProtocol.Core/Client/McpClientExtensions.cs b/src/ModelContextProtocol.Core/Client/McpClientExtensions.cs index de2d0071..57b56e48 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientExtensions.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientExtensions.cs @@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Client; @@ -556,6 +557,7 @@ public static Task UnsubscribeFromResourceAsync(this IMcpClient client, Uri uri, /// /// The JSON serialization options governing argument serialization. If , the default serialization options will be used. /// + /// Optional metadata to include in the request. This will be serialized as the _meta field in the JSON-RPC request parameters. /// The to monitor for cancellation requests. The default is . /// /// A task containing the from the tool execution. The response includes @@ -583,8 +585,9 @@ public static ValueTask CallToolAsync( IReadOnlyDictionary? arguments = null, IProgress? progress = null, JsonSerializerOptions? serializerOptions = null, + JsonObject? meta = null, CancellationToken cancellationToken = default) - => AsClientOrThrow(client).CallToolAsync(toolName, arguments, progress, serializerOptions, cancellationToken); + => AsClientOrThrow(client).CallToolAsync(toolName, arguments, progress, serializerOptions, meta, cancellationToken); [MethodImpl(MethodImplOptions.AggressiveInlining)] #pragma warning disable CS0618 // Type or member is obsolete diff --git a/src/ModelContextProtocol.Core/Client/McpClientTool.cs b/src/ModelContextProtocol.Core/Client/McpClientTool.cs index 012bc813..0306fc9b 100644 --- a/src/ModelContextProtocol.Core/Client/McpClientTool.cs +++ b/src/ModelContextProtocol.Core/Client/McpClientTool.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Protocol; using System.Collections.ObjectModel; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Client; @@ -36,6 +37,7 @@ public sealed class McpClientTool : AIFunction private readonly string _name; private readonly string _description; private readonly IProgress? _progress; + private readonly JsonObject? _meta; /// /// Initializes a new instance of the class. @@ -74,6 +76,7 @@ public McpClientTool( _name = tool.Name; _description = tool.Description ?? string.Empty; _progress = null; + _meta = null; } internal McpClientTool( @@ -82,7 +85,8 @@ internal McpClientTool( JsonSerializerOptions serializerOptions, string? name = null, string? description = null, - IProgress? progress = null) + IProgress? progress = null, + JsonObject? metadata = null) { _client = client; ProtocolTool = tool; @@ -90,6 +94,7 @@ internal McpClientTool( _name = name ?? tool.Name; _description = description ?? tool.Description ?? string.Empty; _progress = progress; + _meta = metadata; } /// @@ -194,7 +199,7 @@ 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, _meta, cancellationToken); /// /// Creates a new instance of the tool but modified to return the specified name from its property. @@ -221,7 +226,7 @@ public ValueTask CallAsync( /// /// public McpClientTool WithName(string name) => - new(_client, ProtocolTool, JsonSerializerOptions, name, _description, _progress); + new(_client, ProtocolTool, JsonSerializerOptions, name, _description, _progress, _meta); /// /// Creates a new instance of the tool but modified to return the specified description from its property. @@ -245,7 +250,7 @@ public McpClientTool WithName(string name) => /// /// A new instance of with the provided description. public McpClientTool WithDescription(string description) => - new(_client, ProtocolTool, JsonSerializerOptions, _name, description, _progress); + new(_client, ProtocolTool, JsonSerializerOptions, _name, description, _progress, _meta); /// /// Creates a new instance of the tool but modified to report progress via the specified . @@ -268,6 +273,35 @@ public McpClientTool WithProgress(IProgress progress) { Throw.IfNull(progress); - return new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, progress); + return new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, progress, _meta); + } + + /// + /// Creates a new instance of the tool but modified to include the specified metadata in tool call requests. + /// + /// + /// The metadata to include in tool call requests. This will be serialized as the _meta field + /// in the JSON-RPC request parameters. + /// + /// + /// + /// Adding metadata to the tool allows you to pass additional protocol-level information with each tool call. + /// This can be useful for tracing, logging, or passing context information to the server. + /// + /// + /// Only one metadata object can be specified at a time. Calling again + /// will overwrite any previously specified metadata. + /// + /// + /// The metadata is passed through to the server as-is, merged with any protocol-level metadata + /// such as progress tokens when is also used. + /// + /// + /// A new instance of , configured with the provided metadata. + public McpClientTool WithMeta(JsonObject metadata) + { + Throw.IfNull(metadata); + + return new McpClientTool(_client, ProtocolTool, JsonSerializerOptions, _name, _description, _progress, metadata); } } \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs index 7f1cc368..880d7e49 100644 --- a/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs @@ -151,6 +151,15 @@ public static EmbeddedResourceBlock BinaryResourceTool() => MimeType = "application/octet-stream" } }; + + // Tool that echoes back the metadata it receives + [McpServerTool] + public static TextContentBlock MetadataEchoTool(RequestContext context) + { + var meta = context.Params?.Meta; + var metaJson = meta?.ToJsonString() ?? "{}"; + return new TextContentBlock { Text = metaJson }; + } } [Fact] @@ -234,10 +243,10 @@ public async Task MixedContentTool_ReturnsAIContentArray() var aiContents = Assert.IsType(result); Assert.Equal(2, aiContents.Length); - + var textContent = Assert.IsType(aiContents[0]); Assert.Equal("Description of the image", textContent.Text); - + var dataContent = Assert.IsType(aiContents[1]); Assert.Equal("image/png", dataContent.MediaType); } @@ -253,11 +262,11 @@ public async Task MultipleImagesTool_ReturnsAIContentArray() var aiContents = Assert.IsType(result); Assert.Equal(2, aiContents.Length); - + var dataContent0 = Assert.IsType(aiContents[0]); Assert.Equal("image/png", dataContent0.MediaType); Assert.Equal("image1", Encoding.UTF8.GetString(dataContent0.Data.ToArray())); - + var dataContent1 = Assert.IsType(aiContents[1]); Assert.Equal("image/jpeg", dataContent1.MediaType); Assert.Equal("image2", Encoding.UTF8.GetString(dataContent1.Data.ToArray())); @@ -274,10 +283,10 @@ public async Task AudioWithTextTool_ReturnsAIContentArray() var aiContents = Assert.IsType(result); Assert.Equal(2, aiContents.Length); - + var textContent = Assert.IsType(aiContents[0]); Assert.Equal("Audio transcription", textContent.Text); - + var dataContent = Assert.IsType(aiContents[1]); Assert.Equal("audio/wav", dataContent.MediaType); } @@ -293,10 +302,10 @@ public async Task ResourceWithTextTool_ReturnsAIContentArray() var aiContents = Assert.IsType(result); Assert.Equal(2, aiContents.Length); - + var textContent0 = Assert.IsType(aiContents[0]); Assert.Equal("Resource description", textContent0.Text); - + var textContent1 = Assert.IsType(aiContents[1]); Assert.Equal("File content", textContent1.Text); } @@ -312,16 +321,16 @@ public async Task AllContentTypesTool_ReturnsAIContentArray() var aiContents = Assert.IsType(result); Assert.Equal(4, aiContents.Length); - + var textContent = Assert.IsType(aiContents[0]); Assert.Equal("Mixed content", textContent.Text); - + var dataContent1 = Assert.IsType(aiContents[1]); Assert.Equal("image/png", dataContent1.MediaType); - + var dataContent2 = Assert.IsType(aiContents[2]); Assert.Equal("audio/mp3", dataContent2.MediaType); - + var dataContent3 = Assert.IsType(aiContents[3]); Assert.Equal("application/octet-stream", dataContent3.MediaType); } @@ -354,7 +363,7 @@ public async Task ResourceLinkTool_ReturnsJsonElement() var jsonElement = (JsonElement)result!; Assert.True(jsonElement.TryGetProperty("content", out var contentValue)); Assert.Equal(JsonValueKind.Array, contentValue.ValueKind); - + Assert.Equal(1, contentValue.GetArrayLength()); } @@ -371,11 +380,11 @@ public async Task MixedWithNonConvertibleTool_ReturnsJsonElement() Assert.True(jsonElement.TryGetProperty("content", out var contentArray)); Assert.Equal(JsonValueKind.Array, contentArray.ValueKind); Assert.Equal(2, contentArray.GetArrayLength()); - + var firstContent = contentArray[0]; Assert.True(firstContent.TryGetProperty("type", out var type1)); Assert.Equal("image", type1.GetString()); - + var secondContent = contentArray[1]; Assert.True(secondContent.TryGetProperty("type", out var type2)); Assert.Equal("resource_link", type2.GetString()); @@ -486,4 +495,110 @@ public async Task MultipleAIContent_PreservesRawRepresentation() Assert.NotNull(dataContent.RawRepresentation); Assert.IsType(dataContent.RawRepresentation); } + + [Fact] + public async Task WithMeta_MetaIsPassedToServer() + { + // Arrange + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "metadata_echo_tool"); + + var metadata = new JsonObject + { + ["traceId"] = "test-trace-123", + ["customKey"] = "customValue" + }; + + // Act - use tool with metadata + var toolWithMeta = tool.WithMeta(metadata); + var result = await toolWithMeta.CallAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + Assert.Single(result.Content); + var textBlock = Assert.IsType(result.Content[0]); + + // The tool echoes back the metadata it received + var receivedMetadata = JsonNode.Parse(textBlock.Text)?.AsObject(); + Assert.NotNull(receivedMetadata); + Assert.Equal("test-trace-123", receivedMetadata["traceId"]?.GetValue()); + Assert.Equal("customValue", receivedMetadata["customKey"]?.GetValue()); + } + + [Fact] + public async Task WithMeta_CreatesNewInstance() + { + // Arrange + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "text_only_tool"); + + var metadata = new JsonObject { ["key"] = "value" }; + + // Act + var toolWithMeta = tool.WithMeta(metadata); + + // Assert - should be a different instance + Assert.NotSame(tool, toolWithMeta); + Assert.Equal(tool.Name, toolWithMeta.Name); + Assert.Equal(tool.Description, toolWithMeta.Description); + } + + [Fact] + public async Task WithMeta_ChainsWithOtherWithMethods() + { + // Arrange + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "metadata_echo_tool"); + + var metadata = new JsonObject { ["chainedKey"] = "chainedValue" }; + + // Act - chain WithName, WithDescription, and WithMeta + var modifiedTool = tool + .WithName("custom_name") + .WithDescription("Custom description") + .WithMeta(metadata); + + var result = await modifiedTool.CallAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert - name and description should be modified + Assert.Equal("custom_name", modifiedTool.Name); + Assert.Equal("Custom description", modifiedTool.Description); + + // Assert - metadata should still be passed + var textBlock = Assert.IsType(result.Content[0]); + var receivedMetadata = JsonNode.Parse(textBlock.Text)?.AsObject(); + Assert.NotNull(receivedMetadata); + Assert.Equal("chainedValue", receivedMetadata["chainedKey"]?.GetValue()); + } + + [Fact] + public async Task WithMeta_MultipleToolInstancesWithDifferentMetadata() + { + // Arrange + await using McpClient client = await CreateMcpClientForServer(); + var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + var tool = tools.Single(t => t.Name == "metadata_echo_tool"); + + var metadata1 = new JsonObject { ["clientId"] = "client-1" }; + var metadata2 = new JsonObject { ["clientId"] = "client-2" }; + + // Act - create two tool instances with different metadata + var tool1 = tool.WithMeta(metadata1); + var tool2 = tool.WithMeta(metadata2); + + var result1 = await tool1.CallAsync(cancellationToken: TestContext.Current.CancellationToken); + var result2 = await tool2.CallAsync(cancellationToken: TestContext.Current.CancellationToken); + + // Assert - each call should have its own metadata + var textBlock1 = Assert.IsType(result1.Content[0]); + var receivedMetadata1 = JsonNode.Parse(textBlock1.Text)?.AsObject(); + Assert.Equal("client-1", receivedMetadata1?["clientId"]?.GetValue()); + + var textBlock2 = Assert.IsType(result2.Content[0]); + var receivedMetadata2 = JsonNode.Parse(textBlock2.Text)?.AsObject(); + Assert.Equal("client-2", receivedMetadata2?["clientId"]?.GetValue()); + } }