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;
|
||||
|
||||
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;
|
||||
ProtocolPrompt = prompt;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,30 @@ public sealed class McpClientResource
|
|||
{
|
||||
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;
|
||||
ProtocolResource = resource;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,30 @@ public sealed class McpClientResourceTemplate
|
|||
{
|
||||
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;
|
||||
ProtocolResourceTemplate = resourceTemplate;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -37,6 +37,45 @@ public sealed class McpClientTool : AIFunction
|
|||
private readonly string _description;
|
||||
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(
|
||||
McpClient client,
|
||||
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