Add public constructors to McpClient types for reusing cached definitions (#938)
* Initial plan * Add public constructor to McpClientTool for reusing tool definitions Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Fix McpClientTool constructor per review feedback Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> * Add public constructors for McpClientPrompt, McpClientResource, and McpClientResourceTemplate Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
This commit is contained in:
parent
baa7d6e320
commit
b90f17bccb
8 changed files with 785 additions and 3 deletions
|
|
@ -22,8 +22,30 @@ public sealed class McpClientPrompt
|
||||||
{
|
{
|
||||||
private readonly McpClient _client;
|
private readonly McpClient _client;
|
||||||
|
|
||||||
internal McpClientPrompt(McpClient client, Prompt prompt)
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="McpClientPrompt"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The <see cref="McpClient"/> instance to use for invoking the prompt.</param>
|
||||||
|
/// <param name="prompt">The protocol <see cref="Prompt"/> definition describing the prompt's metadata.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This constructor enables reusing cached prompt definitions across different <see cref="McpClient"/> instances
|
||||||
|
/// without needing to call <see cref="McpClient.ListPromptsAsync"/> on every reconnect. This is particularly useful
|
||||||
|
/// in scenarios where prompt definitions are stable and network round-trips should be minimized.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The provided <paramref name="prompt"/> must represent a prompt that is actually available on the server
|
||||||
|
/// associated with the <paramref name="client"/>. Attempting to invoke a prompt that doesn't exist on the
|
||||||
|
/// server will result in an <see cref="McpException"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="prompt"/> is <see langword="null"/>.</exception>
|
||||||
|
public McpClientPrompt(McpClient client, Prompt prompt)
|
||||||
{
|
{
|
||||||
|
Throw.IfNull(client);
|
||||||
|
Throw.IfNull(prompt);
|
||||||
|
|
||||||
_client = client;
|
_client = client;
|
||||||
ProtocolPrompt = prompt;
|
ProtocolPrompt = prompt;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,30 @@ public sealed class McpClientResource
|
||||||
{
|
{
|
||||||
private readonly McpClient _client;
|
private readonly McpClient _client;
|
||||||
|
|
||||||
internal McpClientResource(McpClient client, Resource resource)
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="McpClientResource"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The <see cref="McpClient"/> instance to use for reading the resource.</param>
|
||||||
|
/// <param name="resource">The protocol <see cref="Resource"/> definition describing the resource's metadata.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This constructor enables reusing cached resource definitions across different <see cref="McpClient"/> instances
|
||||||
|
/// without needing to call <see cref="McpClient.ListResourcesAsync"/> on every reconnect. This is particularly useful
|
||||||
|
/// in scenarios where resource definitions are stable and network round-trips should be minimized.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The provided <paramref name="resource"/> must represent a resource that is actually available on the server
|
||||||
|
/// associated with the <paramref name="client"/>. Attempting to read a resource that doesn't exist on the
|
||||||
|
/// server will result in an <see cref="McpException"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="resource"/> is <see langword="null"/>.</exception>
|
||||||
|
public McpClientResource(McpClient client, Resource resource)
|
||||||
{
|
{
|
||||||
|
Throw.IfNull(client);
|
||||||
|
Throw.IfNull(resource);
|
||||||
|
|
||||||
_client = client;
|
_client = client;
|
||||||
ProtocolResource = resource;
|
ProtocolResource = resource;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,30 @@ public sealed class McpClientResourceTemplate
|
||||||
{
|
{
|
||||||
private readonly McpClient _client;
|
private readonly McpClient _client;
|
||||||
|
|
||||||
internal McpClientResourceTemplate(McpClient client, ResourceTemplate resourceTemplate)
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="McpClientResourceTemplate"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The <see cref="McpClient"/> instance to use for reading the resource template.</param>
|
||||||
|
/// <param name="resourceTemplate">The protocol <see cref="ResourceTemplate"/> definition describing the resource template's metadata.</param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This constructor enables reusing cached resource template definitions across different <see cref="McpClient"/> instances
|
||||||
|
/// without needing to call <see cref="McpClient.ListResourceTemplatesAsync"/> on every reconnect. This is particularly useful
|
||||||
|
/// in scenarios where resource template definitions are stable and network round-trips should be minimized.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The provided <paramref name="resourceTemplate"/> must represent a resource template that is actually available on the server
|
||||||
|
/// associated with the <paramref name="client"/>. Attempting to read a resource template that doesn't exist on the
|
||||||
|
/// server will result in an <see cref="McpException"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="resourceTemplate"/> is <see langword="null"/>.</exception>
|
||||||
|
public McpClientResourceTemplate(McpClient client, ResourceTemplate resourceTemplate)
|
||||||
{
|
{
|
||||||
|
Throw.IfNull(client);
|
||||||
|
Throw.IfNull(resourceTemplate);
|
||||||
|
|
||||||
_client = client;
|
_client = client;
|
||||||
ProtocolResourceTemplate = resourceTemplate;
|
ProtocolResourceTemplate = resourceTemplate;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,45 @@ public sealed class McpClientTool : AIFunction
|
||||||
private readonly string _description;
|
private readonly string _description;
|
||||||
private readonly IProgress<ProgressNotificationValue>? _progress;
|
private readonly IProgress<ProgressNotificationValue>? _progress;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Initializes a new instance of the <see cref="McpClientTool"/> class.
|
||||||
|
/// </summary>
|
||||||
|
/// <param name="client">The <see cref="McpClient"/> instance to use for invoking the tool.</param>
|
||||||
|
/// <param name="tool">The protocol <see cref="Tool"/> definition describing the tool's metadata and schema.</param>
|
||||||
|
/// <param name="serializerOptions">
|
||||||
|
/// The JSON serialization options governing argument serialization. If <see langword="null"/>,
|
||||||
|
/// <see cref="McpJsonUtilities.DefaultOptions"/> will be used.
|
||||||
|
/// </param>
|
||||||
|
/// <remarks>
|
||||||
|
/// <para>
|
||||||
|
/// This constructor enables reusing cached tool definitions across different <see cref="McpClient"/> instances
|
||||||
|
/// without needing to call <see cref="McpClient.ListToolsAsync"/> on every reconnect. This is particularly useful
|
||||||
|
/// in scenarios where tool definitions are stable and network round-trips should be minimized.
|
||||||
|
/// </para>
|
||||||
|
/// <para>
|
||||||
|
/// The provided <paramref name="tool"/> must represent a tool that is actually available on the server
|
||||||
|
/// associated with the <paramref name="client"/>. Attempting to invoke a tool that doesn't exist on the
|
||||||
|
/// server will result in an <see cref="McpException"/>.
|
||||||
|
/// </para>
|
||||||
|
/// </remarks>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
|
||||||
|
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
|
||||||
|
public McpClientTool(
|
||||||
|
McpClient client,
|
||||||
|
Tool tool,
|
||||||
|
JsonSerializerOptions? serializerOptions = null)
|
||||||
|
{
|
||||||
|
Throw.IfNull(client);
|
||||||
|
Throw.IfNull(tool);
|
||||||
|
|
||||||
|
_client = client;
|
||||||
|
ProtocolTool = tool;
|
||||||
|
JsonSerializerOptions = serializerOptions ?? McpJsonUtilities.DefaultOptions;
|
||||||
|
_name = tool.Name;
|
||||||
|
_description = tool.Description ?? string.Empty;
|
||||||
|
_progress = null;
|
||||||
|
}
|
||||||
|
|
||||||
internal McpClientTool(
|
internal McpClientTool(
|
||||||
McpClient client,
|
McpClient client,
|
||||||
Tool tool,
|
Tool tool,
|
||||||
|
|
|
||||||
137
tests/ModelContextProtocol.Tests/Client/McpClientPromptTests.cs
Normal file
137
tests/ModelContextProtocol.Tests/Client/McpClientPromptTests.cs
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
using Microsoft.Extensions.AI;
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Tests.Client;
|
||||||
|
|
||||||
|
public class McpClientPromptTests : ClientServerTestBase
|
||||||
|
{
|
||||||
|
public McpClientPromptTests(ITestOutputHelper outputHelper)
|
||||||
|
: base(outputHelper)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||||
|
{
|
||||||
|
mcpServerBuilder.WithPrompts<GreetingPrompts>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerPromptType]
|
||||||
|
private sealed class GreetingPrompts
|
||||||
|
{
|
||||||
|
[McpServerPrompt, Description("Generates a greeting prompt")]
|
||||||
|
public static ChatMessage Greeting([Description("The name to greet")] string name) =>
|
||||||
|
new(ChatRole.User, $"Hello, {name}!");
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithValidParameters_CreatesInstance()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var originalPrompt = prompts.First();
|
||||||
|
var promptDefinition = originalPrompt.ProtocolPrompt;
|
||||||
|
|
||||||
|
var newPrompt = new McpClientPrompt(client, promptDefinition);
|
||||||
|
|
||||||
|
Assert.NotNull(newPrompt);
|
||||||
|
Assert.Equal("greeting", newPrompt.Name);
|
||||||
|
Assert.Equal("Generates a greeting prompt", newPrompt.Description);
|
||||||
|
Assert.Same(promptDefinition, newPrompt.ProtocolPrompt);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WithNullClient_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
var promptDefinition = new Prompt
|
||||||
|
{
|
||||||
|
Name = "test",
|
||||||
|
Description = "Test prompt"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>("client", () => new McpClientPrompt(null!, promptDefinition));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithNullPrompt_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>("prompt", () => new McpClientPrompt(client, null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReusePromptDefinition_AcrossDifferentClients_InvokesSuccessfully()
|
||||||
|
{
|
||||||
|
Prompt promptDefinition;
|
||||||
|
{
|
||||||
|
await using McpClient client1 = await CreateMcpClientForServer();
|
||||||
|
var prompts = await client1.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var greetingPrompt = prompts.First(p => p.Name == "greeting");
|
||||||
|
promptDefinition = greetingPrompt.ProtocolPrompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using McpClient client2 = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var reusedPrompt = new McpClientPrompt(client2, promptDefinition);
|
||||||
|
|
||||||
|
var result = await reusedPrompt.GetAsync(
|
||||||
|
new Dictionary<string, object?> { ["name"] = "World" },
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.NotNull(result.Messages);
|
||||||
|
var message = result.Messages.First();
|
||||||
|
Assert.NotNull(message.Content);
|
||||||
|
var textContent = message.Content as TextContentBlock;
|
||||||
|
Assert.NotNull(textContent);
|
||||||
|
Assert.Equal("Hello, World!", textContent.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReusePromptDefinition_PreservesPromptMetadata()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var originalPrompt = prompts.First();
|
||||||
|
var promptDefinition = originalPrompt.ProtocolPrompt;
|
||||||
|
|
||||||
|
var reusedPrompt = new McpClientPrompt(client, promptDefinition);
|
||||||
|
|
||||||
|
Assert.Equal(originalPrompt.Name, reusedPrompt.Name);
|
||||||
|
Assert.Equal(originalPrompt.Description, reusedPrompt.Description);
|
||||||
|
Assert.Equal(originalPrompt.ProtocolPrompt.Name, reusedPrompt.ProtocolPrompt.Name);
|
||||||
|
Assert.Equal(originalPrompt.ProtocolPrompt.Description, reusedPrompt.ProtocolPrompt.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ManuallyConstructedPrompt_CanBeInvoked()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var manualPrompt = new Prompt
|
||||||
|
{
|
||||||
|
Name = "greeting",
|
||||||
|
Description = "Generates a greeting prompt"
|
||||||
|
};
|
||||||
|
|
||||||
|
var clientPrompt = new McpClientPrompt(client, manualPrompt);
|
||||||
|
|
||||||
|
var result = await clientPrompt.GetAsync(
|
||||||
|
new Dictionary<string, object?> { ["name"] = "Test" },
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.NotNull(result.Messages);
|
||||||
|
var message = result.Messages.First();
|
||||||
|
Assert.NotNull(message.Content);
|
||||||
|
var textContent = message.Content as TextContentBlock;
|
||||||
|
Assert.NotNull(textContent);
|
||||||
|
Assert.Equal("Hello, Test!", textContent.Text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,103 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Tests.Client;
|
||||||
|
|
||||||
|
public class McpClientResourceTemplateConstructorTests : ClientServerTestBase
|
||||||
|
{
|
||||||
|
public McpClientResourceTemplateConstructorTests(ITestOutputHelper outputHelper)
|
||||||
|
: base(outputHelper)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||||
|
{
|
||||||
|
mcpServerBuilder.WithResources<FileTemplateResources>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerResourceType]
|
||||||
|
private sealed class FileTemplateResources
|
||||||
|
{
|
||||||
|
[McpServerResource, Description("A file template")]
|
||||||
|
public static string FileTemplate([Description("The file path")] string path) => $"Content for {path}";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithValidParameters_CreatesInstance()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var templates = await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var originalTemplate = templates.First();
|
||||||
|
var templateDefinition = originalTemplate.ProtocolResourceTemplate;
|
||||||
|
|
||||||
|
var newTemplate = new McpClientResourceTemplate(client, templateDefinition);
|
||||||
|
|
||||||
|
Assert.NotNull(newTemplate);
|
||||||
|
Assert.Equal("file_template", newTemplate.Name);
|
||||||
|
Assert.Equal("A file template", newTemplate.Description);
|
||||||
|
Assert.Same(templateDefinition, newTemplate.ProtocolResourceTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WithNullClient_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
var templateDefinition = new ResourceTemplate
|
||||||
|
{
|
||||||
|
UriTemplate = "file:///{path}",
|
||||||
|
Name = "test",
|
||||||
|
Description = "Test template"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>("client", () => new McpClientResourceTemplate(null!, templateDefinition));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithNullResourceTemplate_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>("resourceTemplate", () => new McpClientResourceTemplate(client, null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReuseResourceTemplateDefinition_PreservesTemplateMetadata()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var templates = await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var originalTemplate = templates.First();
|
||||||
|
var templateDefinition = originalTemplate.ProtocolResourceTemplate;
|
||||||
|
|
||||||
|
var reusedTemplate = new McpClientResourceTemplate(client, templateDefinition);
|
||||||
|
|
||||||
|
Assert.Equal(originalTemplate.Name, reusedTemplate.Name);
|
||||||
|
Assert.Equal(originalTemplate.Description, reusedTemplate.Description);
|
||||||
|
Assert.Equal(originalTemplate.UriTemplate, reusedTemplate.UriTemplate);
|
||||||
|
Assert.Equal(originalTemplate.ProtocolResourceTemplate.Name, reusedTemplate.ProtocolResourceTemplate.Name);
|
||||||
|
Assert.Equal(originalTemplate.ProtocolResourceTemplate.Description, reusedTemplate.ProtocolResourceTemplate.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ManuallyConstructedResourceTemplate_CreatesValidInstance()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var manualTemplate = new ResourceTemplate
|
||||||
|
{
|
||||||
|
UriTemplate = "file:///{path}",
|
||||||
|
Name = "file_template",
|
||||||
|
Description = "A file template"
|
||||||
|
};
|
||||||
|
|
||||||
|
var clientTemplate = new McpClientResourceTemplate(client, manualTemplate);
|
||||||
|
|
||||||
|
Assert.NotNull(clientTemplate);
|
||||||
|
Assert.Equal("file_template", clientTemplate.Name);
|
||||||
|
Assert.Equal("A file template", clientTemplate.Description);
|
||||||
|
Assert.Equal("file:///{path}", clientTemplate.UriTemplate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,128 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.ComponentModel;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Tests.Client;
|
||||||
|
|
||||||
|
public class McpClientResourceTests : ClientServerTestBase
|
||||||
|
{
|
||||||
|
public McpClientResourceTests(ITestOutputHelper outputHelper)
|
||||||
|
: base(outputHelper)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||||
|
{
|
||||||
|
mcpServerBuilder.WithResources<SampleResources>();
|
||||||
|
}
|
||||||
|
|
||||||
|
[McpServerResourceType]
|
||||||
|
private sealed class SampleResources
|
||||||
|
{
|
||||||
|
[McpServerResource, Description("A sample resource")]
|
||||||
|
public static string Sample() => "Sample content";
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithValidParameters_CreatesInstance()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var originalResource = resources.First();
|
||||||
|
var resourceDefinition = originalResource.ProtocolResource;
|
||||||
|
|
||||||
|
var newResource = new McpClientResource(client, resourceDefinition);
|
||||||
|
|
||||||
|
Assert.NotNull(newResource);
|
||||||
|
Assert.Equal("sample", newResource.Name);
|
||||||
|
Assert.Equal("A sample resource", newResource.Description);
|
||||||
|
Assert.Same(resourceDefinition, newResource.ProtocolResource);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void Constructor_WithNullClient_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
var resourceDefinition = new Resource
|
||||||
|
{
|
||||||
|
Uri = "file:///test.txt",
|
||||||
|
Name = "test",
|
||||||
|
Description = "Test resource"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>("client", () => new McpClientResource(null!, resourceDefinition));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithNullResource_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>("resource", () => new McpClientResource(client, null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReuseResourceDefinition_AcrossDifferentClients_ReadsSuccessfully()
|
||||||
|
{
|
||||||
|
Resource resourceDefinition;
|
||||||
|
{
|
||||||
|
await using McpClient client1 = await CreateMcpClientForServer();
|
||||||
|
var resources = await client1.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var sampleResource = resources.First(r => r.Name == "sample");
|
||||||
|
resourceDefinition = sampleResource.ProtocolResource;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using McpClient client2 = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var reusedResource = new McpClientResource(client2, resourceDefinition);
|
||||||
|
|
||||||
|
var result = await reusedResource.ReadAsync(
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.NotNull(result.Contents);
|
||||||
|
var content = result.Contents.FirstOrDefault() as TextResourceContents;
|
||||||
|
Assert.NotNull(content);
|
||||||
|
Assert.Equal("Sample content", content.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReuseResourceDefinition_PreservesResourceMetadata()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var resources = await client.ListResourcesAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var originalResource = resources.First();
|
||||||
|
var resourceDefinition = originalResource.ProtocolResource;
|
||||||
|
|
||||||
|
var reusedResource = new McpClientResource(client, resourceDefinition);
|
||||||
|
|
||||||
|
Assert.Equal(originalResource.Name, reusedResource.Name);
|
||||||
|
Assert.Equal(originalResource.Description, reusedResource.Description);
|
||||||
|
Assert.Equal(originalResource.Uri, reusedResource.Uri);
|
||||||
|
Assert.Equal(originalResource.ProtocolResource.Name, reusedResource.ProtocolResource.Name);
|
||||||
|
Assert.Equal(originalResource.ProtocolResource.Description, reusedResource.ProtocolResource.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ManuallyConstructedResource_CreatesValidInstance()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var manualResource = new Resource
|
||||||
|
{
|
||||||
|
Uri = "file:///sample.txt",
|
||||||
|
Name = "sample",
|
||||||
|
Description = "A sample resource"
|
||||||
|
};
|
||||||
|
|
||||||
|
var clientResource = new McpClientResource(client, manualResource);
|
||||||
|
|
||||||
|
Assert.NotNull(clientResource);
|
||||||
|
Assert.Equal("sample", clientResource.Name);
|
||||||
|
Assert.Equal("A sample resource", clientResource.Description);
|
||||||
|
Assert.Equal("file:///sample.txt", clientResource.Uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
309
tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs
Normal file
309
tests/ModelContextProtocol.Tests/Client/McpClientToolTests.cs
Normal file
|
|
@ -0,0 +1,309 @@
|
||||||
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using ModelContextProtocol.Client;
|
||||||
|
using ModelContextProtocol.Protocol;
|
||||||
|
using ModelContextProtocol.Server;
|
||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace ModelContextProtocol.Tests.Client;
|
||||||
|
|
||||||
|
public class McpClientToolTests : ClientServerTestBase
|
||||||
|
{
|
||||||
|
public McpClientToolTests(ITestOutputHelper outputHelper)
|
||||||
|
: base(outputHelper)
|
||||||
|
{
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
|
||||||
|
{
|
||||||
|
// Add a simple echo tool for testing
|
||||||
|
mcpServerBuilder.WithTools([McpServerTool.Create((string message) => $"Echo: {message}", new() { Name = "echo", Description = "Echoes back the message" })]);
|
||||||
|
|
||||||
|
// Add a tool with parameters for testing
|
||||||
|
mcpServerBuilder.WithTools([McpServerTool.Create((int a, int b) => a + b, new() { Name = "add", Description = "Adds two numbers" })]);
|
||||||
|
|
||||||
|
// Add a tool that returns complex result
|
||||||
|
mcpServerBuilder.WithTools([McpServerTool.Create((string name, int age) => $"Person: {name}, Age: {age}", new() { Name = "createPerson", Description = "Creates a person description" })]);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithValidParameters_CreatesInstance()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
// Get a tool definition from the server
|
||||||
|
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var originalTool = tools.First(t => t.Name == "echo");
|
||||||
|
var toolDefinition = originalTool.ProtocolTool;
|
||||||
|
|
||||||
|
// Create a new McpClientTool using the public constructor
|
||||||
|
var newTool = new McpClientTool(client, toolDefinition);
|
||||||
|
|
||||||
|
Assert.NotNull(newTool);
|
||||||
|
Assert.Equal("echo", newTool.Name);
|
||||||
|
Assert.Equal("Echoes back the message", newTool.Description);
|
||||||
|
Assert.Same(toolDefinition, newTool.ProtocolTool);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithNullClient_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
var toolDefinition = new Tool
|
||||||
|
{
|
||||||
|
Name = "test",
|
||||||
|
Description = "Test tool"
|
||||||
|
};
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>("client", () => new McpClientTool(null!, toolDefinition));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithNullTool_ThrowsArgumentNullException()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
Assert.Throws<ArgumentNullException>("tool", () => new McpClientTool(client, null!));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithNullSerializerOptions_UsesDefaultOptions()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var toolDefinition = new Tool
|
||||||
|
{
|
||||||
|
Name = "test",
|
||||||
|
Description = "Test tool"
|
||||||
|
};
|
||||||
|
|
||||||
|
var tool = new McpClientTool(client, toolDefinition, serializerOptions: null);
|
||||||
|
|
||||||
|
Assert.NotNull(tool.JsonSerializerOptions);
|
||||||
|
Assert.Same(McpJsonUtilities.DefaultOptions, tool.JsonSerializerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithCustomSerializerOptions_UsesProvidedOptions()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var toolDefinition = new Tool
|
||||||
|
{
|
||||||
|
Name = "test",
|
||||||
|
Description = "Test tool"
|
||||||
|
};
|
||||||
|
|
||||||
|
var customOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||||
|
|
||||||
|
var tool = new McpClientTool(client, toolDefinition, customOptions);
|
||||||
|
|
||||||
|
Assert.NotNull(tool.JsonSerializerOptions);
|
||||||
|
Assert.Same(customOptions, tool.JsonSerializerOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReuseToolDefinition_AcrossDifferentClients_InvokesSuccessfully()
|
||||||
|
{
|
||||||
|
// Create first client and get tool definition
|
||||||
|
Tool toolDefinition;
|
||||||
|
{
|
||||||
|
await using McpClient client1 = await CreateMcpClientForServer();
|
||||||
|
var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var echoTool = tools.First(t => t.Name == "echo");
|
||||||
|
toolDefinition = echoTool.ProtocolTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create second client (simulating reconnect)
|
||||||
|
await using McpClient client2 = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
// Create new McpClientTool with cached tool definition and new client
|
||||||
|
var reusedTool = new McpClientTool(client2, toolDefinition);
|
||||||
|
|
||||||
|
// Invoke the tool using the new client
|
||||||
|
var result = await reusedTool.CallAsync(
|
||||||
|
new Dictionary<string, object?> { ["message"] = "Hello from reused tool" },
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.NotNull(result.Content);
|
||||||
|
var textContent = result.Content.FirstOrDefault() as TextContentBlock;
|
||||||
|
Assert.NotNull(textContent);
|
||||||
|
Assert.Equal("Echo: Hello from reused tool", textContent.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReuseToolDefinition_WithComplexParameters_InvokesSuccessfully()
|
||||||
|
{
|
||||||
|
// Create first client and get tool definition
|
||||||
|
Tool toolDefinition;
|
||||||
|
{
|
||||||
|
await using McpClient client1 = await CreateMcpClientForServer();
|
||||||
|
var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var addTool = tools.First(t => t.Name == "add");
|
||||||
|
toolDefinition = addTool.ProtocolTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create second client
|
||||||
|
await using McpClient client2 = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
// Create new McpClientTool with cached tool definition
|
||||||
|
var reusedTool = new McpClientTool(client2, toolDefinition);
|
||||||
|
|
||||||
|
// Invoke the tool with integer parameters
|
||||||
|
var result = await reusedTool.CallAsync(
|
||||||
|
new Dictionary<string, object?> { ["a"] = 5, ["b"] = 7 },
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.NotNull(result.Content);
|
||||||
|
var textContent = result.Content.FirstOrDefault() as TextContentBlock;
|
||||||
|
Assert.NotNull(textContent);
|
||||||
|
Assert.Equal("12", textContent.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReuseToolDefinition_PreservesToolMetadata()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var tools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var originalTool = tools.First(t => t.Name == "createPerson");
|
||||||
|
var toolDefinition = originalTool.ProtocolTool;
|
||||||
|
|
||||||
|
// Create new McpClientTool with cached tool definition
|
||||||
|
var reusedTool = new McpClientTool(client, toolDefinition);
|
||||||
|
|
||||||
|
// Verify metadata is preserved
|
||||||
|
Assert.Equal(originalTool.Name, reusedTool.Name);
|
||||||
|
Assert.Equal(originalTool.Description, reusedTool.Description);
|
||||||
|
Assert.Equal(originalTool.ProtocolTool.Name, reusedTool.ProtocolTool.Name);
|
||||||
|
Assert.Equal(originalTool.ProtocolTool.Description, reusedTool.ProtocolTool.Description);
|
||||||
|
|
||||||
|
// Verify JSON schema is preserved
|
||||||
|
Assert.Equal(
|
||||||
|
JsonSerializer.Serialize(originalTool.JsonSchema, McpJsonUtilities.DefaultOptions),
|
||||||
|
JsonSerializer.Serialize(reusedTool.JsonSchema, McpJsonUtilities.DefaultOptions));
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ManuallyConstructedTool_CanBeInvoked()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
// Manually construct a Tool object matching the server's tool
|
||||||
|
var manualTool = new Tool
|
||||||
|
{
|
||||||
|
Name = "echo",
|
||||||
|
Description = "Echoes back the message",
|
||||||
|
InputSchema = JsonDocument.Parse("""
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"message": { "type": "string" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
""").RootElement.Clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create McpClientTool with manually constructed tool
|
||||||
|
var clientTool = new McpClientTool(client, manualTool);
|
||||||
|
|
||||||
|
// Invoke the tool
|
||||||
|
var result = await clientTool.CallAsync(
|
||||||
|
new Dictionary<string, object?> { ["message"] = "Test message" },
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
Assert.NotNull(result.Content);
|
||||||
|
var textContent = result.Content.FirstOrDefault() as TextContentBlock;
|
||||||
|
Assert.NotNull(textContent);
|
||||||
|
Assert.Equal("Echo: Test message", textContent.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReuseToolDefinition_WithInvokeAsync_WorksCorrectly()
|
||||||
|
{
|
||||||
|
Tool toolDefinition;
|
||||||
|
{
|
||||||
|
await using McpClient client1 = await CreateMcpClientForServer();
|
||||||
|
var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var addTool = tools.First(t => t.Name == "add");
|
||||||
|
toolDefinition = addTool.ProtocolTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
await using McpClient client2 = await CreateMcpClientForServer();
|
||||||
|
var reusedTool = new McpClientTool(client2, toolDefinition);
|
||||||
|
|
||||||
|
// Use AIFunction.InvokeAsync (inherited method)
|
||||||
|
var result = await reusedTool.InvokeAsync(
|
||||||
|
new() { ["a"] = 10, ["b"] = 20 },
|
||||||
|
TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.NotNull(result);
|
||||||
|
|
||||||
|
// InvokeAsync returns a JsonElement containing the serialized CallToolResult
|
||||||
|
var jsonElement = Assert.IsType<JsonElement>(result);
|
||||||
|
var callToolResult = JsonSerializer.Deserialize<CallToolResult>(jsonElement, McpJsonUtilities.DefaultOptions);
|
||||||
|
|
||||||
|
Assert.NotNull(callToolResult);
|
||||||
|
Assert.NotNull(callToolResult.Content);
|
||||||
|
var textContent = callToolResult.Content.FirstOrDefault() as TextContentBlock;
|
||||||
|
Assert.NotNull(textContent);
|
||||||
|
Assert.Equal("30", textContent.Text);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Constructor_WithToolWithoutDescription_UsesEmptyDescription()
|
||||||
|
{
|
||||||
|
await using McpClient client = await CreateMcpClientForServer();
|
||||||
|
|
||||||
|
var toolWithoutDescription = new Tool
|
||||||
|
{
|
||||||
|
Name = "noDescTool",
|
||||||
|
Description = null
|
||||||
|
};
|
||||||
|
|
||||||
|
var clientTool = new McpClientTool(client, toolWithoutDescription);
|
||||||
|
|
||||||
|
Assert.Equal("noDescTool", clientTool.Name);
|
||||||
|
Assert.Equal(string.Empty, clientTool.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task ReuseToolDefinition_MultipleClients_AllWorkIndependently()
|
||||||
|
{
|
||||||
|
// Get tool definition from first client
|
||||||
|
Tool toolDefinition;
|
||||||
|
{
|
||||||
|
await using McpClient client1 = await CreateMcpClientForServer();
|
||||||
|
var tools = await client1.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
var echoTool = tools.First(t => t.Name == "echo");
|
||||||
|
toolDefinition = echoTool.ProtocolTool;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and invoke on second client
|
||||||
|
string text2;
|
||||||
|
{
|
||||||
|
await using McpClient client2 = await CreateMcpClientForServer();
|
||||||
|
var tool2 = new McpClientTool(client2, toolDefinition);
|
||||||
|
var result2 = await tool2.CallAsync(
|
||||||
|
new Dictionary<string, object?> { ["message"] = "From client 2" },
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
text2 = (result2.Content.FirstOrDefault() as TextContentBlock)?.Text ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create and invoke on third client
|
||||||
|
string text3;
|
||||||
|
{
|
||||||
|
await using McpClient client3 = await CreateMcpClientForServer();
|
||||||
|
var tool3 = new McpClientTool(client3, toolDefinition);
|
||||||
|
var result3 = await tool3.CallAsync(
|
||||||
|
new Dictionary<string, object?> { ["message"] = "From client 3" },
|
||||||
|
cancellationToken: TestContext.Current.CancellationToken);
|
||||||
|
text3 = (result3.Content.FirstOrDefault() as TextContentBlock)?.Text ?? string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify both worked
|
||||||
|
Assert.Equal("Echo: From client 2", text2);
|
||||||
|
Assert.Equal("Echo: From client 3", text3);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue