From d003653e2ce7aea9001c7e942d47b7fc0a70ed74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 18:32:44 +0000 Subject: [PATCH 01/16] Initial plan From 034bd502da6da5ba5b47e45e406307404c6cd418 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 18:46:19 +0000 Subject: [PATCH 02/16] Add McpMetaAttribute and integrate with tools, prompts, and resources Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerPrompt.cs | 6 ++ .../Server/AIFunctionMcpServerResource.cs | 8 +++ .../Server/AIFunctionMcpServerTool.cs | 22 +++++++ .../Server/McpMetaAttribute.cs | 57 +++++++++++++++++++ 4 files changed, 93 insertions(+) create mode 100644 src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index 720c37521..4dd9e485d 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -138,6 +138,12 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Icons = options?.Icons, }; + // Populate Meta from McpMetaAttribute instances if a MethodInfo is in the metadata + if (options?.Metadata?.FirstOrDefault(m => m is MethodInfo) is MethodInfo method) + { + prompt.Meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method); + } + return new AIFunctionMcpServerPrompt(function, prompt, options?.Metadata ?? []); } diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index 5746a7dff..14f0d3b11 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -211,6 +211,13 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( string name = options?.Name ?? function.Name; + // Populate Meta from McpMetaAttribute instances if a MethodInfo is in the metadata + JsonObject? meta = null; + if (options?.Metadata?.FirstOrDefault(m => m is MethodInfo) is MethodInfo method) + { + meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method); + } + ResourceTemplate resource = new() { UriTemplate = options?.UriTemplate ?? DeriveUriTemplate(name, function), @@ -219,6 +226,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Description = options?.Description, MimeType = options?.MimeType ?? "application/octet-stream", Icons = options?.Icons, + Meta = meta, }; return new AIFunctionMcpServerResource(function, resource, options?.Metadata ?? []); diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index aa50046f9..d11461c9e 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -143,6 +143,12 @@ options.OpenWorld is not null || ReadOnlyHint = options.ReadOnly, }; } + + // Populate Meta from McpMetaAttribute instances if a MethodInfo is in the metadata + if (options.Metadata?.FirstOrDefault(m => m is MethodInfo) is MethodInfo method) + { + tool.Meta = CreateMetaFromAttributes(method); + } } return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []); @@ -351,6 +357,22 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) return metadata.AsReadOnly(); } + /// Creates a Meta JsonObject from McpMetaAttribute instances on the specified method. + internal static JsonObject? CreateMetaFromAttributes(MethodInfo method) + { + // Get all McpMetaAttribute instances from the method + var metaAttributes = method.GetCustomAttributes(); + + JsonObject? meta = null; + foreach (var attr in metaAttributes) + { + meta ??= new JsonObject(); + meta[attr.Name] = attr.Value; + } + + return meta; + } + /// Regex that flags runs of characters other than ASCII digits or letters. #if NET [GeneratedRegex("[^0-9A-Za-z]+")] diff --git a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs new file mode 100644 index 000000000..a9b07259f --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs @@ -0,0 +1,57 @@ +using ModelContextProtocol.Protocol; + +namespace ModelContextProtocol.Server; + +/// +/// Used to specify metadata for an MCP server primitive (tool, prompt, or resource). +/// +/// +/// +/// This attribute can be applied multiple times to a method to specify multiple key/value pairs +/// of metadata. The metadata is used to populate the , , +/// or property of the corresponding primitive. +/// +/// +/// Metadata can be used to attach additional information to primitives, such as model preferences, +/// version information, or other custom data that should be communicated to MCP clients. +/// +/// +/// +/// [McpServerTool] +/// [McpMeta(Name = "model", Value = "gpt-4o")] +/// [McpMeta(Name = "version", Value = "1.0")] +/// public string MyTool(string input) +/// { +/// return $"Processed: {input}"; +/// } +/// +/// +/// +[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] +public sealed class McpMetaAttribute : Attribute +{ + /// + /// Initializes a new instance of the class. + /// + public McpMetaAttribute() + { + } + + /// + /// Gets or sets the name (key) of the metadata entry. + /// + /// + /// This value is used as the key in the metadata object. It should be a unique identifier + /// for this piece of metadata within the context of the primitive. + /// + public required string Name { get; set; } + + /// + /// Gets or sets the value of the metadata entry. + /// + /// + /// This value is stored as a string in the metadata object. Complex values should be + /// serialized to JSON strings if needed. + /// + public required string Value { get; set; } +} From 47e68b49a3d1aa55793a15f0ba16832d6615fa88 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 18:51:18 +0000 Subject: [PATCH 03/16] Add tests and sample usage for McpMetaAttribute Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../AspNetCoreMcpServer/Tools/WeatherTools.cs | 4 + .../Server/McpMetaAttributeTests.cs | 128 ++++++++++++++++++ 2 files changed, 132 insertions(+) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs diff --git a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs index b4e3a7414..a3281c862 100644 --- a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs +++ b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs @@ -17,6 +17,8 @@ public WeatherTools(IHttpClientFactory httpClientFactory) } [McpServerTool, Description("Get weather alerts for a US state.")] + [McpMeta(Name = "category", Value = "weather")] + [McpMeta(Name = "dataSource", Value = "weather.gov")] public async Task GetAlerts( [Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state) { @@ -46,6 +48,8 @@ public async Task GetAlerts( } [McpServerTool, Description("Get weather forecast for a location.")] + [McpMeta(Name = "category", Value = "weather")] + [McpMeta(Name = "recommendedModel", Value = "gpt-4")] public async Task GetForecast( [Description("Latitude of the location.")] double latitude, [Description("Longitude of the location.")] double longitude) diff --git a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs new file mode 100644 index 000000000..67f5e7f9f --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs @@ -0,0 +1,128 @@ +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; +using System.Reflection; +using System.Text.Json.Nodes; + +namespace ModelContextProtocol.Tests.Server; + +public class McpMetaAttributeTests +{ + [Fact] + public void McpMetaAttribute_OnTool_PopulatesMeta() + { + // Arrange + var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithMeta))!; + + // Act + var tool = McpServerTool.Create(method, null); + + // Assert + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal("gpt-4o", tool.ProtocolTool.Meta["model"]?.ToString()); + Assert.Equal("1.0", tool.ProtocolTool.Meta["version"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_OnPrompt_PopulatesMeta() + { + // Arrange + var method = typeof(TestPromptClass).GetMethod(nameof(TestPromptClass.PromptWithMeta))!; + + // Act + var prompt = McpServerPrompt.Create(method, null); + + // Assert + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal("reasoning", prompt.ProtocolPrompt.Meta["type"]?.ToString()); + Assert.Equal("claude-3", prompt.ProtocolPrompt.Meta["model"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_OnResource_PopulatesMeta() + { + // Arrange + var method = typeof(TestResourceClass).GetMethod(nameof(TestResourceClass.ResourceWithMeta))!; + + // Act + var resource = McpServerResource.Create(method, null); + + // Assert + Assert.NotNull(resource.ProtocolResource.Meta); + Assert.Equal("text/plain", resource.ProtocolResource.Meta["encoding"]?.ToString()); + Assert.Equal("cached", resource.ProtocolResource.Meta["caching"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_WithoutAttributes_ReturnsNull() + { + // Arrange + var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithoutMeta))!; + + // Act + var tool = McpServerTool.Create(method, null); + + // Assert + Assert.Null(tool.ProtocolTool.Meta); + } + + [Fact] + public void McpMetaAttribute_SingleAttribute_PopulatesMeta() + { + // Arrange + var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithSingleMeta))!; + + // Act + var tool = McpServerTool.Create(method, null); + + // Assert + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal("test-value", tool.ProtocolTool.Meta["test-key"]?.ToString()); + Assert.Single(tool.ProtocolTool.Meta); + } + + private class TestToolClass + { + [McpServerTool] + [McpMeta(Name = "model", Value = "gpt-4o")] + [McpMeta(Name = "version", Value = "1.0")] + public static string ToolWithMeta(string input) + { + return input; + } + + [McpServerTool] + public static string ToolWithoutMeta(string input) + { + return input; + } + + [McpServerTool] + [McpMeta(Name = "test-key", Value = "test-value")] + public static string ToolWithSingleMeta(string input) + { + return input; + } + } + + private class TestPromptClass + { + [McpServerPrompt] + [McpMeta(Name = "type", Value = "reasoning")] + [McpMeta(Name = "model", Value = "claude-3")] + public static string PromptWithMeta(string input) + { + return input; + } + } + + private class TestResourceClass + { + [McpServerResource(UriTemplate = "resource://test/{id}")] + [McpMeta(Name = "encoding", Value = "text/plain")] + [McpMeta(Name = "caching", Value = "cached")] + public static string ResourceWithMeta(string id) + { + return $"Resource content for {id}"; + } + } +} From cf94e1b4635c70d79b6a4d568e6230531a267b9e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:08:19 +0000 Subject: [PATCH 04/16] Add Meta property to options classes and update logic to merge with attributes Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerPrompt.cs | 8 +- .../Server/AIFunctionMcpServerResource.cs | 8 +- .../Server/AIFunctionMcpServerTool.cs | 21 +++- .../Server/McpServerPromptCreateOptions.cs | 16 ++++ .../Server/McpServerResourceCreateOptions.cs | 16 ++++ .../Server/McpServerToolCreateOptions.cs | 16 ++++ .../Server/McpMetaAttributeTests.cs | 95 +++++++++++++++++++ 7 files changed, 171 insertions(+), 9 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index 4dd9e485d..59f47b23c 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -138,10 +138,14 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Icons = options?.Icons, }; - // Populate Meta from McpMetaAttribute instances if a MethodInfo is in the metadata + // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is in the metadata if (options?.Metadata?.FirstOrDefault(m => m is MethodInfo) is MethodInfo method) { - prompt.Meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method); + prompt.Meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options.Meta); + } + else if (options?.Meta is not null) + { + prompt.Meta = options.Meta; } return new AIFunctionMcpServerPrompt(function, prompt, options?.Metadata ?? []); diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index 14f0d3b11..25a4e5340 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -211,11 +211,15 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( string name = options?.Name ?? function.Name; - // Populate Meta from McpMetaAttribute instances if a MethodInfo is in the metadata + // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is in the metadata JsonObject? meta = null; if (options?.Metadata?.FirstOrDefault(m => m is MethodInfo) is MethodInfo method) { - meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method); + meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options.Meta); + } + else if (options?.Meta is not null) + { + meta = options.Meta; } ResourceTemplate resource = new() diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index d11461c9e..231195eac 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -144,10 +144,14 @@ options.OpenWorld is not null || }; } - // Populate Meta from McpMetaAttribute instances if a MethodInfo is in the metadata + // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is in the metadata if (options.Metadata?.FirstOrDefault(m => m is MethodInfo) is MethodInfo method) { - tool.Meta = CreateMetaFromAttributes(method); + tool.Meta = CreateMetaFromAttributes(method, options.Meta); + } + else if (options.Meta is not null) + { + tool.Meta = options.Meta; } } @@ -358,16 +362,23 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) } /// Creates a Meta JsonObject from McpMetaAttribute instances on the specified method. - internal static JsonObject? CreateMetaFromAttributes(MethodInfo method) + /// The method to extract McpMetaAttribute instances from. + /// Optional JsonObject to seed the Meta with. Properties from this object take precedence over attributes. + /// A JsonObject with metadata, or null if no metadata is present. + internal static JsonObject? CreateMetaFromAttributes(MethodInfo method, JsonObject? seedMeta = null) { // Get all McpMetaAttribute instances from the method var metaAttributes = method.GetCustomAttributes(); - JsonObject? meta = null; + JsonObject? meta = seedMeta; foreach (var attr in metaAttributes) { meta ??= new JsonObject(); - meta[attr.Name] = attr.Value; + // Only add the attribute property if it doesn't already exist in the seed + if (!meta.ContainsKey(attr.Name)) + { + meta[attr.Name] = attr.Value; + } } return meta; diff --git a/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs index 434ed4052..439bd738f 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs @@ -86,6 +86,21 @@ public sealed class McpServerPromptCreateOptions /// public IList? Icons { get; set; } + /// + /// Gets or sets metadata reserved by MCP for protocol-level metadata. + /// + /// + /// + /// This JsonObject is used to seed the property. Any metadata from + /// instances on the method will be added to this object, but + /// properties already present in this JsonObject will not be overwritten. + /// + /// + /// Implementations must not make assumptions about its contents. + /// + /// + public System.Text.Json.Nodes.JsonObject? Meta { get; set; } + /// /// Creates a shallow clone of the current instance. /// @@ -100,5 +115,6 @@ internal McpServerPromptCreateOptions Clone() => SchemaCreateOptions = SchemaCreateOptions, Metadata = Metadata, Icons = Icons, + Meta = Meta, }; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs index c519828a0..fa31d54a0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs @@ -101,6 +101,21 @@ public sealed class McpServerResourceCreateOptions /// public IList? Icons { get; set; } + /// + /// Gets or sets metadata reserved by MCP for protocol-level metadata. + /// + /// + /// + /// This JsonObject is used to seed the property. Any metadata from + /// instances on the method will be added to this object, but + /// properties already present in this JsonObject will not be overwritten. + /// + /// + /// Implementations must not make assumptions about its contents. + /// + /// + public System.Text.Json.Nodes.JsonObject? Meta { get; set; } + /// /// Creates a shallow clone of the current instance. /// @@ -117,5 +132,6 @@ internal McpServerResourceCreateOptions Clone() => SchemaCreateOptions = SchemaCreateOptions, Metadata = Metadata, Icons = Icons, + Meta = Meta, }; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 73f76bca7..5c9c2ae43 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -172,6 +172,21 @@ public sealed class McpServerToolCreateOptions /// public IList? Icons { get; set; } + /// + /// Gets or sets metadata reserved by MCP for protocol-level metadata. + /// + /// + /// + /// This JsonObject is used to seed the property. Any metadata from + /// instances on the method will be added to this object, but + /// properties already present in this JsonObject will not be overwritten. + /// + /// + /// Implementations must not make assumptions about its contents. + /// + /// + public System.Text.Json.Nodes.JsonObject? Meta { get; set; } + /// /// Creates a shallow clone of the current instance. /// @@ -191,5 +206,6 @@ internal McpServerToolCreateOptions Clone() => SchemaCreateOptions = SchemaCreateOptions, Metadata = Metadata, Icons = Icons, + Meta = Meta, }; } diff --git a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs index 67f5e7f9f..945dec8ea 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs @@ -80,6 +80,101 @@ public void McpMetaAttribute_SingleAttribute_PopulatesMeta() Assert.Single(tool.ProtocolTool.Meta); } + [Fact] + public void McpMetaAttribute_OptionsMetaTakesPrecedence() + { + // Arrange + var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithMeta))!; + var seedMeta = new JsonObject + { + ["model"] = "options-model", + ["extra"] = "options-extra" + }; + var options = new McpServerToolCreateOptions { Meta = seedMeta }; + + // Act + var tool = McpServerTool.Create(method, options); + + // Assert + Assert.NotNull(tool.ProtocolTool.Meta); + // Options Meta should win for "model" + Assert.Equal("options-model", tool.ProtocolTool.Meta["model"]?.ToString()); + // Attribute should add "version" since it's not in options + Assert.Equal("1.0", tool.ProtocolTool.Meta["version"]?.ToString()); + // Options Meta should include "extra" + Assert.Equal("options-extra", tool.ProtocolTool.Meta["extra"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_OptionsMetaOnly_NoAttributes() + { + // Arrange + var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithoutMeta))!; + var seedMeta = new JsonObject + { + ["custom"] = "value" + }; + var options = new McpServerToolCreateOptions { Meta = seedMeta }; + + // Act + var tool = McpServerTool.Create(method, options); + + // Assert + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal("value", tool.ProtocolTool.Meta["custom"]?.ToString()); + Assert.Single(tool.ProtocolTool.Meta); + } + + [Fact] + public void McpMetaAttribute_PromptOptionsMetaTakesPrecedence() + { + // Arrange + var method = typeof(TestPromptClass).GetMethod(nameof(TestPromptClass.PromptWithMeta))!; + var seedMeta = new JsonObject + { + ["type"] = "options-type", + ["extra"] = "options-extra" + }; + var options = new McpServerPromptCreateOptions { Meta = seedMeta }; + + // Act + var prompt = McpServerPrompt.Create(method, options); + + // Assert + Assert.NotNull(prompt.ProtocolPrompt.Meta); + // Options Meta should win for "type" + Assert.Equal("options-type", prompt.ProtocolPrompt.Meta["type"]?.ToString()); + // Attribute should add "model" since it's not in options + Assert.Equal("claude-3", prompt.ProtocolPrompt.Meta["model"]?.ToString()); + // Options Meta should include "extra" + Assert.Equal("options-extra", prompt.ProtocolPrompt.Meta["extra"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_ResourceOptionsMetaTakesPrecedence() + { + // Arrange + var method = typeof(TestResourceClass).GetMethod(nameof(TestResourceClass.ResourceWithMeta))!; + var seedMeta = new JsonObject + { + ["encoding"] = "options-encoding", + ["extra"] = "options-extra" + }; + var options = new McpServerResourceCreateOptions { Meta = seedMeta }; + + // Act + var resource = McpServerResource.Create(method, options); + + // Assert + Assert.NotNull(resource.ProtocolResource.Meta); + // Options Meta should win for "encoding" + Assert.Equal("options-encoding", resource.ProtocolResource.Meta["encoding"]?.ToString()); + // Attribute should add "caching" since it's not in options + Assert.Equal("cached", resource.ProtocolResource.Meta["caching"]?.ToString()); + // Options Meta should include "extra" + Assert.Equal("options-extra", resource.ProtocolResource.Meta["extra"]?.ToString()); + } + private class TestToolClass { [McpServerTool] From 8dc4a0a6a0b957023b7193ddceac0ad8ecc3f6c3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:12:56 +0000 Subject: [PATCH 05/16] Fix missing using directives and clarify McpMetaAttribute documentation Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerPrompt.cs | 1 + .../Server/AIFunctionMcpServerResource.cs | 1 + src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs | 5 +++-- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index 59f47b23c..ca8a83620 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -5,6 +5,7 @@ using System.Diagnostics; using System.Reflection; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Server; diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index 25a4e5340..cd97a5cbb 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -9,6 +9,7 @@ using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; +using System.Text.Json.Nodes; using System.Text.RegularExpressions; namespace ModelContextProtocol.Server; diff --git a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs index a9b07259f..f6f11058f 100644 --- a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs @@ -50,8 +50,9 @@ public McpMetaAttribute() /// Gets or sets the value of the metadata entry. /// /// - /// This value is stored as a string in the metadata object. Complex values should be - /// serialized to JSON strings if needed. + /// This value is stored as a string in the metadata object. For complex values, use the + /// , , + /// or property to provide a JsonObject directly. /// public required string Value { get; set; } } From 62cfd2def153342fbf0d748920a53eeadafe0b57 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:22:31 +0000 Subject: [PATCH 06/16] Make Name and Value constructor parameters in McpMetaAttribute and check AIFunction.Metadata.UnderlyingMethod Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../AspNetCoreMcpServer/Tools/WeatherTools.cs | 8 ++++---- .../Server/AIFunctionMcpServerPrompt.cs | 7 ++++--- .../Server/AIFunctionMcpServerResource.cs | 7 ++++--- .../Server/AIFunctionMcpServerTool.cs | 5 +++-- .../Server/McpMetaAttribute.cs | 18 +++++++++++------- .../Server/McpMetaAttributeTests.cs | 14 +++++++------- 6 files changed, 33 insertions(+), 26 deletions(-) diff --git a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs index a3281c862..f68e40570 100644 --- a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs +++ b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs @@ -17,8 +17,8 @@ public WeatherTools(IHttpClientFactory httpClientFactory) } [McpServerTool, Description("Get weather alerts for a US state.")] - [McpMeta(Name = "category", Value = "weather")] - [McpMeta(Name = "dataSource", Value = "weather.gov")] + [McpMeta("category", "weather")] + [McpMeta("dataSource", "weather.gov")] public async Task GetAlerts( [Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state) { @@ -48,8 +48,8 @@ public async Task GetAlerts( } [McpServerTool, Description("Get weather forecast for a location.")] - [McpMeta(Name = "category", Value = "weather")] - [McpMeta(Name = "recommendedModel", Value = "gpt-4")] + [McpMeta("category", "weather")] + [McpMeta("recommendedModel", "gpt-4")] public async Task GetForecast( [Description("Latitude of the location.")] double latitude, [Description("Longitude of the location.")] double longitude) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index ca8a83620..d105ce3ce 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -139,10 +139,11 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Icons = options?.Icons, }; - // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is in the metadata - if (options?.Metadata?.FirstOrDefault(m => m is MethodInfo) is MethodInfo method) + // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available + MethodInfo? method = options?.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.Metadata?.UnderlyingMethod; + if (method is not null) { - prompt.Meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options.Meta); + prompt.Meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options?.Meta); } else if (options?.Meta is not null) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index cd97a5cbb..34f9819d4 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -212,11 +212,12 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( string name = options?.Name ?? function.Name; - // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is in the metadata + // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available + MethodInfo? method = options?.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.Metadata?.UnderlyingMethod; JsonObject? meta = null; - if (options?.Metadata?.FirstOrDefault(m => m is MethodInfo) is MethodInfo method) + if (method is not null) { - meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options.Meta); + meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options?.Meta); } else if (options?.Meta is not null) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 231195eac..107e93ddc 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -144,8 +144,9 @@ options.OpenWorld is not null || }; } - // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is in the metadata - if (options.Metadata?.FirstOrDefault(m => m is MethodInfo) is MethodInfo method) + // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available + MethodInfo? method = options.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.Metadata?.UnderlyingMethod; + if (method is not null) { tool.Meta = CreateMetaFromAttributes(method, options.Meta); } diff --git a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs index f6f11058f..1e2b9e92f 100644 --- a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs @@ -18,8 +18,8 @@ namespace ModelContextProtocol.Server; /// /// /// [McpServerTool] -/// [McpMeta(Name = "model", Value = "gpt-4o")] -/// [McpMeta(Name = "version", Value = "1.0")] +/// [McpMeta("model", "gpt-4o")] +/// [McpMeta("version", "1.0")] /// public string MyTool(string input) /// { /// return $"Processed: {input}"; @@ -33,26 +33,30 @@ public sealed class McpMetaAttribute : Attribute /// /// Initializes a new instance of the class. /// - public McpMetaAttribute() + /// The name (key) of the metadata entry. + /// The value of the metadata entry. + public McpMetaAttribute(string name, string value) { + Name = name; + Value = value; } /// - /// Gets or sets the name (key) of the metadata entry. + /// Gets the name (key) of the metadata entry. /// /// /// This value is used as the key in the metadata object. It should be a unique identifier /// for this piece of metadata within the context of the primitive. /// - public required string Name { get; set; } + public string Name { get; } /// - /// Gets or sets the value of the metadata entry. + /// Gets the value of the metadata entry. /// /// /// This value is stored as a string in the metadata object. For complex values, use the /// , , /// or property to provide a JsonObject directly. /// - public required string Value { get; set; } + public string Value { get; } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs index 945dec8ea..e990d3549 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs @@ -178,8 +178,8 @@ public void McpMetaAttribute_ResourceOptionsMetaTakesPrecedence() private class TestToolClass { [McpServerTool] - [McpMeta(Name = "model", Value = "gpt-4o")] - [McpMeta(Name = "version", Value = "1.0")] + [McpMeta("model", "gpt-4o")] + [McpMeta("version", "1.0")] public static string ToolWithMeta(string input) { return input; @@ -192,7 +192,7 @@ public static string ToolWithoutMeta(string input) } [McpServerTool] - [McpMeta(Name = "test-key", Value = "test-value")] + [McpMeta("test-key", "test-value")] public static string ToolWithSingleMeta(string input) { return input; @@ -202,8 +202,8 @@ public static string ToolWithSingleMeta(string input) private class TestPromptClass { [McpServerPrompt] - [McpMeta(Name = "type", Value = "reasoning")] - [McpMeta(Name = "model", Value = "claude-3")] + [McpMeta("type", "reasoning")] + [McpMeta("model", "claude-3")] public static string PromptWithMeta(string input) { return input; @@ -213,8 +213,8 @@ public static string PromptWithMeta(string input) private class TestResourceClass { [McpServerResource(UriTemplate = "resource://test/{id}")] - [McpMeta(Name = "encoding", Value = "text/plain")] - [McpMeta(Name = "caching", Value = "cached")] + [McpMeta("encoding", "text/plain")] + [McpMeta("caching", "cached")] public static string ResourceWithMeta(string id) { return $"Resource content for {id}"; From ce334083e2d36582bb60edc2d1e1e8d388e44a1b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 19:28:07 +0000 Subject: [PATCH 07/16] Change Value type to object and use JsonSerializer.SerializeToNode, fix nullable warnings Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerTool.cs | 2 +- .../Server/McpMetaAttribute.cs | 15 +++++++++++---- .../Server/McpMetaAttributeTests.cs | 18 +++++++++--------- 3 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 107e93ddc..84fe0501c 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -378,7 +378,7 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) // Only add the attribute property if it doesn't already exist in the seed if (!meta.ContainsKey(attr.Name)) { - meta[attr.Name] = attr.Value; + meta[attr.Name] = JsonSerializer.SerializeToNode(attr.Value); } } diff --git a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs index 1e2b9e92f..9da5c0b0a 100644 --- a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs @@ -34,8 +34,8 @@ public sealed class McpMetaAttribute : Attribute /// Initializes a new instance of the class. /// /// The name (key) of the metadata entry. - /// The value of the metadata entry. - public McpMetaAttribute(string name, string value) + /// The value of the metadata entry. This can be any value that can be encoded in .NET metadata. + public McpMetaAttribute(string name, object? value) { Name = name; Value = value; @@ -54,9 +54,16 @@ public McpMetaAttribute(string name, string value) /// Gets the value of the metadata entry. /// /// - /// This value is stored as a string in the metadata object. For complex values, use the + /// + /// This value can be any object that can be encoded in .NET metadata (strings, numbers, booleans, etc.). + /// The value will be serialized to JSON using when + /// populating the metadata JsonObject. + /// + /// + /// For complex JSON structures that cannot be represented as .NET metadata, use the /// , , /// or property to provide a JsonObject directly. + /// /// - public string Value { get; } + public object? Value { get; } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs index e990d3549..c2edab618 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs @@ -14,7 +14,7 @@ public void McpMetaAttribute_OnTool_PopulatesMeta() var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithMeta))!; // Act - var tool = McpServerTool.Create(method, null); + var tool = McpServerTool.Create(method, target: null); // Assert Assert.NotNull(tool.ProtocolTool.Meta); @@ -29,7 +29,7 @@ public void McpMetaAttribute_OnPrompt_PopulatesMeta() var method = typeof(TestPromptClass).GetMethod(nameof(TestPromptClass.PromptWithMeta))!; // Act - var prompt = McpServerPrompt.Create(method, null); + var prompt = McpServerPrompt.Create(method, target: null); // Assert Assert.NotNull(prompt.ProtocolPrompt.Meta); @@ -44,7 +44,7 @@ public void McpMetaAttribute_OnResource_PopulatesMeta() var method = typeof(TestResourceClass).GetMethod(nameof(TestResourceClass.ResourceWithMeta))!; // Act - var resource = McpServerResource.Create(method, null); + var resource = McpServerResource.Create(method, target: null); // Assert Assert.NotNull(resource.ProtocolResource.Meta); @@ -59,7 +59,7 @@ public void McpMetaAttribute_WithoutAttributes_ReturnsNull() var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithoutMeta))!; // Act - var tool = McpServerTool.Create(method, null); + var tool = McpServerTool.Create(method, target: null); // Assert Assert.Null(tool.ProtocolTool.Meta); @@ -72,7 +72,7 @@ public void McpMetaAttribute_SingleAttribute_PopulatesMeta() var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithSingleMeta))!; // Act - var tool = McpServerTool.Create(method, null); + var tool = McpServerTool.Create(method, target: null); // Assert Assert.NotNull(tool.ProtocolTool.Meta); @@ -93,7 +93,7 @@ public void McpMetaAttribute_OptionsMetaTakesPrecedence() var options = new McpServerToolCreateOptions { Meta = seedMeta }; // Act - var tool = McpServerTool.Create(method, options); + var tool = McpServerTool.Create(method, target: null, options: options); // Assert Assert.NotNull(tool.ProtocolTool.Meta); @@ -117,7 +117,7 @@ public void McpMetaAttribute_OptionsMetaOnly_NoAttributes() var options = new McpServerToolCreateOptions { Meta = seedMeta }; // Act - var tool = McpServerTool.Create(method, options); + var tool = McpServerTool.Create(method, target: null, options: options); // Assert Assert.NotNull(tool.ProtocolTool.Meta); @@ -138,7 +138,7 @@ public void McpMetaAttribute_PromptOptionsMetaTakesPrecedence() var options = new McpServerPromptCreateOptions { Meta = seedMeta }; // Act - var prompt = McpServerPrompt.Create(method, options); + var prompt = McpServerPrompt.Create(method, target: null, options: options); // Assert Assert.NotNull(prompt.ProtocolPrompt.Meta); @@ -163,7 +163,7 @@ public void McpMetaAttribute_ResourceOptionsMetaTakesPrecedence() var options = new McpServerResourceCreateOptions { Meta = seedMeta }; // Act - var resource = McpServerResource.Create(method, options); + var resource = McpServerResource.Create(method, target: null, options: options); // Assert Assert.NotNull(resource.ProtocolResource.Meta); From 540eea48fd4027c661a6331c8547e4ddb2aaa70a Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:10:12 +0000 Subject: [PATCH 08/16] Fix UnderlyingMethod property path, use SerializerOptions, and fix JsonObject cref Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerPrompt.cs | 4 ++-- .../Server/AIFunctionMcpServerResource.cs | 4 ++-- .../Server/AIFunctionMcpServerTool.cs | 9 +++++---- .../Server/McpServerPromptCreateOptions.cs | 4 ++-- .../Server/McpServerResourceCreateOptions.cs | 4 ++-- .../Server/McpServerToolCreateOptions.cs | 4 ++-- 6 files changed, 15 insertions(+), 14 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index d105ce3ce..257df7922 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -140,10 +140,10 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( }; // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available - MethodInfo? method = options?.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.Metadata?.UnderlyingMethod; + MethodInfo? method = options?.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.UnderlyingMethod; if (method is not null) { - prompt.Meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options?.Meta); + prompt.Meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options?.Meta, options?.SerializerOptions); } else if (options?.Meta is not null) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index 34f9819d4..b62f37c95 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -213,11 +213,11 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( string name = options?.Name ?? function.Name; // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available - MethodInfo? method = options?.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.Metadata?.UnderlyingMethod; + MethodInfo? method = options?.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.UnderlyingMethod; JsonObject? meta = null; if (method is not null) { - meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options?.Meta); + meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options?.Meta, options?.SerializerOptions); } else if (options?.Meta is not null) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 84fe0501c..d6640b953 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -145,10 +145,10 @@ options.OpenWorld is not null || } // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available - MethodInfo? method = options.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.Metadata?.UnderlyingMethod; + MethodInfo? method = options.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.UnderlyingMethod; if (method is not null) { - tool.Meta = CreateMetaFromAttributes(method, options.Meta); + tool.Meta = CreateMetaFromAttributes(method, options.Meta, options.SerializerOptions); } else if (options.Meta is not null) { @@ -365,8 +365,9 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) /// Creates a Meta JsonObject from McpMetaAttribute instances on the specified method. /// The method to extract McpMetaAttribute instances from. /// Optional JsonObject to seed the Meta with. Properties from this object take precedence over attributes. + /// Optional JsonSerializerOptions to use for serialization. Defaults to McpJsonUtilities.DefaultOptions if not provided. /// A JsonObject with metadata, or null if no metadata is present. - internal static JsonObject? CreateMetaFromAttributes(MethodInfo method, JsonObject? seedMeta = null) + internal static JsonObject? CreateMetaFromAttributes(MethodInfo method, JsonObject? seedMeta = null, JsonSerializerOptions? serializerOptions = null) { // Get all McpMetaAttribute instances from the method var metaAttributes = method.GetCustomAttributes(); @@ -378,7 +379,7 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) // Only add the attribute property if it doesn't already exist in the seed if (!meta.ContainsKey(attr.Name)) { - meta[attr.Name] = JsonSerializer.SerializeToNode(attr.Value); + meta[attr.Name] = JsonSerializer.SerializeToNode(attr.Value, serializerOptions ?? McpJsonUtilities.DefaultOptions); } } diff --git a/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs index 439bd738f..2c7529360 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs @@ -91,9 +91,9 @@ public sealed class McpServerPromptCreateOptions /// /// /// - /// This JsonObject is used to seed the property. Any metadata from + /// This is used to seed the property. Any metadata from /// instances on the method will be added to this object, but - /// properties already present in this JsonObject will not be overwritten. + /// properties already present in this will not be overwritten. /// /// /// Implementations must not make assumptions about its contents. diff --git a/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs index fa31d54a0..cf8671516 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs @@ -106,9 +106,9 @@ public sealed class McpServerResourceCreateOptions /// /// /// - /// This JsonObject is used to seed the property. Any metadata from + /// This is used to seed the property. Any metadata from /// instances on the method will be added to this object, but - /// properties already present in this JsonObject will not be overwritten. + /// properties already present in this will not be overwritten. /// /// /// Implementations must not make assumptions about its contents. diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 5c9c2ae43..6daa015df 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -177,9 +177,9 @@ public sealed class McpServerToolCreateOptions /// /// /// - /// This JsonObject is used to seed the property. Any metadata from + /// This is used to seed the property. Any metadata from /// instances on the method will be added to this object, but - /// properties already present in this JsonObject will not be overwritten. + /// properties already present in this will not be overwritten. /// /// /// Implementations must not make assumptions about its contents. From e9ef92257ae1b37e0630a845e069ebb3c0fe0e5f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 20:17:10 +0000 Subject: [PATCH 09/16] Simplify MethodInfo resolution to use function.UnderlyingMethod directly Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerPrompt.cs | 5 ++--- .../Server/AIFunctionMcpServerResource.cs | 5 ++--- .../Server/AIFunctionMcpServerTool.cs | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index 257df7922..4b027f92e 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -140,10 +140,9 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( }; // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available - MethodInfo? method = options?.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.UnderlyingMethod; - if (method is not null) + if (function.UnderlyingMethod is not null) { - prompt.Meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options?.Meta, options?.SerializerOptions); + prompt.Meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(function.UnderlyingMethod, options?.Meta, options?.SerializerOptions); } else if (options?.Meta is not null) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index b62f37c95..24b2830f2 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -213,11 +213,10 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( string name = options?.Name ?? function.Name; // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available - MethodInfo? method = options?.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.UnderlyingMethod; JsonObject? meta = null; - if (method is not null) + if (function.UnderlyingMethod is not null) { - meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(method, options?.Meta, options?.SerializerOptions); + meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(function.UnderlyingMethod, options?.Meta, options?.SerializerOptions); } else if (options?.Meta is not null) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index d6640b953..703a97147 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -145,10 +145,9 @@ options.OpenWorld is not null || } // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available - MethodInfo? method = options.Metadata?.FirstOrDefault(m => m is MethodInfo) as MethodInfo ?? function.UnderlyingMethod; - if (method is not null) + if (function.UnderlyingMethod is not null) { - tool.Meta = CreateMetaFromAttributes(method, options.Meta, options.SerializerOptions); + tool.Meta = CreateMetaFromAttributes(function.UnderlyingMethod, options.Meta, options.SerializerOptions); } else if (options.Meta is not null) { From 5ac543a42e9f8ca785c61a8699659875c430ef15 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Mon, 13 Oct 2025 21:13:36 +0000 Subject: [PATCH 10/16] Fix IL2026 trimming warning by providing type info to JsonSerializer.SerializeToNode Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../Server/AIFunctionMcpServerTool.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 703a97147..d98b05949 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -372,13 +372,14 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) var metaAttributes = method.GetCustomAttributes(); JsonObject? meta = seedMeta; + JsonSerializerOptions options = serializerOptions ?? McpJsonUtilities.DefaultOptions; foreach (var attr in metaAttributes) { meta ??= new JsonObject(); // Only add the attribute property if it doesn't already exist in the seed if (!meta.ContainsKey(attr.Name)) { - meta[attr.Name] = JsonSerializer.SerializeToNode(attr.Value, serializerOptions ?? McpJsonUtilities.DefaultOptions); + meta[attr.Name] = JsonSerializer.SerializeToNode(attr.Value, options.GetTypeInfo(typeof(object))); } } From c734f237e27e467622fe384e842178058eb81876 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Mon, 13 Oct 2025 18:20:30 -0400 Subject: [PATCH 11/16] Fix remaining issues --- .../Server/AIFunctionMcpServerPrompt.cs | 11 +- .../Server/AIFunctionMcpServerResource.cs | 15 +- .../Server/AIFunctionMcpServerTool.cs | 46 ++-- .../Server/McpMetaAttributeTests.cs | 198 ++++++++++++++---- 4 files changed, 186 insertions(+), 84 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs index 4b027f92e..7a0fa04ca 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerPrompt.cs @@ -140,14 +140,9 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( }; // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available - if (function.UnderlyingMethod is not null) - { - prompt.Meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(function.UnderlyingMethod, options?.Meta, options?.SerializerOptions); - } - else if (options?.Meta is not null) - { - prompt.Meta = options.Meta; - } + prompt.Meta = function.UnderlyingMethod is not null ? + AIFunctionMcpServerTool.CreateMetaFromAttributes(function.UnderlyingMethod, options?.Meta, options?.SerializerOptions) : + options?.Meta; return new AIFunctionMcpServerPrompt(function, prompt, options?.Metadata ?? []); } diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index 24b2830f2..91dc20421 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -212,17 +212,6 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( string name = options?.Name ?? function.Name; - // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available - JsonObject? meta = null; - if (function.UnderlyingMethod is not null) - { - meta = AIFunctionMcpServerTool.CreateMetaFromAttributes(function.UnderlyingMethod, options?.Meta, options?.SerializerOptions); - } - else if (options?.Meta is not null) - { - meta = options.Meta; - } - ResourceTemplate resource = new() { UriTemplate = options?.UriTemplate ?? DeriveUriTemplate(name, function), @@ -231,7 +220,9 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions( Description = options?.Description, MimeType = options?.MimeType ?? "application/octet-stream", Icons = options?.Icons, - Meta = meta, + Meta = function.UnderlyingMethod is not null ? + AIFunctionMcpServerTool.CreateMetaFromAttributes(function.UnderlyingMethod, options?.Meta, options?.SerializerOptions) : + options?.Meta, }; return new AIFunctionMcpServerResource(function, resource, options?.Metadata ?? []); diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index d98b05949..6dd55893c 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -6,6 +6,7 @@ using System.Reflection; using System.Text.Json; using System.Text.Json.Nodes; +using System.Text.Json.Serialization.Metadata; using System.Text.RegularExpressions; namespace ModelContextProtocol.Server; @@ -145,14 +146,9 @@ options.OpenWorld is not null || } // Populate Meta from options and/or McpMetaAttribute instances if a MethodInfo is available - if (function.UnderlyingMethod is not null) - { - tool.Meta = CreateMetaFromAttributes(function.UnderlyingMethod, options.Meta, options.SerializerOptions); - } - else if (options.Meta is not null) - { - tool.Meta = options.Meta; - } + tool.Meta = function.UnderlyingMethod is not null ? + CreateMetaFromAttributes(function.UnderlyingMethod, options.Meta, options.SerializerOptions) : + options.Meta; } return new AIFunctionMcpServerTool(function, tool, options?.Services, structuredOutputRequiresWrapping, options?.Metadata ?? []); @@ -361,25 +357,35 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) return metadata.AsReadOnly(); } - /// Creates a Meta JsonObject from McpMetaAttribute instances on the specified method. - /// The method to extract McpMetaAttribute instances from. - /// Optional JsonObject to seed the Meta with. Properties from this object take precedence over attributes. - /// Optional JsonSerializerOptions to use for serialization. Defaults to McpJsonUtilities.DefaultOptions if not provided. - /// A JsonObject with metadata, or null if no metadata is present. - internal static JsonObject? CreateMetaFromAttributes(MethodInfo method, JsonObject? seedMeta = null, JsonSerializerOptions? serializerOptions = null) + /// Creates a Meta from instances on the specified method. + /// The method to extract instances from. + /// Optional to seed the Meta with. Properties from this object take precedence over attributes. + /// Optional to use for serialization. Defaults to if not provided. + /// A with metadata, or null if no metadata is present. + internal static JsonObject? CreateMetaFromAttributes(MethodInfo method, JsonObject? meta = null, JsonSerializerOptions? serializerOptions = null) { - // Get all McpMetaAttribute instances from the method + // Get all McpMetaAttribute instances from the method. var metaAttributes = method.GetCustomAttributes(); - JsonObject? meta = seedMeta; - JsonSerializerOptions options = serializerOptions ?? McpJsonUtilities.DefaultOptions; foreach (var attr in metaAttributes) { - meta ??= new JsonObject(); - // Only add the attribute property if it doesn't already exist in the seed + meta ??= []; if (!meta.ContainsKey(attr.Name)) { - meta[attr.Name] = JsonSerializer.SerializeToNode(attr.Value, options.GetTypeInfo(typeof(object))); + JsonTypeInfo? valueTypeInfo = null; + if (attr.Value?.GetType() is { } valueType) + { + if (serializerOptions?.TryGetTypeInfo(valueType, out valueTypeInfo) is not true && + McpJsonUtilities.DefaultOptions.TryGetTypeInfo(valueType, out valueTypeInfo) is not true) + { + // Throw using GetTypeInfo in order to get a good exception message. + (serializerOptions ?? McpJsonUtilities.DefaultOptions).GetTypeInfo(valueType); + } + + Debug.Assert(valueTypeInfo is not null, "GetTypeInfo should have thrown an exception"); + } + + meta[attr.Name] = valueTypeInfo is not null ? JsonSerializer.SerializeToNode(attr.Value, valueTypeInfo) : null; } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs index c2edab618..ca711d666 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs @@ -1,22 +1,18 @@ -using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; -using System.Reflection; using System.Text.Json.Nodes; +using System.Text.Json.Serialization; namespace ModelContextProtocol.Tests.Server; -public class McpMetaAttributeTests +public partial class McpMetaAttributeTests { [Fact] public void McpMetaAttribute_OnTool_PopulatesMeta() { - // Arrange var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithMeta))!; - // Act var tool = McpServerTool.Create(method, target: null); - // Assert Assert.NotNull(tool.ProtocolTool.Meta); Assert.Equal("gpt-4o", tool.ProtocolTool.Meta["model"]?.ToString()); Assert.Equal("1.0", tool.ProtocolTool.Meta["version"]?.ToString()); @@ -25,13 +21,10 @@ public void McpMetaAttribute_OnTool_PopulatesMeta() [Fact] public void McpMetaAttribute_OnPrompt_PopulatesMeta() { - // Arrange var method = typeof(TestPromptClass).GetMethod(nameof(TestPromptClass.PromptWithMeta))!; - // Act var prompt = McpServerPrompt.Create(method, target: null); - // Assert Assert.NotNull(prompt.ProtocolPrompt.Meta); Assert.Equal("reasoning", prompt.ProtocolPrompt.Meta["type"]?.ToString()); Assert.Equal("claude-3", prompt.ProtocolPrompt.Meta["model"]?.ToString()); @@ -40,28 +33,22 @@ public void McpMetaAttribute_OnPrompt_PopulatesMeta() [Fact] public void McpMetaAttribute_OnResource_PopulatesMeta() { - // Arrange var method = typeof(TestResourceClass).GetMethod(nameof(TestResourceClass.ResourceWithMeta))!; - // Act var resource = McpServerResource.Create(method, target: null); - // Assert - Assert.NotNull(resource.ProtocolResource.Meta); - Assert.Equal("text/plain", resource.ProtocolResource.Meta["encoding"]?.ToString()); - Assert.Equal("cached", resource.ProtocolResource.Meta["caching"]?.ToString()); + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal("text/plain", resource.ProtocolResourceTemplate.Meta["encoding"]?.ToString()); + Assert.Equal("cached", resource.ProtocolResourceTemplate.Meta["caching"]?.ToString()); } [Fact] public void McpMetaAttribute_WithoutAttributes_ReturnsNull() { - // Arrange var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithoutMeta))!; - // Act var tool = McpServerTool.Create(method, target: null); - // Assert Assert.Null(tool.ProtocolTool.Meta); } @@ -83,7 +70,6 @@ public void McpMetaAttribute_SingleAttribute_PopulatesMeta() [Fact] public void McpMetaAttribute_OptionsMetaTakesPrecedence() { - // Arrange var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithMeta))!; var seedMeta = new JsonObject { @@ -92,34 +78,25 @@ public void McpMetaAttribute_OptionsMetaTakesPrecedence() }; var options = new McpServerToolCreateOptions { Meta = seedMeta }; - // Act var tool = McpServerTool.Create(method, target: null, options: options); - // Assert Assert.NotNull(tool.ProtocolTool.Meta); - // Options Meta should win for "model" Assert.Equal("options-model", tool.ProtocolTool.Meta["model"]?.ToString()); - // Attribute should add "version" since it's not in options Assert.Equal("1.0", tool.ProtocolTool.Meta["version"]?.ToString()); - // Options Meta should include "extra" Assert.Equal("options-extra", tool.ProtocolTool.Meta["extra"]?.ToString()); } [Fact] public void McpMetaAttribute_OptionsMetaOnly_NoAttributes() { - // Arrange var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithoutMeta))!; var seedMeta = new JsonObject { ["custom"] = "value" }; var options = new McpServerToolCreateOptions { Meta = seedMeta }; - - // Act var tool = McpServerTool.Create(method, target: null, options: options); - // Assert Assert.NotNull(tool.ProtocolTool.Meta); Assert.Equal("value", tool.ProtocolTool.Meta["custom"]?.ToString()); Assert.Single(tool.ProtocolTool.Meta); @@ -128,7 +105,6 @@ public void McpMetaAttribute_OptionsMetaOnly_NoAttributes() [Fact] public void McpMetaAttribute_PromptOptionsMetaTakesPrecedence() { - // Arrange var method = typeof(TestPromptClass).GetMethod(nameof(TestPromptClass.PromptWithMeta))!; var seedMeta = new JsonObject { @@ -137,23 +113,17 @@ public void McpMetaAttribute_PromptOptionsMetaTakesPrecedence() }; var options = new McpServerPromptCreateOptions { Meta = seedMeta }; - // Act var prompt = McpServerPrompt.Create(method, target: null, options: options); - // Assert Assert.NotNull(prompt.ProtocolPrompt.Meta); - // Options Meta should win for "type" Assert.Equal("options-type", prompt.ProtocolPrompt.Meta["type"]?.ToString()); - // Attribute should add "model" since it's not in options Assert.Equal("claude-3", prompt.ProtocolPrompt.Meta["model"]?.ToString()); - // Options Meta should include "extra" Assert.Equal("options-extra", prompt.ProtocolPrompt.Meta["extra"]?.ToString()); } [Fact] public void McpMetaAttribute_ResourceOptionsMetaTakesPrecedence() { - // Arrange var method = typeof(TestResourceClass).GetMethod(nameof(TestResourceClass.ResourceWithMeta))!; var seedMeta = new JsonObject { @@ -162,17 +132,106 @@ public void McpMetaAttribute_ResourceOptionsMetaTakesPrecedence() }; var options = new McpServerResourceCreateOptions { Meta = seedMeta }; - // Act var resource = McpServerResource.Create(method, target: null, options: options); - // Assert - Assert.NotNull(resource.ProtocolResource.Meta); - // Options Meta should win for "encoding" - Assert.Equal("options-encoding", resource.ProtocolResource.Meta["encoding"]?.ToString()); - // Attribute should add "caching" since it's not in options - Assert.Equal("cached", resource.ProtocolResource.Meta["caching"]?.ToString()); - // Options Meta should include "extra" - Assert.Equal("options-extra", resource.ProtocolResource.Meta["extra"]?.ToString()); + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal("options-encoding", resource.ProtocolResourceTemplate.Meta["encoding"]?.ToString()); + Assert.Equal("cached", resource.ProtocolResourceTemplate.Meta["caching"]?.ToString()); + Assert.Equal("options-extra", resource.ProtocolResourceTemplate.Meta["extra"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_ResourceOptionsMetaOnly_NoAttributes() + { + var method = typeof(TestResourceNoMetaClass).GetMethod(nameof(TestResourceNoMetaClass.ResourceWithoutMeta))!; + var seedMeta = new JsonObject { ["only"] = "resource" }; + var options = new McpServerResourceCreateOptions { Meta = seedMeta }; + + var resource = McpServerResource.Create(method, target: null, options: options); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal("resource", resource.ProtocolResourceTemplate.Meta["only"]?.ToString()); + Assert.Single(resource.ProtocolResourceTemplate.Meta!); + } + + [Fact] + public void McpMetaAttribute_PromptWithoutMeta_ReturnsNull() + { + var method = typeof(TestPromptNoMetaClass).GetMethod(nameof(TestPromptNoMetaClass.PromptWithoutMeta))!; + var prompt = McpServerPrompt.Create(method, target: null); + Assert.Null(prompt.ProtocolPrompt.Meta); + } + + [Fact] + public void McpMetaAttribute_DuplicateKeys_IgnoresLaterAttributes() + { + var method = typeof(TestToolDuplicateMetaClass).GetMethod(nameof(TestToolDuplicateMetaClass.ToolWithDuplicateMeta))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + // "key" first attribute value should remain, second ignored + Assert.Equal("first", tool.ProtocolTool.Meta["key"]?.ToString()); + // Ensure only two keys (key and other) + Assert.Equal(2, tool.ProtocolTool.Meta!.Count); + Assert.Equal("other-value", tool.ProtocolTool.Meta["other"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_DuplicateKeys_WithSeedMeta_SeedTakesPrecedence() + { + var method = typeof(TestToolDuplicateMetaClass).GetMethod(nameof(TestToolDuplicateMetaClass.ToolWithDuplicateMeta))!; + var seedMeta = new JsonObject { ["key"] = "seed" }; + var options = new McpServerToolCreateOptions { Meta = seedMeta }; + var tool = McpServerTool.Create(method, target: null, options: options); + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal("seed", tool.ProtocolTool.Meta["key"]?.ToString()); + Assert.Equal("other-value", tool.ProtocolTool.Meta["other"]?.ToString()); + Assert.Equal(2, tool.ProtocolTool.Meta!.Count); + } + + [Fact] + public void McpMetaAttribute_NonStringValues_Serialized() + { + var method = typeof(TestToolNonStringMetaClass).GetMethod(nameof(TestToolNonStringMetaClass.ToolWithNonStringMeta))!; + var tool = McpServerTool.Create(method, target: null, options: new() { SerializerOptions = JsonContext5.Default.Options }); + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal("42", tool.ProtocolTool.Meta["intValue"]?.ToString()); + Assert.Equal("true", tool.ProtocolTool.Meta["boolValue"]?.ToString()); + // Enum serialized as numeric by default + Assert.Equal(((int)TestEnum.Second).ToString(), tool.ProtocolTool.Meta["enumValue"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_NullValue_SerializedAsNull() + { + var method = typeof(TestToolNullMetaClass).GetMethod(nameof(TestToolNullMetaClass.ToolWithNullMeta))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.True(tool.ProtocolTool.Meta.ContainsKey("nullable")); + Assert.Null(tool.ProtocolTool.Meta["nullable"]); + } + + [Fact] + public void McpMetaAttribute_ClassLevelAttributesIgnored() + { + // Since McpMetaAttribute is only valid on methods, class-level attributes are not supported. + // This test simply validates method-level attributes still function as expected. + var method = typeof(TestToolMethodMetaOnlyClass).GetMethod(nameof(TestToolMethodMetaOnlyClass.ToolWithMethodMeta))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal("method", tool.ProtocolTool.Meta["methodKey"]?.ToString()); + // Ensure only the method-level key exists + Assert.Single(tool.ProtocolTool.Meta!); + } + + [Fact] + public void McpMetaAttribute_DelegateOverload_PopulatesMeta() + { + // Create tool using delegate overload instead of MethodInfo directly + var del = new Func(TestToolClass.ToolWithMeta); + var tool = McpServerTool.Create(del); + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal("gpt-4o", tool.ProtocolTool.Meta!["model"]?.ToString()); + Assert.Equal("1.0", tool.ProtocolTool.Meta["version"]?.ToString()); } private class TestToolClass @@ -220,4 +279,55 @@ public static string ResourceWithMeta(string id) return $"Resource content for {id}"; } } + + private class TestResourceNoMetaClass + { + [McpServerResource(UriTemplate = "resource://test2/{id}")] + public static string ResourceWithoutMeta(string id) => id; + } + + private class TestPromptNoMetaClass + { + [McpServerPrompt] + public static string PromptWithoutMeta(string input) => input; + } + + private class TestToolDuplicateMetaClass + { + [McpServerTool] + [McpMeta("key", "first")] + [McpMeta("key", "second")] + [McpMeta("other", "other-value")] + public static string ToolWithDuplicateMeta(string input) => input; + } + + private enum TestEnum { First = 0, Second = 1 } + + private class TestToolNonStringMetaClass + { + [McpServerTool] + [McpMeta("intValue", 42)] + [McpMeta("boolValue", true)] + [McpMeta("enumValue", TestEnum.Second)] + public static string ToolWithNonStringMeta(string input) => input; + } + + private class TestToolNullMetaClass + { + [McpServerTool] + [McpMeta("nullable", null)] + public static string ToolWithNullMeta(string input) => input; + } + + private class TestToolMethodMetaOnlyClass + { + [McpServerTool] + [McpMeta("methodKey", "method")] + public static string ToolWithMethodMeta(string input) => input; + } + + [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] + [JsonSerializable(typeof(string))] + [JsonSerializable(typeof(TestEnum))] + private partial class JsonContext5 : JsonSerializerContext; } From 8b801edb89b95f3a2ecf60a247eb54c7e71b8e2e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Oct 2025 03:48:37 +0000 Subject: [PATCH 12/16] Revert to string-based JSON values for McpMetaAttribute with StringSyntax annotation Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- .../AspNetCoreMcpServer/Tools/WeatherTools.cs | 4 +- .../Server/AIFunctionMcpServerTool.cs | 18 ++------ .../Server/McpMetaAttribute.cs | 27 ++++++----- .../Server/McpMetaAttributeTests.cs | 45 ++++++++----------- 4 files changed, 38 insertions(+), 56 deletions(-) diff --git a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs index f68e40570..58d636c48 100644 --- a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs +++ b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs @@ -17,8 +17,8 @@ public WeatherTools(IHttpClientFactory httpClientFactory) } [McpServerTool, Description("Get weather alerts for a US state.")] - [McpMeta("category", "weather")] - [McpMeta("dataSource", "weather.gov")] + [McpMeta("category", "\"weather\"")] + [McpMeta("dataSource", "\"weather.gov\"")] public async Task GetAlerts( [Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state) { diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 6dd55893c..cd2512d38 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -360,7 +360,7 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) /// Creates a Meta from instances on the specified method. /// The method to extract instances from. /// Optional to seed the Meta with. Properties from this object take precedence over attributes. - /// Optional to use for serialization. Defaults to if not provided. + /// Optional to use for serialization. This parameter is ignored when parsing JSON strings from attributes. /// A with metadata, or null if no metadata is present. internal static JsonObject? CreateMetaFromAttributes(MethodInfo method, JsonObject? meta = null, JsonSerializerOptions? serializerOptions = null) { @@ -372,20 +372,8 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) meta ??= []; if (!meta.ContainsKey(attr.Name)) { - JsonTypeInfo? valueTypeInfo = null; - if (attr.Value?.GetType() is { } valueType) - { - if (serializerOptions?.TryGetTypeInfo(valueType, out valueTypeInfo) is not true && - McpJsonUtilities.DefaultOptions.TryGetTypeInfo(valueType, out valueTypeInfo) is not true) - { - // Throw using GetTypeInfo in order to get a good exception message. - (serializerOptions ?? McpJsonUtilities.DefaultOptions).GetTypeInfo(valueType); - } - - Debug.Assert(valueTypeInfo is not null, "GetTypeInfo should have thrown an exception"); - } - - meta[attr.Name] = valueTypeInfo is not null ? JsonSerializer.SerializeToNode(attr.Value, valueTypeInfo) : null; + // Parse the JSON string value into a JsonNode + meta[attr.Name] = JsonNode.Parse(attr.Value); } } diff --git a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs index 9da5c0b0a..2d8a76e49 100644 --- a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Protocol; +using System.Diagnostics.CodeAnalysis; namespace ModelContextProtocol.Server; @@ -18,8 +19,9 @@ namespace ModelContextProtocol.Server; /// /// /// [McpServerTool] -/// [McpMeta("model", "gpt-4o")] -/// [McpMeta("version", "1.0")] +/// [McpMeta("model", "\"gpt-4o\"")] +/// [McpMeta("version", "\"1.0\"")] +/// [McpMeta("priority", "5")] /// public string MyTool(string input) /// { /// return $"Processed: {input}"; @@ -34,8 +36,8 @@ public sealed class McpMetaAttribute : Attribute /// Initializes a new instance of the class. /// /// The name (key) of the metadata entry. - /// The value of the metadata entry. This can be any value that can be encoded in .NET metadata. - public McpMetaAttribute(string name, object? value) + /// The value of the metadata entry as a JSON string. This must be well-formed JSON. + public McpMetaAttribute(string name, [StringSyntax(StringSyntaxAttribute.Json)] string value) { Name = name; Value = value; @@ -51,19 +53,20 @@ public McpMetaAttribute(string name, object? value) public string Name { get; } /// - /// Gets the value of the metadata entry. + /// Gets the value of the metadata entry as a JSON string. /// /// /// - /// This value can be any object that can be encoded in .NET metadata (strings, numbers, booleans, etc.). - /// The value will be serialized to JSON using when - /// populating the metadata JsonObject. + /// This value must be well-formed JSON. It will be parsed and added to the metadata JsonObject. + /// Simple values can be represented as JSON literals like "\"my-string\"", "123", + /// "true", etc. Complex structures can be represented as JSON objects or arrays. /// /// - /// For complex JSON structures that cannot be represented as .NET metadata, use the - /// , , - /// or property to provide a JsonObject directly. + /// For programmatic scenarios where you want to construct complex metadata without dealing with + /// JSON strings, use the , + /// , or + /// property to provide a JsonObject directly. /// /// - public object? Value { get; } + public string Value { get; } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs index ca711d666..d014b73a9 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs @@ -1,6 +1,5 @@ using ModelContextProtocol.Server; using System.Text.Json.Nodes; -using System.Text.Json.Serialization; namespace ModelContextProtocol.Tests.Server; @@ -192,12 +191,11 @@ public void McpMetaAttribute_DuplicateKeys_WithSeedMeta_SeedTakesPrecedence() public void McpMetaAttribute_NonStringValues_Serialized() { var method = typeof(TestToolNonStringMetaClass).GetMethod(nameof(TestToolNonStringMetaClass.ToolWithNonStringMeta))!; - var tool = McpServerTool.Create(method, target: null, options: new() { SerializerOptions = JsonContext5.Default.Options }); + var tool = McpServerTool.Create(method, target: null); Assert.NotNull(tool.ProtocolTool.Meta); Assert.Equal("42", tool.ProtocolTool.Meta["intValue"]?.ToString()); - Assert.Equal("true", tool.ProtocolTool.Meta["boolValue"]?.ToString()); - // Enum serialized as numeric by default - Assert.Equal(((int)TestEnum.Second).ToString(), tool.ProtocolTool.Meta["enumValue"]?.ToString()); + Assert.Equal("True", tool.ProtocolTool.Meta["boolValue"]?.ToString()); + Assert.Equal("1", tool.ProtocolTool.Meta["enumValue"]?.ToString()); } [Fact] @@ -237,8 +235,8 @@ public void McpMetaAttribute_DelegateOverload_PopulatesMeta() private class TestToolClass { [McpServerTool] - [McpMeta("model", "gpt-4o")] - [McpMeta("version", "1.0")] + [McpMeta("model", "\"gpt-4o\"")] + [McpMeta("version", "\"1.0\"")] public static string ToolWithMeta(string input) { return input; @@ -251,7 +249,7 @@ public static string ToolWithoutMeta(string input) } [McpServerTool] - [McpMeta("test-key", "test-value")] + [McpMeta("test-key", "\"test-value\"")] public static string ToolWithSingleMeta(string input) { return input; @@ -261,8 +259,8 @@ public static string ToolWithSingleMeta(string input) private class TestPromptClass { [McpServerPrompt] - [McpMeta("type", "reasoning")] - [McpMeta("model", "claude-3")] + [McpMeta("type", "\"reasoning\"")] + [McpMeta("model", "\"claude-3\"")] public static string PromptWithMeta(string input) { return input; @@ -272,8 +270,8 @@ public static string PromptWithMeta(string input) private class TestResourceClass { [McpServerResource(UriTemplate = "resource://test/{id}")] - [McpMeta("encoding", "text/plain")] - [McpMeta("caching", "cached")] + [McpMeta("encoding", "\"text/plain\"")] + [McpMeta("caching", "\"cached\"")] public static string ResourceWithMeta(string id) { return $"Resource content for {id}"; @@ -295,39 +293,32 @@ private class TestPromptNoMetaClass private class TestToolDuplicateMetaClass { [McpServerTool] - [McpMeta("key", "first")] - [McpMeta("key", "second")] - [McpMeta("other", "other-value")] + [McpMeta("key", "\"first\"")] + [McpMeta("key", "\"second\"")] + [McpMeta("other", "\"other-value\"")] public static string ToolWithDuplicateMeta(string input) => input; } - private enum TestEnum { First = 0, Second = 1 } - private class TestToolNonStringMetaClass { [McpServerTool] - [McpMeta("intValue", 42)] - [McpMeta("boolValue", true)] - [McpMeta("enumValue", TestEnum.Second)] + [McpMeta("intValue", "42")] + [McpMeta("boolValue", "true")] + [McpMeta("enumValue", "1")] public static string ToolWithNonStringMeta(string input) => input; } private class TestToolNullMetaClass { [McpServerTool] - [McpMeta("nullable", null)] + [McpMeta("nullable", "null")] public static string ToolWithNullMeta(string input) => input; } private class TestToolMethodMetaOnlyClass { [McpServerTool] - [McpMeta("methodKey", "method")] + [McpMeta("methodKey", "\"method\"")] public static string ToolWithMethodMeta(string input) => input; } - - [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)] - [JsonSerializable(typeof(string))] - [JsonSerializable(typeof(TestEnum))] - private partial class JsonContext5 : JsonSerializerContext; } From d2a34449333d2ff88a6d50ed973c75546c13b52d Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 15 Oct 2025 14:08:56 +0000 Subject: [PATCH 13/16] Add StringSyntax attribute to Value property and fix sample code to use escaped quotes Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- samples/AspNetCoreMcpServer/Tools/WeatherTools.cs | 4 ++-- src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs index 58d636c48..4c03301a9 100644 --- a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs +++ b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs @@ -48,8 +48,8 @@ public async Task GetAlerts( } [McpServerTool, Description("Get weather forecast for a location.")] - [McpMeta("category", "weather")] - [McpMeta("recommendedModel", "gpt-4")] + [McpMeta("category", "\"weather\"")] + [McpMeta("recommendedModel", "\"gpt-4\"")] public async Task GetForecast( [Description("Latitude of the location.")] double latitude, [Description("Longitude of the location.")] double longitude) diff --git a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs index 2d8a76e49..c85e720a0 100644 --- a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs @@ -68,5 +68,6 @@ public McpMetaAttribute(string name, [StringSyntax(StringSyntaxAttribute.Json)] /// property to provide a JsonObject directly. /// /// + [StringSyntax(StringSyntaxAttribute.Json)] public string Value { get; } } From 3c597d6390d0f351ac5954160661973d38fb6702 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 15 Oct 2025 17:34:56 -0400 Subject: [PATCH 14/16] Fix tests and a bit of cleanup --- .../Protocol/ClientCapabilities.cs | 2 +- .../Protocol/ElicitationCapability.cs | 2 +- .../Protocol/SamplingCapability.cs | 2 +- .../Server/AIFunctionMcpServerTool.cs | 12 ++++-------- .../Server/McpMetaAttribute.cs | 13 +++++++------ .../Server/McpServerPromptCreateOptions.cs | 7 ++++--- .../Server/McpServerResourceCreateOptions.cs | 7 ++++--- .../Server/McpServerToolCreateOptions.cs | 7 ++++--- .../Server/McpMetaAttributeTests.cs | 9 ++++++++- 9 files changed, 34 insertions(+), 27 deletions(-) diff --git a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs index f133e8dca..102960e36 100644 --- a/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs +++ b/src/ModelContextProtocol.Core/Protocol/ClientCapabilities.cs @@ -47,7 +47,7 @@ public sealed class ClientCapabilities /// /// /// The server can use to request the list of - /// available roots from the client, which will trigger the client's . + /// available roots from the client, which will trigger the client's . /// /// [JsonPropertyName("roots")] diff --git a/src/ModelContextProtocol.Core/Protocol/ElicitationCapability.cs b/src/ModelContextProtocol.Core/Protocol/ElicitationCapability.cs index e096e0e09..9d46bcc43 100644 --- a/src/ModelContextProtocol.Core/Protocol/ElicitationCapability.cs +++ b/src/ModelContextProtocol.Core/Protocol/ElicitationCapability.cs @@ -13,7 +13,7 @@ namespace ModelContextProtocol.Protocol; /// /// /// When this capability is enabled, an MCP server can request the client to provide additional information -/// during interactions. The client must set a to process these requests. +/// during interactions. The client must set a to process these requests. /// /// /// This class is intentionally empty as the Model Context Protocol specification does not diff --git a/src/ModelContextProtocol.Core/Protocol/SamplingCapability.cs b/src/ModelContextProtocol.Core/Protocol/SamplingCapability.cs index e917b2af9..8ddc7ecf8 100644 --- a/src/ModelContextProtocol.Core/Protocol/SamplingCapability.cs +++ b/src/ModelContextProtocol.Core/Protocol/SamplingCapability.cs @@ -14,7 +14,7 @@ namespace ModelContextProtocol.Protocol; /// /// /// When this capability is enabled, an MCP server can request the client to generate content -/// using an AI model. The client must set a to process these requests. +/// using an AI model. The client must set a to process these requests. /// /// /// This class is intentionally empty as the Model Context Protocol specification does not diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index cd2512d38..25bb54dce 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -364,16 +364,12 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) /// A with metadata, or null if no metadata is present. internal static JsonObject? CreateMetaFromAttributes(MethodInfo method, JsonObject? meta = null, JsonSerializerOptions? serializerOptions = null) { - // Get all McpMetaAttribute instances from the method. - var metaAttributes = method.GetCustomAttributes(); - - foreach (var attr in metaAttributes) + // Transfer all McpMetaAttribute instances to the Meta JsonObject, ignoring any that would overwrite existing properties. + foreach (var attr in method.GetCustomAttributes()) { - meta ??= []; - if (!meta.ContainsKey(attr.Name)) + if (meta?.ContainsKey(attr.Name) is not true) { - // Parse the JSON string value into a JsonNode - meta[attr.Name] = JsonNode.Parse(attr.Value); + (meta ??= [])[attr.Name] = JsonNode.Parse(attr.Value); } } diff --git a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs index c85e720a0..b6fc30ccc 100644 --- a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs @@ -8,11 +8,15 @@ namespace ModelContextProtocol.Server; /// /// /// -/// This attribute can be applied multiple times to a method to specify multiple key/value pairs -/// of metadata. The metadata is used to populate the , , +/// The metadata is used to populate the , , /// or property of the corresponding primitive. /// /// +/// This attribute can be applied multiple times to a method to specify multiple key/value pairs +/// of metadata. However, the same key should not be used more than once; doing so will result +/// in undefined behavior. +/// +/// /// Metadata can be used to attach additional information to primitives, such as model preferences, /// version information, or other custom data that should be communicated to MCP clients. /// @@ -22,10 +26,7 @@ namespace ModelContextProtocol.Server; /// [McpMeta("model", "\"gpt-4o\"")] /// [McpMeta("version", "\"1.0\"")] /// [McpMeta("priority", "5")] -/// public string MyTool(string input) -/// { -/// return $"Processed: {input}"; -/// } +/// public string MyTool(string input) => $"Processed: {input}"; /// /// /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs index 2c7529360..3281bef16 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPromptCreateOptions.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Protocol; using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Server; @@ -91,15 +92,15 @@ public sealed class McpServerPromptCreateOptions /// /// /// - /// This is used to seed the property. Any metadata from + /// This is used to seed the property. Any metadata from /// instances on the method will be added to this object, but - /// properties already present in this will not be overwritten. + /// properties already present in this will not be overwritten. /// /// /// Implementations must not make assumptions about its contents. /// /// - public System.Text.Json.Nodes.JsonObject? Meta { get; set; } + public JsonObject? Meta { get; set; } /// /// Creates a shallow clone of the current instance. diff --git a/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs index cf8671516..c52af48f0 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerResourceCreateOptions.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Protocol; using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Server; @@ -106,15 +107,15 @@ public sealed class McpServerResourceCreateOptions /// /// /// - /// This is used to seed the property. Any metadata from + /// This is used to seed the property. Any metadata from /// instances on the method will be added to this object, but - /// properties already present in this will not be overwritten. + /// properties already present in this will not be overwritten. /// /// /// Implementations must not make assumptions about its contents. /// /// - public System.Text.Json.Nodes.JsonObject? Meta { get; set; } + public JsonObject? Meta { get; set; } /// /// Creates a shallow clone of the current instance. diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs index 6daa015df..e4e1d9330 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolCreateOptions.cs @@ -2,6 +2,7 @@ using ModelContextProtocol.Protocol; using System.ComponentModel; using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Server; @@ -177,15 +178,15 @@ public sealed class McpServerToolCreateOptions /// /// /// - /// This is used to seed the property. Any metadata from + /// This is used to seed the property. Any metadata from /// instances on the method will be added to this object, but - /// properties already present in this will not be overwritten. + /// properties already present in this will not be overwritten. /// /// /// Implementations must not make assumptions about its contents. /// /// - public System.Text.Json.Nodes.JsonObject? Meta { get; set; } + public JsonObject? Meta { get; set; } /// /// Creates a shallow clone of the current instance. diff --git a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs index d014b73a9..424f7cb38 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs @@ -1,4 +1,5 @@ using ModelContextProtocol.Server; +using System.Text.Json; using System.Text.Json.Nodes; namespace ModelContextProtocol.Tests.Server; @@ -193,9 +194,15 @@ public void McpMetaAttribute_NonStringValues_Serialized() var method = typeof(TestToolNonStringMetaClass).GetMethod(nameof(TestToolNonStringMetaClass.ToolWithNonStringMeta))!; var tool = McpServerTool.Create(method, target: null); Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal("42", tool.ProtocolTool.Meta["intValue"]?.ToString()); - Assert.Equal("True", tool.ProtocolTool.Meta["boolValue"]?.ToString()); + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["intValue"]?.GetValueKind()); + + Assert.Equal("true", tool.ProtocolTool.Meta["boolValue"]?.ToString()); + Assert.Equal(JsonValueKind.True, tool.ProtocolTool.Meta["boolValue"]?.GetValueKind()); + Assert.Equal("1", tool.ProtocolTool.Meta["enumValue"]?.ToString()); + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["enumValue"]?.GetValueKind()); } [Fact] From 9e8de5c64b333ac9c083db4bb1d8e367ce3fe5c9 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Thu, 16 Oct 2025 21:24:16 -0400 Subject: [PATCH 15/16] Change approach and add lots of tests --- .../AspNetCoreMcpServer/Tools/WeatherTools.cs | 8 +- .../Server/AIFunctionMcpServerTool.cs | 2 +- .../Server/McpMetaAttribute.cs | 51 +- .../Server/McpMetaAttributeTests.cs | 1284 ++++++++++++++++- 4 files changed, 1291 insertions(+), 54 deletions(-) diff --git a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs index 4c03301a9..f68e40570 100644 --- a/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs +++ b/samples/AspNetCoreMcpServer/Tools/WeatherTools.cs @@ -17,8 +17,8 @@ public WeatherTools(IHttpClientFactory httpClientFactory) } [McpServerTool, Description("Get weather alerts for a US state.")] - [McpMeta("category", "\"weather\"")] - [McpMeta("dataSource", "\"weather.gov\"")] + [McpMeta("category", "weather")] + [McpMeta("dataSource", "weather.gov")] public async Task GetAlerts( [Description("The US state to get alerts for. Use the 2 letter abbreviation for the state (e.g. NY).")] string state) { @@ -48,8 +48,8 @@ public async Task GetAlerts( } [McpServerTool, Description("Get weather forecast for a location.")] - [McpMeta("category", "\"weather\"")] - [McpMeta("recommendedModel", "\"gpt-4\"")] + [McpMeta("category", "weather")] + [McpMeta("recommendedModel", "gpt-4")] public async Task GetForecast( [Description("Latitude of the location.")] double latitude, [Description("Longitude of the location.")] double longitude) diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 25bb54dce..287995cf2 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -369,7 +369,7 @@ internal static IReadOnlyList CreateMetadata(MethodInfo method) { if (meta?.ContainsKey(attr.Name) is not true) { - (meta ??= [])[attr.Name] = JsonNode.Parse(attr.Value); + (meta ??= [])[attr.Name] = JsonNode.Parse(attr.JsonValue); } } diff --git a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs index b6fc30ccc..730ecc06d 100644 --- a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs @@ -1,5 +1,7 @@ using ModelContextProtocol.Protocol; using System.Diagnostics.CodeAnalysis; +using System.Text.Json; +using System.Text.Json.Nodes; namespace ModelContextProtocol.Server; @@ -20,28 +22,52 @@ namespace ModelContextProtocol.Server; /// Metadata can be used to attach additional information to primitives, such as model preferences, /// version information, or other custom data that should be communicated to MCP clients. /// +/// /// /// /// [McpServerTool] -/// [McpMeta("model", "\"gpt-4o\"")] -/// [McpMeta("version", "\"1.0\"")] -/// [McpMeta("priority", "5")] +/// [McpMeta("model", "gpt-4o")] +/// [McpMeta("version", "1.0")] +/// [McpMeta("priority", 5.0)] +/// [McpMeta("isBeta", true)] +/// [McpMeta("tags", JsonValue = """["a","b"]""")] /// public string MyTool(string input) => $"Processed: {input}"; /// /// -/// [AttributeUsage(AttributeTargets.Method, AllowMultiple = true)] public sealed class McpMetaAttribute : Attribute { /// - /// Initializes a new instance of the class. + /// Initializes a new instance of the class with a string value. + /// + /// The name (key) of the metadata entry. + /// The string value of the metadata entry. If null, the value will be serialized as JSON null. + public McpMetaAttribute(string name, string? value = null) + { + Name = name; + JsonValue = value is null ? "null" : JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(string))); + } + + /// + /// Initializes a new instance of the class with a double value. /// /// The name (key) of the metadata entry. - /// The value of the metadata entry as a JSON string. This must be well-formed JSON. - public McpMetaAttribute(string name, [StringSyntax(StringSyntaxAttribute.Json)] string value) + /// The double value of the metadata entry. + public McpMetaAttribute(string name, double value) { Name = name; - Value = value; + JsonValue = JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(double))); + } + + /// + /// Initializes a new instance of the class with a boolean value. + /// + /// The name (key) of the metadata entry. + /// The boolean value of the metadata entry. + public McpMetaAttribute(string name, bool value) + { + Name = name; + JsonValue = value ? "true" : "false"; } /// @@ -54,15 +80,18 @@ public McpMetaAttribute(string name, [StringSyntax(StringSyntaxAttribute.Json)] public string Name { get; } /// - /// Gets the value of the metadata entry as a JSON string. + /// Gets or sets the value of the metadata entry as a JSON string. /// /// /// - /// This value must be well-formed JSON. It will be parsed and added to the metadata JsonObject. + /// This value must be well-formed JSON. It will be parsed and added to the metadata . /// Simple values can be represented as JSON literals like "\"my-string\"", "123", /// "true", etc. Complex structures can be represented as JSON objects or arrays. /// /// + /// Setting this property will override any value provided via the constructor. + /// + /// /// For programmatic scenarios where you want to construct complex metadata without dealing with /// JSON strings, use the , /// , or @@ -70,5 +99,5 @@ public McpMetaAttribute(string name, [StringSyntax(StringSyntaxAttribute.Json)] /// /// [StringSyntax(StringSyntaxAttribute.Json)] - public string Value { get; } + public string JsonValue { get; set; } } diff --git a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs index 424f7cb38..0315dbe42 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs @@ -6,6 +6,828 @@ namespace ModelContextProtocol.Tests.Server; public partial class McpMetaAttributeTests { + #region Direct Attribute Instantiation Tests + + [Fact] + public void McpMetaAttribute_StringConstructor_WithValue_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", "test-value"); + + Assert.Equal("key", attr.Name); + Assert.Equal("\"test-value\"", attr.JsonValue); + + // Verify it can be parsed back as JSON + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(JsonValueKind.String, node.GetValueKind()); + Assert.Equal("test-value", node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_StringConstructor_WithNull_SerializesAsJsonNull() + { + var attr = new McpMetaAttribute("key", (string?)null); + + Assert.Equal("key", attr.Name); + Assert.Equal("null", attr.JsonValue); + + // Verify it parses as JSON null + var node = JsonNode.Parse(attr.JsonValue); + Assert.Null(node); + } + + [Fact] + public void McpMetaAttribute_StringConstructor_WithEmptyString_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", ""); + + Assert.Equal("key", attr.Name); + Assert.Equal("\"\"", attr.JsonValue); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal("", node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_StringConstructor_WithSpecialCharacters_RoundtripsCorrectly() + { + var testString = "Line1\nLine2\tTab\"Quote"; + var attr = new McpMetaAttribute("key", testString); + + Assert.Equal("key", attr.Name); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(testString, node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_StringConstructor_WithUnicode_RoundtripsCorrectly() + { + var testString = "Hello δΈ–η•Œ 🌍"; + var attr = new McpMetaAttribute("key", testString); + + Assert.Equal("key", attr.Name); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(testString, node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_DoubleConstructor_WithPositiveValue_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", 3.14159); + + Assert.Equal("key", attr.Name); + Assert.Equal("3.14159", attr.JsonValue); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(JsonValueKind.Number, node.GetValueKind()); + Assert.Equal(3.14159, node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_DoubleConstructor_WithNegativeValue_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", -999.999); + + Assert.Equal("key", attr.Name); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(JsonValueKind.Number, node.GetValueKind()); + Assert.Equal(-999.999, node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_DoubleConstructor_WithZero_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", 0.0); + + Assert.Equal("key", attr.Name); + Assert.Equal("0", attr.JsonValue); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(JsonValueKind.Number, node.GetValueKind()); + Assert.Equal(0.0, node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_DoubleConstructor_WithIntegerValue_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", 42.0); + + Assert.Equal("key", attr.Name); + Assert.Equal("42", attr.JsonValue); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(42.0, node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_DoubleConstructor_WithMaxValue_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", double.MaxValue); + + Assert.Equal("key", attr.Name); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(double.MaxValue, node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_DoubleConstructor_WithMinValue_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", double.MinValue); + + Assert.Equal("key", attr.Name); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(double.MinValue, node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_DoubleConstructor_WithVerySmallValue_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", 0.000001); + + Assert.Equal("key", attr.Name); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + var value = node.GetValue(); + Assert.True(Math.Abs(0.000001 - value) < 0.0000001); + } + + [Fact] + public void McpMetaAttribute_BoolConstructor_WithTrue_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", true); + + Assert.Equal("key", attr.Name); + Assert.Equal("true", attr.JsonValue); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(JsonValueKind.True, node.GetValueKind()); + Assert.True(node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_BoolConstructor_WithFalse_RoundtripsCorrectly() + { + var attr = new McpMetaAttribute("key", false); + + Assert.Equal("key", attr.Name); + Assert.Equal("false", attr.JsonValue); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(JsonValueKind.False, node.GetValueKind()); + Assert.False(node.GetValue()); + } + + [Fact] + public void McpMetaAttribute_JsonValueProperty_CanBeOverridden() + { + var attr = new McpMetaAttribute("key", "original"); + + Assert.Equal("\"original\"", attr.JsonValue); + + // Override with custom JSON + attr.JsonValue = """{"custom": "value", "num": 123}"""; + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + Assert.Equal(JsonValueKind.Object, node.GetValueKind()); + + var obj = node.AsObject(); + Assert.Equal("value", obj["custom"]?.GetValue()); + Assert.Equal(123, obj["num"]?.GetValue()); + } + + [Fact] + public void McpMetaAttribute_JsonValueProperty_SupportsComplexTypes() + { + var attr = new McpMetaAttribute("key", "placeholder") + { + JsonValue = """{"nested": {"deep": "value"}, "array": [1, 2, 3]}""" + }; + + var node = JsonNode.Parse(attr.JsonValue); + Assert.NotNull(node); + + var obj = node.AsObject(); + var nested = obj["nested"]?.AsObject(); + Assert.Equal("value", nested?["deep"]?.GetValue()); + + var array = obj["array"]?.AsArray(); + Assert.Equal(3, array?.Count); + Assert.Equal(1, array?[0]?.GetValue()); + Assert.Equal(2, array?[1]?.GetValue()); + Assert.Equal(3, array?[2]?.GetValue()); + } + + [Fact] + public void McpMetaAttribute_StringConstructor_WithDefaultParameter_UsesNull() + { + var attr = new McpMetaAttribute("key"); + + Assert.Equal("key", attr.Name); + Assert.Equal("null", attr.JsonValue); + + var node = JsonNode.Parse(attr.JsonValue); + Assert.Null(node); + } + + [Fact] + public void McpMetaAttribute_Name_CanContainSpecialCharacters() + { + var attr = new McpMetaAttribute("my-key_with.special/chars", "value"); + + Assert.Equal("my-key_with.special/chars", attr.Name); + } + + [Fact] + public void McpMetaAttribute_MultipleInstances_AreIndependent() + { + var attr1 = new McpMetaAttribute("key1", "value1"); + var attr2 = new McpMetaAttribute("key2", 42.0); + var attr3 = new McpMetaAttribute("key3", true); + + Assert.Equal("key1", attr1.Name); + Assert.Equal("\"value1\"", attr1.JsonValue); + + Assert.Equal("key2", attr2.Name); + Assert.Equal("42", attr2.JsonValue); + + Assert.Equal("key3", attr3.Name); + Assert.Equal("true", attr3.JsonValue); + + // Modifying one doesn't affect others + attr1.JsonValue = "\"modified\""; + Assert.Equal("\"modified\"", attr1.JsonValue); + Assert.Equal("42", attr2.JsonValue); + Assert.Equal("true", attr3.JsonValue); + } + + [Fact] + public void McpServerTool_Create_WithStringMeta_PopulatesToolMeta() + { + var method = typeof(TestToolStringMetaClass).GetMethod(nameof(TestToolStringMetaClass.ToolWithStringMeta))!; + + var tool = McpServerTool.Create(method, target: null); + + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal(3, tool.ProtocolTool.Meta.Count); + Assert.Equal("value1", tool.ProtocolTool.Meta["key1"]?.GetValue()); + Assert.Equal("value2", tool.ProtocolTool.Meta["key2"]?.GetValue()); + Assert.Null(tool.ProtocolTool.Meta["key3"]); + } + + [Fact] + public void McpServerTool_Create_WithDoubleMeta_PopulatesToolMeta() + { + var method = typeof(TestToolDoubleMetaClass2).GetMethod(nameof(TestToolDoubleMetaClass2.ToolWithDoubleMeta))!; + + var tool = McpServerTool.Create(method, target: null); + + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal(3, tool.ProtocolTool.Meta.Count); + Assert.Equal(3.14, tool.ProtocolTool.Meta["pi"]?.GetValue()); + Assert.Equal(0.0, tool.ProtocolTool.Meta["zero"]?.GetValue()); + Assert.Equal(-1.5, tool.ProtocolTool.Meta["negative"]?.GetValue()); + } + + [Fact] + public void McpServerTool_Create_WithBoolMeta_PopulatesToolMeta() + { + var method = typeof(TestToolBoolMetaClass).GetMethod(nameof(TestToolBoolMetaClass.ToolWithBoolMeta))!; + + var tool = McpServerTool.Create(method, target: null); + + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal(2, tool.ProtocolTool.Meta.Count); + Assert.True(tool.ProtocolTool.Meta["enabled"]?.GetValue()); + Assert.False(tool.ProtocolTool.Meta["deprecated"]?.GetValue()); + } + + [Fact] + public void McpServerTool_Create_WithAllConstructorTypes_PopulatesToolMeta() + { + var method = typeof(TestToolAllTypesMetaClass).GetMethod(nameof(TestToolAllTypesMetaClass.ToolWithAllTypes))!; + + var tool = McpServerTool.Create(method, target: null); + + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal(4, tool.ProtocolTool.Meta.Count); + + Assert.Equal("test", tool.ProtocolTool.Meta["stringKey"]?.GetValue()); + Assert.Equal(JsonValueKind.String, tool.ProtocolTool.Meta["stringKey"]?.GetValueKind()); + + Assert.Equal(42.5, tool.ProtocolTool.Meta["doubleKey"]?.GetValue()); + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["doubleKey"]?.GetValueKind()); + + Assert.True(tool.ProtocolTool.Meta["boolKey"]?.GetValue()); + Assert.Equal(JsonValueKind.True, tool.ProtocolTool.Meta["boolKey"]?.GetValueKind()); + + Assert.Null(tool.ProtocolTool.Meta["nullKey"]); + } + + [Fact] + public void McpServerPrompt_Create_WithStringMeta_PopulatesPromptMeta() + { + var method = typeof(TestPromptStringMetaClass).GetMethod(nameof(TestPromptStringMetaClass.PromptWithStringMeta))!; + + var prompt = McpServerPrompt.Create(method, target: null); + + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal(2, prompt.ProtocolPrompt.Meta.Count); + Assert.Equal("system", prompt.ProtocolPrompt.Meta["role"]?.GetValue()); + Assert.Equal("instruction", prompt.ProtocolPrompt.Meta["type"]?.GetValue()); + } + + [Fact] + public void McpServerPrompt_Create_WithDoubleMeta_PopulatesPromptMeta() + { + var method = typeof(TestPromptDoubleMetaClass).GetMethod(nameof(TestPromptDoubleMetaClass.PromptWithDoubleMeta))!; + + var prompt = McpServerPrompt.Create(method, target: null); + + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal(2, prompt.ProtocolPrompt.Meta.Count); + Assert.Equal(0.7, prompt.ProtocolPrompt.Meta["temperature"]?.GetValue()); + Assert.Equal(100.0, prompt.ProtocolPrompt.Meta["maxTokens"]?.GetValue()); + } + + [Fact] + public void McpServerPrompt_Create_WithBoolMeta_PopulatesPromptMeta() + { + var method = typeof(TestPromptBoolMetaClass).GetMethod(nameof(TestPromptBoolMetaClass.PromptWithBoolMeta))!; + + var prompt = McpServerPrompt.Create(method, target: null); + + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Single(prompt.ProtocolPrompt.Meta); + Assert.True(prompt.ProtocolPrompt.Meta["stream"]?.GetValue()); + } + + [Fact] + public void McpServerPrompt_Create_WithAllConstructorTypes_PopulatesPromptMeta() + { + var method = typeof(TestPromptAllTypesMetaClass).GetMethod(nameof(TestPromptAllTypesMetaClass.PromptWithAllTypes))!; + + var prompt = McpServerPrompt.Create(method, target: null); + + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal(4, prompt.ProtocolPrompt.Meta.Count); + Assert.Equal("user", prompt.ProtocolPrompt.Meta["role"]?.GetValue()); + Assert.Equal(1.0, prompt.ProtocolPrompt.Meta["version"]?.GetValue()); + Assert.False(prompt.ProtocolPrompt.Meta["experimental"]?.GetValue()); + Assert.Null(prompt.ProtocolPrompt.Meta["deprecated"]); + } + + [Fact] + public void McpServerResource_Create_WithStringMeta_PopulatesResourceMeta() + { + var method = typeof(TestResourceStringMetaClass).GetMethod(nameof(TestResourceStringMetaClass.ResourceWithStringMeta))!; + + var resource = McpServerResource.Create(method, target: null); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal(2, resource.ProtocolResourceTemplate.Meta.Count); + Assert.Equal("text/html", resource.ProtocolResourceTemplate.Meta["contentType"]?.GetValue()); + Assert.Equal("utf-8", resource.ProtocolResourceTemplate.Meta["encoding"]?.GetValue()); + } + + [Fact] + public void McpServerResource_Create_WithDoubleMeta_PopulatesResourceMeta() + { + var method = typeof(TestResourceDoubleMetaClass).GetMethod(nameof(TestResourceDoubleMetaClass.ResourceWithDoubleMeta))!; + + var resource = McpServerResource.Create(method, target: null); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal(2, resource.ProtocolResourceTemplate.Meta.Count); + Assert.Equal(1.5, resource.ProtocolResourceTemplate.Meta["version"]?.GetValue()); + Assert.Equal(3600.0, resource.ProtocolResourceTemplate.Meta["cacheDuration"]?.GetValue()); + } + + [Fact] + public void McpServerResource_Create_WithBoolMeta_PopulatesResourceMeta() + { + var method = typeof(TestResourceBoolMetaClass).GetMethod(nameof(TestResourceBoolMetaClass.ResourceWithBoolMeta))!; + + var resource = McpServerResource.Create(method, target: null); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal(2, resource.ProtocolResourceTemplate.Meta.Count); + Assert.True(resource.ProtocolResourceTemplate.Meta["cacheable"]?.GetValue()); + Assert.False(resource.ProtocolResourceTemplate.Meta["requiresAuth"]?.GetValue()); + } + + [Fact] + public void McpServerResource_Create_WithAllConstructorTypes_PopulatesResourceMeta() + { + var method = typeof(TestResourceAllTypesMetaClass).GetMethod(nameof(TestResourceAllTypesMetaClass.ResourceWithAllTypes))!; + + var resource = McpServerResource.Create(method, target: null); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal(4, resource.ProtocolResourceTemplate.Meta.Count); + Assert.Equal("public", resource.ProtocolResourceTemplate.Meta["visibility"]?.GetValue()); + Assert.Equal(2.0, resource.ProtocolResourceTemplate.Meta["apiVersion"]?.GetValue()); + Assert.True(resource.ProtocolResourceTemplate.Meta["available"]?.GetValue()); + Assert.Null(resource.ProtocolResourceTemplate.Meta["owner"]); + } + + [Fact] + public void McpServerTool_Create_WithJsonValueMeta_PopulatesToolMetaWithComplexTypes() + { + var method = typeof(TestToolJsonValueMetaClass).GetMethod(nameof(TestToolJsonValueMetaClass.ToolWithJsonValueMeta))!; + + var tool = McpServerTool.Create(method, target: null); + + Assert.NotNull(tool.ProtocolTool.Meta); + + // Verify integer via JsonValue + Assert.Equal(42, tool.ProtocolTool.Meta["count"]?.GetValue()); + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["count"]?.GetValueKind()); + + // Verify array via JsonValue + var tags = tool.ProtocolTool.Meta["tags"]?.AsArray(); + Assert.NotNull(tags); + Assert.Equal(3, tags.Count); + Assert.Equal("tag1", tags[0]?.GetValue()); + + // Verify object via JsonValue + var config = tool.ProtocolTool.Meta["config"]?.AsObject(); + Assert.NotNull(config); + Assert.Equal("high", config["priority"]?.GetValue()); + } + + [Fact] + public void McpServerPrompt_Create_WithJsonValueMeta_PopulatesPromptMetaWithComplexTypes() + { + var method = typeof(TestPromptJsonValueMetaClass).GetMethod(nameof(TestPromptJsonValueMetaClass.PromptWithJsonValueMeta))!; + + var prompt = McpServerPrompt.Create(method, target: null); + + Assert.NotNull(prompt.ProtocolPrompt.Meta); + + var parameters = prompt.ProtocolPrompt.Meta["parameters"]?.AsObject(); + Assert.NotNull(parameters); + Assert.Equal("string", parameters["type"]?.GetValue()); + Assert.True(parameters["required"]?.GetValue()); + } + + [Fact] + public void McpServerResource_Create_WithJsonValueMeta_PopulatesResourceMetaWithComplexTypes() + { + var method = typeof(TestResourceJsonValueMetaClass).GetMethod(nameof(TestResourceJsonValueMetaClass.ResourceWithJsonValueMeta))!; + + var resource = McpServerResource.Create(method, target: null); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + + var metadata = resource.ProtocolResourceTemplate.Meta["metadata"]?.AsObject(); + Assert.NotNull(metadata); + Assert.Equal("document", metadata["type"]?.GetValue()); + + var permissions = resource.ProtocolResourceTemplate.Meta["permissions"]?.AsArray(); + Assert.NotNull(permissions); + Assert.Equal(2, permissions.Count); + } + + #endregion + + #region Options Meta Interaction Tests + + [Fact] + public void McpServerTool_Create_WithNullOptionsMeta_UsesAttributesOnly() + { + var method = typeof(TestToolStringMetaClass).GetMethod(nameof(TestToolStringMetaClass.ToolWithStringMeta))!; + var options = new McpServerToolCreateOptions { Meta = null }; + + var tool = McpServerTool.Create(method, target: null, options: options); + + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal(3, tool.ProtocolTool.Meta.Count); + Assert.Equal("value1", tool.ProtocolTool.Meta["key1"]?.GetValue()); + Assert.Equal("value2", tool.ProtocolTool.Meta["key2"]?.GetValue()); + Assert.Null(tool.ProtocolTool.Meta["key3"]); + } + + [Fact] + public void McpServerTool_Create_WithEmptyOptionsMeta_UsesAttributesOnly() + { + var method = typeof(TestToolStringMetaClass).GetMethod(nameof(TestToolStringMetaClass.ToolWithStringMeta))!; + var options = new McpServerToolCreateOptions { Meta = new JsonObject() }; + + var tool = McpServerTool.Create(method, target: null, options: options); + + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal(3, tool.ProtocolTool.Meta.Count); + Assert.Equal("value1", tool.ProtocolTool.Meta["key1"]?.GetValue()); + Assert.Equal("value2", tool.ProtocolTool.Meta["key2"]?.GetValue()); + Assert.Null(tool.ProtocolTool.Meta["key3"]); + } + + [Fact] + public void McpServerTool_Create_WithNonConflictingOptionsMeta_MergesBoth() + { + var method = typeof(TestToolStringMetaClass).GetMethod(nameof(TestToolStringMetaClass.ToolWithStringMeta))!; + var options = new McpServerToolCreateOptions + { + Meta = new JsonObject + { + ["newKey1"] = "newValue1", + ["newKey2"] = 42 + } + }; + + var tool = McpServerTool.Create(method, target: null, options: options); + + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal(5, tool.ProtocolTool.Meta.Count); + + // From attributes + Assert.Equal("value1", tool.ProtocolTool.Meta["key1"]?.GetValue()); + Assert.Equal("value2", tool.ProtocolTool.Meta["key2"]?.GetValue()); + Assert.Null(tool.ProtocolTool.Meta["key3"]); + + // From options + Assert.Equal("newValue1", tool.ProtocolTool.Meta["newKey1"]?.GetValue()); + Assert.Equal(42, tool.ProtocolTool.Meta["newKey2"]?.GetValue()); + } + + [Fact] + public void McpServerTool_Create_WithConflictingOptionsMeta_OptionsWin() + { + var method = typeof(TestToolStringMetaClass).GetMethod(nameof(TestToolStringMetaClass.ToolWithStringMeta))!; + var options = new McpServerToolCreateOptions + { + Meta = new JsonObject + { + ["key1"] = "overridden", // Conflicts with attribute + ["newKey"] = "added" // Non-conflicting + } + }; + + var tool = McpServerTool.Create(method, target: null, options: options); + + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal(4, tool.ProtocolTool.Meta.Count); + + // Options take precedence + Assert.Equal("overridden", tool.ProtocolTool.Meta["key1"]?.GetValue()); + + // Attributes that don't conflict + Assert.Equal("value2", tool.ProtocolTool.Meta["key2"]?.GetValue()); + Assert.Null(tool.ProtocolTool.Meta["key3"]); + + // From options only + Assert.Equal("added", tool.ProtocolTool.Meta["newKey"]?.GetValue()); + } + + [Fact] + public void McpServerPrompt_Create_WithNullOptionsMeta_UsesAttributesOnly() + { + var method = typeof(TestPromptStringMetaClass).GetMethod(nameof(TestPromptStringMetaClass.PromptWithStringMeta))!; + var options = new McpServerPromptCreateOptions { Meta = null }; + + var prompt = McpServerPrompt.Create(method, target: null, options: options); + + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal(2, prompt.ProtocolPrompt.Meta.Count); + Assert.Equal("system", prompt.ProtocolPrompt.Meta["role"]?.GetValue()); + Assert.Equal("instruction", prompt.ProtocolPrompt.Meta["type"]?.GetValue()); + } + + [Fact] + public void McpServerPrompt_Create_WithEmptyOptionsMeta_UsesAttributesOnly() + { + var method = typeof(TestPromptStringMetaClass).GetMethod(nameof(TestPromptStringMetaClass.PromptWithStringMeta))!; + var options = new McpServerPromptCreateOptions { Meta = new JsonObject() }; + + var prompt = McpServerPrompt.Create(method, target: null, options: options); + + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal(2, prompt.ProtocolPrompt.Meta.Count); + Assert.Equal("system", prompt.ProtocolPrompt.Meta["role"]?.GetValue()); + Assert.Equal("instruction", prompt.ProtocolPrompt.Meta["type"]?.GetValue()); + } + + [Fact] + public void McpServerPrompt_Create_WithNonConflictingOptionsMeta_MergesBoth() + { + var method = typeof(TestPromptStringMetaClass).GetMethod(nameof(TestPromptStringMetaClass.PromptWithStringMeta))!; + var options = new McpServerPromptCreateOptions + { + Meta = new JsonObject + { + ["temperature"] = 0.7, + ["maxTokens"] = 100.0 + } + }; + + var prompt = McpServerPrompt.Create(method, target: null, options: options); + + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal(4, prompt.ProtocolPrompt.Meta.Count); + + // From attributes + Assert.Equal("system", prompt.ProtocolPrompt.Meta["role"]?.GetValue()); + Assert.Equal("instruction", prompt.ProtocolPrompt.Meta["type"]?.GetValue()); + + // From options + Assert.Equal(0.7, prompt.ProtocolPrompt.Meta["temperature"]?.GetValue()); + Assert.Equal(100.0, prompt.ProtocolPrompt.Meta["maxTokens"]?.GetValue()); + } + + [Fact] + public void McpServerPrompt_Create_WithConflictingOptionsMeta_OptionsWin() + { + var method = typeof(TestPromptStringMetaClass).GetMethod(nameof(TestPromptStringMetaClass.PromptWithStringMeta))!; + var options = new McpServerPromptCreateOptions + { + Meta = new JsonObject + { + ["role"] = "user", // Conflicts with attribute + ["priority"] = 5 // Non-conflicting + } + }; + + var prompt = McpServerPrompt.Create(method, target: null, options: options); + + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal(3, prompt.ProtocolPrompt.Meta.Count); + + // Options take precedence + Assert.Equal("user", prompt.ProtocolPrompt.Meta["role"]?.GetValue()); + + // Attributes that don't conflict + Assert.Equal("instruction", prompt.ProtocolPrompt.Meta["type"]?.GetValue()); + + // From options only + Assert.Equal(5, prompt.ProtocolPrompt.Meta["priority"]?.GetValue()); + } + + [Fact] + public void McpServerResource_Create_WithNullOptionsMeta_UsesAttributesOnly() + { + var method = typeof(TestResourceStringMetaClass).GetMethod(nameof(TestResourceStringMetaClass.ResourceWithStringMeta))!; + var options = new McpServerResourceCreateOptions { Meta = null }; + + var resource = McpServerResource.Create(method, target: null, options: options); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal(2, resource.ProtocolResourceTemplate.Meta.Count); + Assert.Equal("text/html", resource.ProtocolResourceTemplate.Meta["contentType"]?.GetValue()); + Assert.Equal("utf-8", resource.ProtocolResourceTemplate.Meta["encoding"]?.GetValue()); + } + + [Fact] + public void McpServerResource_Create_WithEmptyOptionsMeta_UsesAttributesOnly() + { + var method = typeof(TestResourceStringMetaClass).GetMethod(nameof(TestResourceStringMetaClass.ResourceWithStringMeta))!; + var options = new McpServerResourceCreateOptions { Meta = new JsonObject() }; + + var resource = McpServerResource.Create(method, target: null, options: options); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal(2, resource.ProtocolResourceTemplate.Meta.Count); + Assert.Equal("text/html", resource.ProtocolResourceTemplate.Meta["contentType"]?.GetValue()); + Assert.Equal("utf-8", resource.ProtocolResourceTemplate.Meta["encoding"]?.GetValue()); + } + + [Fact] + public void McpServerResource_Create_WithNonConflictingOptionsMeta_MergesBoth() + { + var method = typeof(TestResourceStringMetaClass).GetMethod(nameof(TestResourceStringMetaClass.ResourceWithStringMeta))!; + var options = new McpServerResourceCreateOptions + { + Meta = new JsonObject + { + ["cacheable"] = true, + ["version"] = 2.0 + } + }; + + var resource = McpServerResource.Create(method, target: null, options: options); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal(4, resource.ProtocolResourceTemplate.Meta.Count); + + // From attributes + Assert.Equal("text/html", resource.ProtocolResourceTemplate.Meta["contentType"]?.GetValue()); + Assert.Equal("utf-8", resource.ProtocolResourceTemplate.Meta["encoding"]?.GetValue()); + + // From options + Assert.True(resource.ProtocolResourceTemplate.Meta["cacheable"]?.GetValue()); + Assert.Equal(2.0, resource.ProtocolResourceTemplate.Meta["version"]?.GetValue()); + } + + [Fact] + public void McpServerResource_Create_WithConflictingOptionsMeta_OptionsWin() + { + var method = typeof(TestResourceStringMetaClass).GetMethod(nameof(TestResourceStringMetaClass.ResourceWithStringMeta))!; + var options = new McpServerResourceCreateOptions + { + Meta = new JsonObject + { + ["encoding"] = "iso-8859-1", // Conflicts with attribute + ["size"] = 1024 // Non-conflicting + } + }; + + var resource = McpServerResource.Create(method, target: null, options: options); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal(3, resource.ProtocolResourceTemplate.Meta.Count); + + // Options take precedence + Assert.Equal("iso-8859-1", resource.ProtocolResourceTemplate.Meta["encoding"]?.GetValue()); + + // Attributes that don't conflict + Assert.Equal("text/html", resource.ProtocolResourceTemplate.Meta["contentType"]?.GetValue()); + + // From options only + Assert.Equal(1024, resource.ProtocolResourceTemplate.Meta["size"]?.GetValue()); + } + + [Fact] + public void McpServerTool_Create_WithOptionsMetaOnly_NoAttributes_PopulatesMeta() + { + var method = typeof(TestToolClass).GetMethod(nameof(TestToolClass.ToolWithoutMeta))!; + var options = new McpServerToolCreateOptions + { + Meta = new JsonObject + { + ["optionsKey"] = "optionsValue", + ["count"] = 10 + } + }; + + var tool = McpServerTool.Create(method, target: null, options: options); + + Assert.NotNull(tool.ProtocolTool.Meta); + Assert.Equal(2, tool.ProtocolTool.Meta.Count); + Assert.Equal("optionsValue", tool.ProtocolTool.Meta["optionsKey"]?.GetValue()); + Assert.Equal(10, tool.ProtocolTool.Meta["count"]?.GetValue()); + } + + [Fact] + public void McpServerPrompt_Create_WithOptionsMetaOnly_NoAttributes_PopulatesMeta() + { + var method = typeof(TestPromptNoMetaClass).GetMethod(nameof(TestPromptNoMetaClass.PromptWithoutMeta))!; + var options = new McpServerPromptCreateOptions + { + Meta = new JsonObject + { + ["model"] = "gpt-4", + ["temperature"] = 0.5 + } + }; + + var prompt = McpServerPrompt.Create(method, target: null, options: options); + + Assert.NotNull(prompt.ProtocolPrompt.Meta); + Assert.Equal(2, prompt.ProtocolPrompt.Meta.Count); + Assert.Equal("gpt-4", prompt.ProtocolPrompt.Meta["model"]?.GetValue()); + Assert.Equal(0.5, prompt.ProtocolPrompt.Meta["temperature"]?.GetValue()); + } + + [Fact] + public void McpServerResource_Create_WithOptionsMetaOnly_NoAttributes_PopulatesMeta() + { + var method = typeof(TestResourceNoMetaClass).GetMethod(nameof(TestResourceNoMetaClass.ResourceWithoutMeta))!; + var options = new McpServerResourceCreateOptions + { + Meta = new JsonObject + { + ["format"] = "json", + ["compressed"] = false + } + }; + + var resource = McpServerResource.Create(method, target: null, options: options); + + Assert.NotNull(resource.ProtocolResourceTemplate?.Meta); + Assert.Equal(2, resource.ProtocolResourceTemplate.Meta.Count); + Assert.Equal("json", resource.ProtocolResourceTemplate.Meta["format"]?.GetValue()); + Assert.False(resource.ProtocolResourceTemplate.Meta["compressed"]?.GetValue()); + } + + #endregion + [Fact] public void McpMetaAttribute_OnTool_PopulatesMeta() { @@ -188,23 +1010,6 @@ public void McpMetaAttribute_DuplicateKeys_WithSeedMeta_SeedTakesPrecedence() Assert.Equal(2, tool.ProtocolTool.Meta!.Count); } - [Fact] - public void McpMetaAttribute_NonStringValues_Serialized() - { - var method = typeof(TestToolNonStringMetaClass).GetMethod(nameof(TestToolNonStringMetaClass.ToolWithNonStringMeta))!; - var tool = McpServerTool.Create(method, target: null); - Assert.NotNull(tool.ProtocolTool.Meta); - - Assert.Equal("42", tool.ProtocolTool.Meta["intValue"]?.ToString()); - Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["intValue"]?.GetValueKind()); - - Assert.Equal("true", tool.ProtocolTool.Meta["boolValue"]?.ToString()); - Assert.Equal(JsonValueKind.True, tool.ProtocolTool.Meta["boolValue"]?.GetValueKind()); - - Assert.Equal("1", tool.ProtocolTool.Meta["enumValue"]?.ToString()); - Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["enumValue"]?.GetValueKind()); - } - [Fact] public void McpMetaAttribute_NullValue_SerializedAsNull() { @@ -239,11 +1044,214 @@ public void McpMetaAttribute_DelegateOverload_PopulatesMeta() Assert.Equal("1.0", tool.ProtocolTool.Meta["version"]?.ToString()); } + [Fact] + public void McpMetaAttribute_ComplexObject_SerializedAsJson() + { + var method = typeof(TestToolComplexMetaClass).GetMethod(nameof(TestToolComplexMetaClass.ToolWithComplexMeta))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + + var configNode = tool.ProtocolTool.Meta["config"]; + Assert.NotNull(configNode); + Assert.Equal(JsonValueKind.Object, configNode.GetValueKind()); + + var configObj = configNode.AsObject(); + Assert.Equal("high", configObj["relevance"]?.ToString()); + Assert.Equal("noble", configObj["purpose"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_Array_SerializedAsJson() + { + var method = typeof(TestToolArrayMetaClass).GetMethod(nameof(TestToolArrayMetaClass.ToolWithArrayMeta))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + + var tagsNode = tool.ProtocolTool.Meta["tags"]; + Assert.NotNull(tagsNode); + Assert.Equal(JsonValueKind.Array, tagsNode.GetValueKind()); + + var tagsArray = tagsNode.AsArray(); + Assert.Equal(3, tagsArray.Count); + Assert.Equal("tag1", tagsArray[0]?.ToString()); + Assert.Equal("tag2", tagsArray[1]?.ToString()); + Assert.Equal("tag3", tagsArray[2]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_JsonValueOverride_UsesProvidedJson() + { + var method = typeof(TestToolJsonValueOverrideClass).GetMethod(nameof(TestToolJsonValueOverrideClass.ToolWithJsonValueOverride))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + + var configNode = tool.ProtocolTool.Meta["config"]; + Assert.NotNull(configNode); + Assert.Equal(JsonValueKind.Object, configNode.GetValueKind()); + + var configObj = configNode.AsObject(); + Assert.Equal("custom", configObj["type"]?.ToString()); + Assert.Equal("123", configObj["value"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_MixedTypes_AllSerializedCorrectly() + { + var method = typeof(TestToolMixedTypesClass).GetMethod(nameof(TestToolMixedTypesClass.ToolWithMixedTypes))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + + Assert.Equal("text", tool.ProtocolTool.Meta["stringValue"]?.ToString()); + Assert.Equal(JsonValueKind.String, tool.ProtocolTool.Meta["stringValue"]?.GetValueKind()); + + Assert.Equal("42", tool.ProtocolTool.Meta["numberValue"]?.ToString()); + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["numberValue"]?.GetValueKind()); + + Assert.Equal("true", tool.ProtocolTool.Meta["boolValue"]?.ToString()); + Assert.Equal(JsonValueKind.True, tool.ProtocolTool.Meta["boolValue"]?.GetValueKind()); + + Assert.Null(tool.ProtocolTool.Meta["nullValue"]); + + var objNode = tool.ProtocolTool.Meta["objectValue"]; + Assert.NotNull(objNode); + Assert.Equal(JsonValueKind.Object, objNode.GetValueKind()); + } + + [Fact] + public void McpMetaAttribute_DoubleValue_SerializedAsNumber() + { + var method = typeof(TestToolDoubleMetaClass).GetMethod(nameof(TestToolDoubleMetaClass.ToolWithDoubleMeta))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + + var piNode = tool.ProtocolTool.Meta["pi"]; + Assert.NotNull(piNode); + Assert.Equal(JsonValueKind.Number, piNode.GetValueKind()); + var piValue = piNode.GetValue(); + Assert.True(Math.Abs(3.14159 - piValue) < 0.00001); + } + + [Fact] + public void McpMetaAttribute_SupportedTypes_SerializedCorrectly() + { + var method = typeof(TestToolSupportedTypesClass).GetMethod(nameof(TestToolSupportedTypesClass.ToolWithSupportedTypes))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + + // string + Assert.Equal("hello world", tool.ProtocolTool.Meta["stringValue"]?.ToString()); + Assert.Equal(JsonValueKind.String, tool.ProtocolTool.Meta["stringValue"]?.GetValueKind()); + + // double (positive) + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["doubleValue"]?.GetValueKind()); + var doubleValue = tool.ProtocolTool.Meta["doubleValue"]?.GetValue(); + Assert.NotNull(doubleValue); + Assert.True(Math.Abs(2.71828 - doubleValue.Value) < 0.00001); + + // double (negative) + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["negativeDouble"]?.GetValueKind()); + Assert.Equal(-1.5, tool.ProtocolTool.Meta["negativeDouble"]?.GetValue()); + + // double (zero) + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["zeroDouble"]?.GetValueKind()); + Assert.Equal(0.0, tool.ProtocolTool.Meta["zeroDouble"]?.GetValue()); + + // double (integer value) + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["intAsDouble"]?.GetValueKind()); + Assert.Equal(42.0, tool.ProtocolTool.Meta["intAsDouble"]?.GetValue()); + + // bool true + Assert.Equal("true", tool.ProtocolTool.Meta["boolTrueValue"]?.ToString()); + Assert.Equal(JsonValueKind.True, tool.ProtocolTool.Meta["boolTrueValue"]?.GetValueKind()); + + // bool false + Assert.Equal("false", tool.ProtocolTool.Meta["boolFalseValue"]?.ToString()); + Assert.Equal(JsonValueKind.False, tool.ProtocolTool.Meta["boolFalseValue"]?.GetValueKind()); + } + + [Fact] + public void McpMetaAttribute_StringEdgeCases_SerializedCorrectly() + { + var method = typeof(TestToolStringEdgeCasesClass).GetMethod(nameof(TestToolStringEdgeCasesClass.ToolWithStringEdgeCases))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + + // Empty string + Assert.Equal("", tool.ProtocolTool.Meta["emptyString"]?.ToString()); + Assert.Equal(JsonValueKind.String, tool.ProtocolTool.Meta["emptyString"]?.GetValueKind()); + + // String with special characters + Assert.Equal("Line1\nLine2\tTabbed", tool.ProtocolTool.Meta["specialChars"]?.ToString()); + + // String with quotes + Assert.Equal("He said \"Hello\"", tool.ProtocolTool.Meta["withQuotes"]?.ToString()); + + // Unicode string + Assert.Equal("Hello δΈ–η•Œ 🌍", tool.ProtocolTool.Meta["unicode"]?.ToString()); + } + + [Fact] + public void McpMetaAttribute_DoubleEdgeCases_SerializedCorrectly() + { + var method = typeof(TestToolDoubleEdgeCasesClass).GetMethod(nameof(TestToolDoubleEdgeCasesClass.ToolWithDoubleEdgeCases))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + + // Zero + Assert.Equal("0", tool.ProtocolTool.Meta["zero"]?.ToString()); + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["zero"]?.GetValueKind()); + + // Negative value + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["negative"]?.GetValueKind()); + Assert.Equal(-999.999, tool.ProtocolTool.Meta["negative"]?.GetValue()); + + // Large positive value + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["largePositive"]?.GetValueKind()); + Assert.Equal(1.7976931348623157E+308, tool.ProtocolTool.Meta["largePositive"]?.GetValue()); + + // Small positive value + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["smallPositive"]?.GetValueKind()); + var smallValue = tool.ProtocolTool.Meta["smallPositive"]?.GetValue(); + Assert.NotNull(smallValue); + Assert.True(Math.Abs(0.000001 - smallValue.Value) < 0.0000001); + } + + [Fact] + public void McpMetaAttribute_JsonValueForComplexTypes_SerializedCorrectly() + { + var method = typeof(TestToolJsonValueComplexClass).GetMethod(nameof(TestToolJsonValueComplexClass.ToolWithComplexTypes))!; + var tool = McpServerTool.Create(method, target: null); + Assert.NotNull(tool.ProtocolTool.Meta); + + // Integer via JsonValue + Assert.Equal("42", tool.ProtocolTool.Meta["intValue"]?.ToString()); + Assert.Equal(JsonValueKind.Number, tool.ProtocolTool.Meta["intValue"]?.GetValueKind()); + Assert.Equal(42, tool.ProtocolTool.Meta["intValue"]?.GetValue()); + + // Array via JsonValue + var arrayNode = tool.ProtocolTool.Meta["arrayValue"]; + Assert.NotNull(arrayNode); + Assert.Equal(JsonValueKind.Array, arrayNode.GetValueKind()); + var array = arrayNode.AsArray(); + Assert.Equal(3, array.Count); + Assert.Equal("a", array[0]?.ToString()); + Assert.Equal("b", array[1]?.ToString()); + Assert.Equal("c", array[2]?.ToString()); + + // Object via JsonValue + var objNode = tool.ProtocolTool.Meta["objectValue"]; + Assert.NotNull(objNode); + Assert.Equal(JsonValueKind.Object, objNode.GetValueKind()); + var obj = objNode.AsObject(); + Assert.Equal("value", obj["key"]?.ToString()); + Assert.Equal("123", obj["num"]?.ToString()); + } + private class TestToolClass { [McpServerTool] - [McpMeta("model", "\"gpt-4o\"")] - [McpMeta("version", "\"1.0\"")] + [McpMeta("model", "gpt-4o")] + [McpMeta("version", "1.0")] public static string ToolWithMeta(string input) { return input; @@ -256,7 +1264,7 @@ public static string ToolWithoutMeta(string input) } [McpServerTool] - [McpMeta("test-key", "\"test-value\"")] + [McpMeta("test-key", "test-value")] public static string ToolWithSingleMeta(string input) { return input; @@ -266,8 +1274,8 @@ public static string ToolWithSingleMeta(string input) private class TestPromptClass { [McpServerPrompt] - [McpMeta("type", "\"reasoning\"")] - [McpMeta("model", "\"claude-3\"")] + [McpMeta("type", "reasoning")] + [McpMeta("model", "claude-3")] public static string PromptWithMeta(string input) { return input; @@ -277,8 +1285,8 @@ public static string PromptWithMeta(string input) private class TestResourceClass { [McpServerResource(UriTemplate = "resource://test/{id}")] - [McpMeta("encoding", "\"text/plain\"")] - [McpMeta("caching", "\"cached\"")] + [McpMeta("encoding", "text/plain")] + [McpMeta("caching", "cached")] public static string ResourceWithMeta(string id) { return $"Resource content for {id}"; @@ -300,32 +1308,232 @@ private class TestPromptNoMetaClass private class TestToolDuplicateMetaClass { [McpServerTool] - [McpMeta("key", "\"first\"")] - [McpMeta("key", "\"second\"")] - [McpMeta("other", "\"other-value\"")] + [McpMeta("key", "first")] + [McpMeta("key", "second")] + [McpMeta("other", "other-value")] public static string ToolWithDuplicateMeta(string input) => input; } - private class TestToolNonStringMetaClass - { - [McpServerTool] - [McpMeta("intValue", "42")] - [McpMeta("boolValue", "true")] - [McpMeta("enumValue", "1")] - public static string ToolWithNonStringMeta(string input) => input; - } - private class TestToolNullMetaClass { [McpServerTool] - [McpMeta("nullable", "null")] + [McpMeta("nullable", null)] public static string ToolWithNullMeta(string input) => input; } private class TestToolMethodMetaOnlyClass { [McpServerTool] - [McpMeta("methodKey", "\"method\"")] + [McpMeta("methodKey", "method")] public static string ToolWithMethodMeta(string input) => input; } + + private class TestToolComplexMetaClass + { + [McpServerTool] + [McpMeta("config", JsonValue = """{"relevance": "high", "purpose": "noble"}""")] + public static string ToolWithComplexMeta(string input) => input; + } + + private class TestToolArrayMetaClass + { + [McpServerTool] + [McpMeta("tags", JsonValue = """["tag1", "tag2", "tag3"]""")] + public static string ToolWithArrayMeta(string input) => input; + } + + private class TestToolJsonValueOverrideClass + { + [McpServerTool] + [McpMeta("config", JsonValue = """{"type": "custom", "value": 123}""")] + public static string ToolWithJsonValueOverride(string input) => input; + } + + private class TestToolMixedTypesClass + { + [McpServerTool] + [McpMeta("stringValue", "text")] + [McpMeta("numberValue", 42.0)] + [McpMeta("boolValue", true)] + [McpMeta("nullValue", null)] + [McpMeta("objectValue", JsonValue = """{"key": "value"}""")] + public static string ToolWithMixedTypes(string input) => input; + } + + private class TestToolDoubleMetaClass + { + [McpServerTool] + [McpMeta("pi", 3.14159)] + public static string ToolWithDoubleMeta(string input) => input; + } + + private class TestToolSupportedTypesClass + { + [McpServerTool] + [McpMeta("stringValue", "hello world")] + [McpMeta("doubleValue", 2.71828)] + [McpMeta("negativeDouble", -1.5)] + [McpMeta("zeroDouble", 0.0)] + [McpMeta("intAsDouble", 42.0)] + [McpMeta("boolTrueValue", true)] + [McpMeta("boolFalseValue", false)] + public static string ToolWithSupportedTypes(string input) => input; + } + + private class TestToolStringEdgeCasesClass + { + [McpServerTool] + [McpMeta("emptyString", "")] + [McpMeta("specialChars", "Line1\nLine2\tTabbed")] + [McpMeta("withQuotes", "He said \"Hello\"")] + [McpMeta("unicode", "Hello δΈ–η•Œ 🌍")] + public static string ToolWithStringEdgeCases(string input) => input; + } + + private class TestToolDoubleEdgeCasesClass + { + [McpServerTool] + [McpMeta("zero", 0.0)] + [McpMeta("negative", -999.999)] + [McpMeta("largePositive", double.MaxValue)] + [McpMeta("smallPositive", 0.000001)] + public static string ToolWithDoubleEdgeCases(string input) => input; + } + + private class TestToolJsonValueComplexClass + { + [McpServerTool] + [McpMeta("intValue", JsonValue = "42")] + [McpMeta("arrayValue", JsonValue = """["a", "b", "c"]""")] + [McpMeta("objectValue", JsonValue = """{"key": "value", "num": 123}""")] + public static string ToolWithComplexTypes(string input) => input; + } + + // Test classes for Meta Property Population Tests + private class TestToolStringMetaClass + { + [McpServerTool] + [McpMeta("key1", "value1")] + [McpMeta("key2", "value2")] + [McpMeta("key3", null)] + public static string ToolWithStringMeta(string input) => input; + } + + private class TestToolDoubleMetaClass2 + { + [McpServerTool] + [McpMeta("pi", 3.14)] + [McpMeta("zero", 0.0)] + [McpMeta("negative", -1.5)] + public static string ToolWithDoubleMeta(string input) => input; + } + + private class TestToolBoolMetaClass + { + [McpServerTool] + [McpMeta("enabled", true)] + [McpMeta("deprecated", false)] + public static string ToolWithBoolMeta(string input) => input; + } + + private class TestToolAllTypesMetaClass + { + [McpServerTool] + [McpMeta("stringKey", "test")] + [McpMeta("doubleKey", 42.5)] + [McpMeta("boolKey", true)] + [McpMeta("nullKey", null)] + public static string ToolWithAllTypes(string input) => input; + } + + private class TestPromptStringMetaClass + { + [McpServerPrompt] + [McpMeta("role", "system")] + [McpMeta("type", "instruction")] + public static string PromptWithStringMeta(string input) => input; + } + + private class TestPromptDoubleMetaClass + { + [McpServerPrompt] + [McpMeta("temperature", 0.7)] + [McpMeta("maxTokens", 100.0)] + public static string PromptWithDoubleMeta(string input) => input; + } + + private class TestPromptBoolMetaClass + { + [McpServerPrompt] + [McpMeta("stream", true)] + public static string PromptWithBoolMeta(string input) => input; + } + + private class TestPromptAllTypesMetaClass + { + [McpServerPrompt] + [McpMeta("role", "user")] + [McpMeta("version", 1.0)] + [McpMeta("experimental", false)] + [McpMeta("deprecated", null)] + public static string PromptWithAllTypes(string input) => input; + } + + private class TestResourceStringMetaClass + { + [McpServerResource(UriTemplate = "resource://test/{id}")] + [McpMeta("contentType", "text/html")] + [McpMeta("encoding", "utf-8")] + public static string ResourceWithStringMeta(string id) => id; + } + + private class TestResourceDoubleMetaClass + { + [McpServerResource(UriTemplate = "resource://test/{id}")] + [McpMeta("version", 1.5)] + [McpMeta("cacheDuration", 3600.0)] + public static string ResourceWithDoubleMeta(string id) => id; + } + + private class TestResourceBoolMetaClass + { + [McpServerResource(UriTemplate = "resource://test/{id}")] + [McpMeta("cacheable", true)] + [McpMeta("requiresAuth", false)] + public static string ResourceWithBoolMeta(string id) => id; + } + + private class TestResourceAllTypesMetaClass + { + [McpServerResource(UriTemplate = "resource://test/{id}")] + [McpMeta("visibility", "public")] + [McpMeta("apiVersion", 2.0)] + [McpMeta("available", true)] + [McpMeta("owner", null)] + public static string ResourceWithAllTypes(string id) => id; + } + + private class TestToolJsonValueMetaClass + { + [McpServerTool] + [McpMeta("count", JsonValue = "42")] + [McpMeta("tags", JsonValue = """["tag1", "tag2", "tag3"]""")] + [McpMeta("config", JsonValue = """{"priority": "high"}""")] + public static string ToolWithJsonValueMeta(string input) => input; + } + + private class TestPromptJsonValueMetaClass + { + [McpServerPrompt] + [McpMeta("parameters", JsonValue = """{"type": "string", "required": true}""")] + public static string PromptWithJsonValueMeta(string input) => input; + } + + private class TestResourceJsonValueMetaClass + { + [McpServerResource(UriTemplate = "resource://test/{id}")] + [McpMeta("metadata", JsonValue = """{"type": "document"}""")] + [McpMeta("permissions", JsonValue = """["read", "write"]""")] + public static string ResourceWithJsonValueMeta(string id) => id; + } } From 4ce9664d7ac5230e8a666c3396e01e2e9f9b8df4 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 17 Oct 2025 12:49:10 +0000 Subject: [PATCH 16/16] Use JsonContext for serialization and fix floating point test comparison Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --- src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs | 4 ++-- .../Server/McpMetaAttributeTests.cs | 1 - 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs index 730ecc06d..ad631ae1a 100644 --- a/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpMetaAttribute.cs @@ -45,7 +45,7 @@ public sealed class McpMetaAttribute : Attribute public McpMetaAttribute(string name, string? value = null) { Name = name; - JsonValue = value is null ? "null" : JsonSerializer.Serialize(value, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(string))); + JsonValue = value is null ? "null" : JsonSerializer.Serialize(value, McpJsonUtilities.JsonContext.Default.String); } /// @@ -67,7 +67,7 @@ public McpMetaAttribute(string name, double value) public McpMetaAttribute(string name, bool value) { Name = name; - JsonValue = value ? "true" : "false"; + JsonValue = JsonSerializer.Serialize(value, McpJsonUtilities.JsonContext.Default.Boolean); } /// diff --git a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs index 0315dbe42..b23a89287 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpMetaAttributeTests.cs @@ -81,7 +81,6 @@ public void McpMetaAttribute_DoubleConstructor_WithPositiveValue_RoundtripsCorre var attr = new McpMetaAttribute("key", 3.14159); Assert.Equal("key", attr.Name); - Assert.Equal("3.14159", attr.JsonValue); var node = JsonNode.Parse(attr.JsonValue); Assert.NotNull(node);