Add WithXx overloads that take target instance (#706)

* Add WithXx overloads that take target instance
* Special-case enumerables
This commit is contained in:
Stephen Toub 2025-08-26 12:38:53 -04:00 • committed by GitHub
commit 2fa658e439
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 290 additions and 4 deletions

View file

@ -53,6 +53,53 @@ public static partial class McpServerBuilderExtensions
return builder;
}
/// <summary>Adds <see cref="McpServerTool"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <typeparam name="TToolType">The tool type.</typeparam>
/// <param name="builder">The builder instance.</param>
/// <param name="target">The target instance from which the tools should be sourced.</param>
/// <param name="serializerOptions">The serializer options governing tool parameter marshalling.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This method discovers all methods (public and non-public) on the specified <typeparamref name="TToolType"/>
/// type, where the methods are attributed as <see cref="McpServerToolAttribute"/>, and adds an <see cref="McpServerTool"/>
/// instance for each, using <paramref name="target"/> as the associated instance for instance methods.
/// </para>
/// <para>
/// However, if <typeparamref name="TToolType"/> is itself an <see cref="IEnumerable{T}"/> of <see cref="McpServerTool"/>,
/// this method will register those tools directly without scanning for methods on <typeparamref name="TToolType"/>.
/// </para>
/// </remarks>
public static IMcpServerBuilder WithTools<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods)] TToolType>(
this IMcpServerBuilder builder,
TToolType target,
JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(builder);
Throw.IfNull(target);
if (target is IEnumerable<McpServerTool> tools)
{
return builder.WithTools(tools);
}
foreach (var toolMethod in typeof(TToolType).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (toolMethod.GetCustomAttribute<McpServerToolAttribute>() is not null)
{
builder.Services.AddSingleton(services => McpServerTool.Create(
toolMethod,
toolMethod.IsStatic ? null : target,
new() { Services = services, SerializerOptions = serializerOptions }));
}
}
return builder;
}
/// <summary>Adds <see cref="McpServerTool"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <param name="builder">The builder instance.</param>
/// <param name="tools">The <see cref="McpServerTool"/> instances to add to the server.</param>
@ -137,7 +184,7 @@ public static partial class McpServerBuilderExtensions
/// </para>
/// <para>
/// Note that this method performs reflection at runtime and may not work in Native AOT scenarios. For
/// Native AOT compatibility, consider using the generic <see cref="WithTools{TToolType}"/> method instead.
/// Native AOT compatibility, consider using the generic <see cref="M:WithTools"/> method instead.
/// </para>
/// </remarks>
[RequiresUnreferencedCode(WithToolsRequiresUnreferencedCodeMessage)]
@ -193,6 +240,50 @@ public static partial class McpServerBuilderExtensions
return builder;
}
/// <summary>Adds <see cref="McpServerPrompt"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <typeparam name="TPromptType">The prompt type.</typeparam>
/// <param name="builder">The builder instance.</param>
/// <param name="target">The target instance from which the prompts should be sourced.</param>
/// <param name="serializerOptions">The serializer options governing prompt parameter marshalling.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This method discovers all methods (public and non-public) on the specified <typeparamref name="TPromptType"/>
/// type, where the methods are attributed as <see cref="McpServerPromptAttribute"/>, and adds an <see cref="McpServerPrompt"/>
/// instance for each, using <paramref name="target"/> as the associated instance for instance methods.
/// </para>
/// <para>
/// However, if <typeparamref name="TPromptType"/> is itself an <see cref="IEnumerable{T}"/> of <see cref="McpServerPrompt"/>,
/// this method will register those prompts directly without scanning for methods on <typeparamref name="TPromptType"/>.
/// </para>
/// </remarks>
public static IMcpServerBuilder WithPrompts<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods)] TPromptType>(
this IMcpServerBuilder builder,
TPromptType target,
JsonSerializerOptions? serializerOptions = null)
{
Throw.IfNull(builder);
Throw.IfNull(target);
if (target is IEnumerable<McpServerPrompt> prompts)
{
return builder.WithPrompts(prompts);
}
foreach (var promptMethod in typeof(TPromptType).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (promptMethod.GetCustomAttribute<McpServerPromptAttribute>() is not null)
{
builder.Services.AddSingleton(services => McpServerPrompt.Create(promptMethod, target, new() { Services = services, SerializerOptions = serializerOptions }));
}
}
return builder;
}
/// <summary>Adds <see cref="McpServerPrompt"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <param name="builder">The builder instance.</param>
/// <param name="prompts">The <see cref="McpServerPrompt"/> instances to add to the server.</param>
@ -277,7 +368,7 @@ public static partial class McpServerBuilderExtensions
/// </para>
/// <para>
/// Note that this method performs reflection at runtime and may not work in Native AOT scenarios. For
/// Native AOT compatibility, consider using the generic <see cref="WithPrompts{TPromptType}"/> method instead.
/// Native AOT compatibility, consider using the generic <see cref="M:WithPrompts"/> method instead.
/// </para>
/// </remarks>
[RequiresUnreferencedCode(WithPromptsRequiresUnreferencedCodeMessage)]
@ -311,7 +402,8 @@ public static partial class McpServerBuilderExtensions
/// instance for each. For instance members, an instance will be constructed for each invocation of the resource.
/// </remarks>
public static IMcpServerBuilder WithResources<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicMethods | DynamicallyAccessedMemberTypes.NonPublicMethods |
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods |
DynamicallyAccessedMemberTypes.PublicConstructors)] TResourceType>(
this IMcpServerBuilder builder)
{
@ -330,6 +422,48 @@ public static partial class McpServerBuilderExtensions
return builder;
}
/// <summary>Adds <see cref="McpServerResource"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <typeparam name="TResourceType">The resource type.</typeparam>
/// <param name="builder">The builder instance.</param>
/// <param name="target">The target instance from which the prompts should be sourced.</param>
/// <returns>The builder provided in <paramref name="builder"/>.</returns>
/// <exception cref="ArgumentNullException"><paramref name="builder"/> is <see langword="null"/>.</exception>
/// <remarks>
/// <para>
/// This method discovers all methods (public and non-public) on the specified <typeparamref name="TResourceType"/>
/// type, where the methods are attributed as <see cref="McpServerResourceAttribute"/>, and adds an <see cref="McpServerResource"/>
/// instance for each, using <paramref name="target"/> as the associated instance for instance methods.
/// </para>
/// <para>
/// However, if <typeparamref name="TResourceType"/> is itself an <see cref="IEnumerable{T}"/> of <see cref="McpServerResource"/>,
/// this method will register those resources directly without scanning for methods on <typeparamref name="TResourceType"/>.
/// </para>
/// </remarks>
public static IMcpServerBuilder WithResources<[DynamicallyAccessedMembers(
DynamicallyAccessedMemberTypes.PublicMethods |
DynamicallyAccessedMemberTypes.NonPublicMethods)] TResourceType>(
this IMcpServerBuilder builder,
TResourceType target)
{
Throw.IfNull(builder);
Throw.IfNull(target);
if (target is IEnumerable<McpServerResource> resources)
{
return builder.WithResources(resources);
}
foreach (var resourceTemplateMethod in typeof(TResourceType).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Static | BindingFlags.Instance))
{
if (resourceTemplateMethod.GetCustomAttribute<McpServerResourceAttribute>() is not null)
{
builder.Services.AddSingleton(services => McpServerResource.Create(resourceTemplateMethod, target, new() { Services = services }));
}
}
return builder;
}
/// <summary>Adds <see cref="McpServerResource"/> instances to the service collection backing <paramref name="builder"/>.</summary>
/// <param name="builder">The builder instance.</param>
/// <param name="resourceTemplates">The <see cref="McpServerResource"/> instances to add to the server.</param>
@ -412,7 +546,7 @@ public static partial class McpServerBuilderExtensions
/// </para>
/// <para>
/// Note that this method performs reflection at runtime and may not work in Native AOT scenarios. For
/// Native AOT compatibility, consider using the generic <see cref="WithResources{TResourceType}"/> method instead.
/// Native AOT compatibility, consider using the generic <see cref="M:WithResources"/> method instead.
/// </para>
/// </remarks>
[RequiresUnreferencedCode(WithResourcesRequiresUnreferencedCodeMessage)]

View file

@ -4,7 +4,10 @@ using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Moq;
using System.Collections;
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Channels;
@ -217,13 +220,63 @@ public partial class McpServerBuilderExtensionsPromptsTests : ClientServerTestBa
Assert.Throws<ArgumentNullException>("prompts", () => builder.WithPrompts((IEnumerable<McpServerPrompt>)null!));
Assert.Throws<ArgumentNullException>("promptTypes", () => builder.WithPrompts((IEnumerable<Type>)null!));
Assert.Throws<ArgumentNullException>("target", () => builder.WithPrompts<object>(target: null!));
IMcpServerBuilder nullBuilder = null!;
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithPrompts<object>());
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithPrompts(new object()));
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithPrompts(Array.Empty<Type>()));
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithPromptsFromAssembly());
}
[Fact]
public async Task WithPrompts_TargetInstance_UsesTarget()
{
ServiceCollection sc = new();
var target = new SimplePrompts(new ObjectWithId() { Id = "42" });
sc.AddMcpServer().WithPrompts(target);
McpServerPrompt prompt = sc.BuildServiceProvider().GetServices<McpServerPrompt>().First(t => t.ProtocolPrompt.Name == "returns_string");
var result = await prompt.GetAsync(new RequestContext<GetPromptRequestParams>(new Mock<IMcpServer>().Object)
{
Params = new GetPromptRequestParams
{
Name = "returns_string",
Arguments = new Dictionary<string, JsonElement>
{
["message"] = JsonSerializer.SerializeToElement("hello", AIJsonUtilities.DefaultOptions),
}
}
}, TestContext.Current.CancellationToken);
Assert.Equal(target.ReturnsString("hello"), (result.Messages[0].Content as TextContentBlock)?.Text);
}
[Fact]
public async Task WithPrompts_TargetInstance_UsesEnumerableImplementation()
{
ServiceCollection sc = new();
sc.AddMcpServer().WithPrompts(new MyPromptProvider());
var prompts = sc.BuildServiceProvider().GetServices<McpServerPrompt>().ToArray();
Assert.Equal(2, prompts.Length);
Assert.Contains(prompts, t => t.ProtocolPrompt.Name == "Returns42");
Assert.Contains(prompts, t => t.ProtocolPrompt.Name == "Returns43");
}
private sealed class MyPromptProvider : IEnumerable<McpServerPrompt>
{
public IEnumerator<McpServerPrompt> GetEnumerator()
{
yield return McpServerPrompt.Create(() => "42", new() { Name = "Returns42" });
yield return McpServerPrompt.Create(() => "43", new() { Name = "Returns43" });
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Fact]
public void Empty_Enumerables_Is_Allowed()
{

View file

@ -4,8 +4,12 @@ using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Moq;
using System.Collections;
using System.ComponentModel;
using System.Text.Json;
using System.Threading.Channels;
using static ModelContextProtocol.Tests.Configuration.McpServerBuilderExtensionsPromptsTests;
namespace ModelContextProtocol.Tests.Configuration;
@ -243,13 +247,59 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
Assert.Throws<ArgumentNullException>("resourceTemplates", () => builder.WithResources((IEnumerable<McpServerResource>)null!));
Assert.Throws<ArgumentNullException>("resourceTemplateTypes", () => builder.WithResources((IEnumerable<Type>)null!));
Assert.Throws<ArgumentNullException>("target", () => builder.WithResources<object>(target: null!));
IMcpServerBuilder nullBuilder = null!;
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithResources<object>());
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithResources(new object()));
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithResources(Array.Empty<Type>()));
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithResourcesFromAssembly());
}
[Fact]
public async Task WithResources_TargetInstance_UsesTarget()
{
ServiceCollection sc = new();
var target = new ResourceWithId(new ObjectWithId() { Id = "42" });
sc.AddMcpServer().WithResources(target);
McpServerResource resource = sc.BuildServiceProvider().GetServices<McpServerResource>().First(t => t.ProtocolResource?.Name == "returns_string");
var result = await resource.ReadAsync(new RequestContext<ReadResourceRequestParams>(new Mock<IMcpServer>().Object)
{
Params = new()
{
Uri = "returns://string"
}
}, TestContext.Current.CancellationToken);
Assert.Equal(target.ReturnsString(), (result?.Contents[0] as TextResourceContents)?.Text);
}
[Fact]
public async Task WithResources_TargetInstance_UsesEnumerableImplementation()
{
ServiceCollection sc = new();
sc.AddMcpServer().WithResources(new MyResourceProvider());
var resources = sc.BuildServiceProvider().GetServices<McpServerResource>().ToArray();
Assert.Equal(2, resources.Length);
Assert.Contains(resources, t => t.ProtocolResource?.Name == "Returns42");
Assert.Contains(resources, t => t.ProtocolResource?.Name == "Returns43");
}
private sealed class MyResourceProvider : IEnumerable<McpServerResource>
{
public IEnumerator<McpServerResource> GetEnumerator()
{
yield return McpServerResource.Create(() => "42", new() { Name = "Returns42" });
yield return McpServerResource.Create(() => "43", new() { Name = "Returns43" });
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Fact]
public void Empty_Enumerables_Is_Allowed()
{
@ -307,4 +357,11 @@ public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTest
[McpServerResource, Description("Another neat direct resource")]
public static string AnotherNeatDirectResource() => "This is a neat resource";
}
[McpServerResourceType]
public sealed class ResourceWithId(ObjectWithId id)
{
[McpServerResource(UriTemplate = "returns://string")]
public string ReturnsString() => $"Id: {id.Id}";
}
}

View file

@ -5,6 +5,8 @@ using Microsoft.Extensions.Options;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Moq;
using System.Collections;
using System.Collections.Concurrent;
using System.ComponentModel;
using System.IO.Pipelines;
@ -403,9 +405,11 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
Assert.Throws<ArgumentNullException>("tools", () => builder.WithTools((IEnumerable<McpServerTool>)null!));
Assert.Throws<ArgumentNullException>("toolTypes", () => builder.WithTools((IEnumerable<Type>)null!));
Assert.Throws<ArgumentNullException>("target", () => builder.WithTools<object>(target: null!));
IMcpServerBuilder nullBuilder = null!;
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithTools<object>());
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithTools(new object()));
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithTools(Array.Empty<Type>()));
Assert.Throws<ArgumentNullException>("builder", () => nullBuilder.WithToolsFromAssembly());
}
@ -503,6 +507,44 @@ public partial class McpServerBuilderExtensionsToolsTests : ClientServerTestBase
}
}
[Fact]
public async Task WithTools_TargetInstance_UsesTarget()
{
ServiceCollection sc = new();
var target = new EchoTool(new ObjectWithId());
sc.AddMcpServer().WithTools(target, BuilderToolsJsonContext.Default.Options);
McpServerTool tool = sc.BuildServiceProvider().GetServices<McpServerTool>().First(t => t.ProtocolTool.Name == "get_ctor_parameter");
var result = await tool.InvokeAsync(new RequestContext<CallToolRequestParams>(new Mock<IMcpServer>().Object), TestContext.Current.CancellationToken);
Assert.Equal(target.GetCtorParameter(), (result.Content[0] as TextContentBlock)?.Text);
}
[Fact]
public async Task WithTools_TargetInstance_UsesEnumerableImplementation()
{
ServiceCollection sc = new();
sc.AddMcpServer().WithTools(new MyToolProvider());
var tools = sc.BuildServiceProvider().GetServices<McpServerTool>().ToArray();
Assert.Equal(2, tools.Length);
Assert.Contains(tools, t => t.ProtocolTool.Name == "Returns42");
Assert.Contains(tools, t => t.ProtocolTool.Name == "Returns43");
}
private sealed class MyToolProvider : IEnumerable<McpServerTool>
{
public IEnumerator<McpServerTool> GetEnumerator()
{
yield return McpServerTool.Create(() => "42", new() { Name = "Returns42" });
yield return McpServerTool.Create(() => "43", new() { Name = "Returns43" });
}
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
}
[Fact]
public async Task Recognizes_Parameter_Types()
{