Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 71 additions & 3 deletions src/ModelContextProtocol.Core/Server/AIFunctionMcpServerTool.cs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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)
{
Expand Down Expand Up @@ -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<DescriptionAttribute>() is { } descAttr)
Expand All @@ -231,6 +244,61 @@ private static McpServerToolCreateOptions DeriveOptions(MethodInfo method, McpSe
return newOptions;
}

/// <summary>
/// Resolves an external description value from a "resource"-like type, modeled after
/// <c>System.ComponentModel.DataAnnotations.DisplayAttribute</c>'s resource lookup.
/// </summary>
/// <remarks>
/// Probes (in order):
/// 1) public static property named <paramref name="resourceName"/> returning string,
/// 2) public static field named <paramref name="resourceName"/> of type string,
/// 3) public static parameterless method named <paramref name="resourceName"/> returning string,
/// 4) public static <see cref="ResourceManager"/> property named "ResourceManager" used as a key/value store.
/// </remarks>
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;
}

/// <summary>Gets the <see cref="AIFunction"/> wrapped by this tool.</summary>
internal AIFunction AIFunction { get; }

Expand Down Expand Up @@ -600,4 +668,4 @@ private static CallToolResult ConvertAIContentEnumerableToCallToolResult(IEnumer
IsError = allErrorContent && hasAny
};
}
}
}
44 changes: 43 additions & 1 deletion src/ModelContextProtocol.Core/Server/McpServerToolAttribute.cs
Original file line number Diff line number Diff line change
Expand Up @@ -286,6 +286,48 @@ public bool ReadOnly
/// </remarks>
public Type? OutputSchemaType { get; set; }

/// <summary>
/// Gets or sets a <see cref="Type"/> from which the tool's description is loaded at discovery time.
/// </summary>
/// <value>
/// The default is <see langword="null"/>, which means the description is taken from the
/// <see cref="System.ComponentModel.DescriptionAttribute"/> applied to the method (if any).
/// </value>
/// <remarks>
/// <para>
/// When set together with <see cref="DescriptionResourceName"/>, the description is resolved at
/// tool-discovery time by looking up <see cref="DescriptionResourceName"/> on the specified type.
/// This mirrors the resource-lookup pattern used by
/// <c>System.ComponentModel.DataAnnotations.DisplayAttribute</c> 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.
/// </para>
/// <para>
/// The lookup probes the type for, in this order:
/// </para>
/// <list type="number">
/// <item><description>A public static property named <see cref="DescriptionResourceName"/> returning <see cref="string"/>.</description></item>
/// <item><description>A public static field named <see cref="DescriptionResourceName"/> of type <see cref="string"/>.</description></item>
/// <item><description>A public static parameterless method named <see cref="DescriptionResourceName"/> returning <see cref="string"/>.</description></item>
/// <item><description>A public static <c>System.Resources.ResourceManager</c> property named <c>ResourceManager</c>; the string is looked up by <see cref="DescriptionResourceName"/>.</description></item>
/// </list>
/// <para>
/// If the lookup succeeds, its value takes precedence over any <see cref="System.ComponentModel.DescriptionAttribute"/>
/// on the method. If both <see cref="DescriptionResourceType"/> and <see cref="DescriptionResourceName"/> are unset,
/// behavior is unchanged. The description value is read once when the tool is created; to refresh it, recreate the tool.
/// </para>
/// </remarks>
public Type? DescriptionResourceType { get; set; }

/// <summary>
/// Gets or sets the name used to look up the tool's description on <see cref="DescriptionResourceType"/>.
/// </summary>
/// <remarks>
/// See <see cref="DescriptionResourceType"/> for resolution rules. Has no effect unless
/// <see cref="DescriptionResourceType"/> is also set.
/// </remarks>
public string? DescriptionResourceName { get; set; }

/// <summary>
/// Gets or sets the source URI for the tool's icon.
/// </summary>
Expand Down Expand Up @@ -315,7 +357,7 @@ public bool ReadOnly
/// When set to <see cref="ToolTaskSupport.Required"/>, clients must invoke the tool as a task.
/// </para>
/// <para>
/// 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 <see cref="ToolTaskSupport.Optional"/>).
/// </para>
/// </remarks>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
using ModelContextProtocol.Server;
using System.ComponentModel;
using System.Reflection;
using System.Resources;

namespace ModelContextProtocol.Tests.Server;

/// <summary>
/// Tests for the external description source feature on <see cref="McpServerToolAttribute"/>
/// (issue https://github.com/modelcontextprotocol/csharp-sdk/issues/1516).
/// </summary>
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.";
}

/// <summary>
/// Minimal in-memory <see cref="ResourceManager"/> used to verify the fallback path that resolves
/// descriptions through a static <c>ResourceManager</c> property.
/// </summary>
private sealed class InMemoryResourceManager : ResourceManager
{
private readonly Dictionary<string, string> _strings;

public InMemoryResourceManager(Dictionary<string, string> 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";
}