diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs index 700d9d26d..d72cf05f7 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs @@ -1,9 +1,10 @@ -using Microsoft.Extensions.AI; +using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol; using System.ComponentModel; using System.Diagnostics; using System.Reflection; +using System.Resources; using System.Text.Json; using System.Text.Json.Nodes; using System.Text.RegularExpressions; @@ -159,7 +160,7 @@ options.OpenWorld is not null || // Auto-detect async methods and mark with taskSupport = "optional" unless explicitly configured. // This enables implicit task support for async tools: clients can choose to invoke them // synchronously (wait for completion) or as a task (receive taskId, poll for result). - if (function.UnderlyingMethod is not null && + if (function.UnderlyingMethod is not null && IsAsyncMethod(function.UnderlyingMethod) && tool.Execution?.TaskSupport is null) { @@ -218,6 +219,18 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe newOptions.Execution ??= new ToolExecution(); newOptions.Execution.TaskSupport ??= taskSupport; } + + // External description source (issue #1516): when DescriptionResourceType + DescriptionResourceName + // are set on the attribute, resolve the description by reflecting against the resource type. + // This takes precedence over [Description] so that callers can intentionally override compiled values. + if (toolAttr.DescriptionResourceType is Type descriptionResourceType && + !string.IsNullOrEmpty(toolAttr.DescriptionResourceName)) + { + if (TryResolveExternalDescription(descriptionResourceType, toolAttr.DescriptionResourceName!, out string? externalDescription)) + { + newOptions.Description ??= externalDescription; + } + } } if (method.GetCustomAttribute() is { } descAttr) @@ -231,6 +244,61 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe return newOptions; } + /// + /// Resolves an external description value from a "resource"-like type, modeled after + /// System.ComponentModel.DataAnnotations.DisplayAttribute's resource lookup. + /// + /// + /// Probes (in order): + /// 1) public static property named returning string, + /// 2) public static field named of type string, + /// 3) public static parameterless method named returning string, + /// 4) public static property named "ResourceManager" used as a key/value store. + /// + private static bool TryResolveExternalDescription(Type resourceType, string resourceName, out string? value) + { + value = null; + + const BindingFlags PublicStatic = BindingFlags.Public | BindingFlags.Static; + + // 1) Static string property. + PropertyInfo? prop = resourceType.GetProperty(resourceName, PublicStatic); + if (prop is not null && prop.PropertyType == typeof(string) && prop.GetGetMethod(nonPublic: false) is not null) + { + value = (string?)prop.GetValue(null); + return true; + } + + // 2) Static string field. + FieldInfo? field = resourceType.GetField(resourceName, PublicStatic); + if (field is not null && field.FieldType == typeof(string)) + { + value = (string?)field.GetValue(null); + return true; + } + + // 3) Static parameterless string method. + MethodInfo? method = resourceType.GetMethod(resourceName, PublicStatic, binder: null, types: Type.EmptyTypes, modifiers: null); + if (method is not null && method.ReturnType == typeof(string)) + { + value = (string?)method.Invoke(null, parameters: null); + return true; + } + + // 4) Static ResourceManager named "ResourceManager". + PropertyInfo? rmProp = resourceType.GetProperty("ResourceManager", PublicStatic); + if (rmProp is not null && typeof(ResourceManager).IsAssignableFrom(rmProp.PropertyType)) + { + if (rmProp.GetValue(null) is ResourceManager rm) + { + value = rm.GetString(resourceName); + return value is not null; + } + } + + return false; + } + /// Gets the wrapped by this tool. internal AIFunction AIFunction { get; } @@ -600,4 +668,4 @@ private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumer IsError = allErrorContent && hasAny }; } -} \ No newline at end of file +} diff --git a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs index d67bac18c..953352abf 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs @@ -286,6 +286,48 @@ public bool ReadOnly /// public Type? OutputSchemaType { get; set; } + /// + /// Gets or sets a from which the tool's description is loaded at discovery time. + /// + /// + /// The default is , which means the description is taken from the + /// applied to the method (if any). + /// + /// + /// + /// When set together with , the description is resolved at + /// tool-discovery time by looking up on the specified type. + /// This mirrors the resource-lookup pattern used by + /// System.ComponentModel.DataAnnotations.DisplayAttribute and lets descriptions live + /// outside compiled string literals (resource files, configuration-backed accessors, etc.), + /// addressing the scenario in https://github.com/modelcontextprotocol/csharp-sdk/issues/1516. + /// + /// + /// The lookup probes the type for, in this order: + /// + /// + /// A public static property named returning . + /// A public static field named of type . + /// A public static parameterless method named returning . + /// A public static System.Resources.ResourceManager property named ResourceManager; the string is looked up by . + /// + /// + /// If the lookup succeeds, its value takes precedence over any + /// on the method. If both and are unset, + /// behavior is unchanged. The description value is read once when the tool is created; to refresh it, recreate the tool. + /// + /// + public Type? DescriptionResourceType { get; set; } + + /// + /// Gets or sets the name used to look up the tool's description on . + /// + /// + /// See for resolution rules. Has no effect unless + /// is also set. + /// + public string? DescriptionResourceName { get; set; } + /// /// Gets or sets the source URI for the tool's icon. /// @@ -315,7 +357,7 @@ public bool ReadOnly /// When set to , clients must invoke the tool as a task. /// /// - /// If this property is not explicitly set on the attribute, the task support behavior will be determined + /// If this property is not explicitly set on the attribute, the task support behavior will be determined /// automatically based on the tool's characteristics (e.g., async methods default to ). /// /// diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolExternalDescriptionTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolExternalDescriptionTests.cs new file mode 100644 index 000000000..d55000b66 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolExternalDescriptionTests.cs @@ -0,0 +1,149 @@ +using ModelContextProtocol.Server; +using System.ComponentModel; +using System.Reflection; +using System.Resources; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests for the external description source feature on +/// (issue https://github.com/modelcontextprotocol/csharp-sdk/issues/1516). +/// +public class McpServerToolExternalDescriptionTests +{ + [Fact] + public void ExternalDescription_StaticProperty_IsResolved() + { + MethodInfo method = typeof(McpServerToolExternalDescriptionTests) + .GetMethod(nameof(FromProperty), BindingFlags.NonPublic | BindingFlags.Static)!; + + McpServerTool tool = McpServerTool.Create(method); + + Assert.Equal("Resolved from static property.", tool.ProtocolTool.Description); + } + + [Fact] + public void ExternalDescription_StaticField_IsResolved() + { + MethodInfo method = typeof(McpServerToolExternalDescriptionTests) + .GetMethod(nameof(FromField), BindingFlags.NonPublic | BindingFlags.Static)!; + + McpServerTool tool = McpServerTool.Create(method); + + Assert.Equal("Resolved from static field.", tool.ProtocolTool.Description); + } + + [Fact] + public void ExternalDescription_StaticMethod_IsResolved() + { + MethodInfo method = typeof(McpServerToolExternalDescriptionTests) + .GetMethod(nameof(FromMethod), BindingFlags.NonPublic | BindingFlags.Static)!; + + McpServerTool tool = McpServerTool.Create(method); + + Assert.Equal("Resolved from static method.", tool.ProtocolTool.Description); + } + + [Fact] + public void ExternalDescription_TakesPrecedenceOverDescriptionAttribute() + { + MethodInfo method = typeof(McpServerToolExternalDescriptionTests) + .GetMethod(nameof(OverridesDescriptionAttribute), BindingFlags.NonPublic | BindingFlags.Static)!; + + McpServerTool tool = McpServerTool.Create(method); + + Assert.Equal("Resolved from static property.", tool.ProtocolTool.Description); + } + + [Fact] + public void ExternalDescription_MissingResource_FallsBackToDescriptionAttribute() + { + MethodInfo method = typeof(McpServerToolExternalDescriptionTests) + .GetMethod(nameof(MissingFallsBack), BindingFlags.NonPublic | BindingFlags.Static)!; + + McpServerTool tool = McpServerTool.Create(method); + + Assert.Equal("Fallback description.", tool.ProtocolTool.Description); + } + + [Fact] + public void ExternalDescription_NameWithoutType_NoEffect() + { + MethodInfo method = typeof(McpServerToolExternalDescriptionTests) + .GetMethod(nameof(NameOnly), BindingFlags.NonPublic | BindingFlags.Static)!; + + McpServerTool tool = McpServerTool.Create(method); + + Assert.Equal("Plain description.", tool.ProtocolTool.Description); + } + + [Fact] + public void ExternalDescription_ResourceManager_IsResolved() + { + MethodInfo method = typeof(McpServerToolExternalDescriptionTests) + .GetMethod(nameof(FromResourceManager), BindingFlags.NonPublic | BindingFlags.Static)!; + + McpServerTool tool = McpServerTool.Create(method); + + Assert.Equal("From resource manager.", tool.ProtocolTool.Description); + } + + // ---- Inline "resource" type used by the resolver tests. ---- + + private static class ToolDescriptions + { + public static string PropertyDescription => "Resolved from static property."; + public static readonly string FieldDescription = "Resolved from static field."; + public static string MethodDescription() => "Resolved from static method."; + } + + /// + /// Minimal in-memory used to verify the fallback path that resolves + /// descriptions through a static ResourceManager property. + /// + private sealed class InMemoryResourceManager : ResourceManager + { + private readonly Dictionary _strings; + + public InMemoryResourceManager(Dictionary strings) + { + _strings = strings; + } + + public override string? GetString(string name) => _strings.TryGetValue(name, out string? value) ? value : null; + + public override string? GetString(string name, System.Globalization.CultureInfo? culture) => GetString(name); + } + + private static class ResourceManagerHost + { + public static ResourceManager ResourceManager { get; } = new InMemoryResourceManager(new() + { + ["WelcomeMessage"] = "From resource manager.", + }); + } + + [McpServerTool(DescriptionResourceType = typeof(ToolDescriptions), DescriptionResourceName = nameof(ToolDescriptions.PropertyDescription))] + private static string FromProperty() => "ok"; + + [McpServerTool(DescriptionResourceType = typeof(ToolDescriptions), DescriptionResourceName = nameof(ToolDescriptions.FieldDescription))] + private static string FromField() => "ok"; + + [McpServerTool(DescriptionResourceType = typeof(ToolDescriptions), DescriptionResourceName = nameof(ToolDescriptions.MethodDescription))] + private static string FromMethod() => "ok"; + + [McpServerTool(DescriptionResourceType = typeof(ToolDescriptions), DescriptionResourceName = nameof(ToolDescriptions.PropertyDescription))] + [Description("Compiled-in description that should be overridden.")] + private static string OverridesDescriptionAttribute() => "ok"; + + [McpServerTool(DescriptionResourceType = typeof(ToolDescriptions), DescriptionResourceName = "DoesNotExist")] + [Description("Fallback description.")] + private static string MissingFallsBack() => "ok"; + + [McpServerTool(DescriptionResourceName = nameof(ToolDescriptions.PropertyDescription))] + [Description("Plain description.")] + private static string NameOnly() => "ok"; + + [McpServerTool(DescriptionResourceType = typeof(ResourceManagerHost), DescriptionResourceName = "WelcomeMessage")] + private static string FromResourceManager() => "ok"; +}