Skip to content

Commit b90f17b

Browse files
Copilotstephentoub
andauthored
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>
1 parent baa7d6e commit b90f17b

File tree

8 files changed

+785
-3
lines changed

8 files changed

+785
-3
lines changed

src/ModelContextProtocol.Core/Client/McpClientPrompt.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,30 @@ public sealed class McpClientPrompt
2222
{
2323
private readonly McpClient _client;
2424

25-
internal McpClientPrompt(McpClient client, Prompt prompt)
25+
/// <summary>
26+
/// Initializes a new instance of the <see cref="McpClientPrompt"/> class.
27+
/// </summary>
28+
/// <param name="client">The <see cref="McpClient"/> instance to use for invoking the prompt.</param>
29+
/// <param name="prompt">The protocol <see cref="Prompt"/> definition describing the prompt's metadata.</param>
30+
/// <remarks>
31+
/// <para>
32+
/// This constructor enables reusing cached prompt definitions across different <see cref="McpClient"/> instances
33+
/// without needing to call <see cref="McpClient.ListPromptsAsync"/> on every reconnect. This is particularly useful
34+
/// in scenarios where prompt definitions are stable and network round-trips should be minimized.
35+
/// </para>
36+
/// <para>
37+
/// The provided <paramref name="prompt"/> must represent a prompt that is actually available on the server
38+
/// associated with the <paramref name="client"/>. Attempting to invoke a prompt that doesn't exist on the
39+
/// server will result in an <see cref="McpException"/>.
40+
/// </para>
41+
/// </remarks>
42+
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
43+
/// <exception cref="ArgumentNullException"><paramref name="prompt"/> is <see langword="null"/>.</exception>
44+
public McpClientPrompt(McpClient client, Prompt prompt)
2645
{
46+
Throw.IfNull(client);
47+
Throw.IfNull(prompt);
48+
2749
_client = client;
2850
ProtocolPrompt = prompt;
2951
}

src/ModelContextProtocol.Core/Client/McpClientResource.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,30 @@ public sealed class McpClientResource
1717
{
1818
private readonly McpClient _client;
1919

20-
internal McpClientResource(McpClient client, Resource resource)
20+
/// <summary>
21+
/// Initializes a new instance of the <see cref="McpClientResource"/> class.
22+
/// </summary>
23+
/// <param name="client">The <see cref="McpClient"/> instance to use for reading the resource.</param>
24+
/// <param name="resource">The protocol <see cref="Resource"/> definition describing the resource's metadata.</param>
25+
/// <remarks>
26+
/// <para>
27+
/// This constructor enables reusing cached resource definitions across different <see cref="McpClient"/> instances
28+
/// without needing to call <see cref="McpClient.ListResourcesAsync"/> on every reconnect. This is particularly useful
29+
/// in scenarios where resource definitions are stable and network round-trips should be minimized.
30+
/// </para>
31+
/// <para>
32+
/// The provided <paramref name="resource"/> must represent a resource that is actually available on the server
33+
/// associated with the <paramref name="client"/>. Attempting to read a resource that doesn't exist on the
34+
/// server will result in an <see cref="McpException"/>.
35+
/// </para>
36+
/// </remarks>
37+
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
38+
/// <exception cref="ArgumentNullException"><paramref name="resource"/> is <see langword="null"/>.</exception>
39+
public McpClientResource(McpClient client, Resource resource)
2140
{
41+
Throw.IfNull(client);
42+
Throw.IfNull(resource);
43+
2244
_client = client;
2345
ProtocolResource = resource;
2446
}

src/ModelContextProtocol.Core/Client/McpClientResourceTemplate.cs

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,30 @@ public sealed class McpClientResourceTemplate
1717
{
1818
private readonly McpClient _client;
1919

20-
internal McpClientResourceTemplate(McpClient client, ResourceTemplate resourceTemplate)
20+
/// <summary>
21+
/// Initializes a new instance of the <see cref="McpClientResourceTemplate"/> class.
22+
/// </summary>
23+
/// <param name="client">The <see cref="McpClient"/> instance to use for reading the resource template.</param>
24+
/// <param name="resourceTemplate">The protocol <see cref="ResourceTemplate"/> definition describing the resource template's metadata.</param>
25+
/// <remarks>
26+
/// <para>
27+
/// This constructor enables reusing cached resource template definitions across different <see cref="McpClient"/> instances
28+
/// without needing to call <see cref="McpClient.ListResourceTemplatesAsync"/> on every reconnect. This is particularly useful
29+
/// in scenarios where resource template definitions are stable and network round-trips should be minimized.
30+
/// </para>
31+
/// <para>
32+
/// The provided <paramref name="resourceTemplate"/> must represent a resource template that is actually available on the server
33+
/// associated with the <paramref name="client"/>. Attempting to read a resource template that doesn't exist on the
34+
/// server will result in an <see cref="McpException"/>.
35+
/// </para>
36+
/// </remarks>
37+
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
38+
/// <exception cref="ArgumentNullException"><paramref name="resourceTemplate"/> is <see langword="null"/>.</exception>
39+
public McpClientResourceTemplate(McpClient client, ResourceTemplate resourceTemplate)
2140
{
41+
Throw.IfNull(client);
42+
Throw.IfNull(resourceTemplate);
43+
2244
_client = client;
2345
ProtocolResourceTemplate = resourceTemplate;
2446
}

src/ModelContextProtocol.Core/Client/McpClientTool.cs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,45 @@ public sealed class McpClientTool : AIFunction
3737
private readonly string _description;
3838
private readonly IProgress<ProgressNotificationValue>? _progress;
3939

40+
/// <summary>
41+
/// Initializes a new instance of the <see cref="McpClientTool"/> class.
42+
/// </summary>
43+
/// <param name="client">The <see cref="McpClient"/> instance to use for invoking the tool.</param>
44+
/// <param name="tool">The protocol <see cref="Tool"/> definition describing the tool's metadata and schema.</param>
45+
/// <param name="serializerOptions">
46+
/// The JSON serialization options governing argument serialization. If <see langword="null"/>,
47+
/// <see cref="McpJsonUtilities.DefaultOptions"/> will be used.
48+
/// </param>
49+
/// <remarks>
50+
/// <para>
51+
/// This constructor enables reusing cached tool definitions across different <see cref="McpClient"/> instances
52+
/// without needing to call <see cref="McpClient.ListToolsAsync"/> on every reconnect. This is particularly useful
53+
/// in scenarios where tool definitions are stable and network round-trips should be minimized.
54+
/// </para>
55+
/// <para>
56+
/// The provided <paramref name="tool"/> must represent a tool that is actually available on the server
57+
/// associated with the <paramref name="client"/>. Attempting to invoke a tool that doesn't exist on the
58+
/// server will result in an <see cref="McpException"/>.
59+
/// </para>
60+
/// </remarks>
61+
/// <exception cref="ArgumentNullException"><paramref name="client"/> is <see langword="null"/>.</exception>
62+
/// <exception cref="ArgumentNullException"><paramref name="tool"/> is <see langword="null"/>.</exception>
63+
public McpClientTool(
64+
McpClient client,
65+
Tool tool,
66+
JsonSerializerOptions? serializerOptions = null)
67+
{
68+
Throw.IfNull(client);
69+
Throw.IfNull(tool);
70+
71+
_client = client;
72+
ProtocolTool = tool;
73+
JsonSerializerOptions = serializerOptions ?? McpJsonUtilities.DefaultOptions;
74+
_name = tool.Name;
75+
_description = tool.Description ?? string.Empty;
76+
_progress = null;
77+
}
78+
4079
internal McpClientTool(
4180
McpClient client,
4281
Tool tool,
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
using Microsoft.Extensions.AI;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using ModelContextProtocol.Client;
4+
using ModelContextProtocol.Protocol;
5+
using ModelContextProtocol.Server;
6+
using System.ComponentModel;
7+
8+
namespace ModelContextProtocol.Tests.Client;
9+
10+
public class McpClientPromptTests : ClientServerTestBase
11+
{
12+
public McpClientPromptTests(ITestOutputHelper outputHelper)
13+
: base(outputHelper)
14+
{
15+
}
16+
17+
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
18+
{
19+
mcpServerBuilder.WithPrompts<GreetingPrompts>();
20+
}
21+
22+
[McpServerPromptType]
23+
private sealed class GreetingPrompts
24+
{
25+
[McpServerPrompt, Description("Generates a greeting prompt")]
26+
public static ChatMessage Greeting([Description("The name to greet")] string name) =>
27+
new(ChatRole.User, $"Hello, {name}!");
28+
}
29+
30+
[Fact]
31+
public async Task Constructor_WithValidParameters_CreatesInstance()
32+
{
33+
await using McpClient client = await CreateMcpClientForServer();
34+
35+
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
36+
var originalPrompt = prompts.First();
37+
var promptDefinition = originalPrompt.ProtocolPrompt;
38+
39+
var newPrompt = new McpClientPrompt(client, promptDefinition);
40+
41+
Assert.NotNull(newPrompt);
42+
Assert.Equal("greeting", newPrompt.Name);
43+
Assert.Equal("Generates a greeting prompt", newPrompt.Description);
44+
Assert.Same(promptDefinition, newPrompt.ProtocolPrompt);
45+
}
46+
47+
[Fact]
48+
public void Constructor_WithNullClient_ThrowsArgumentNullException()
49+
{
50+
var promptDefinition = new Prompt
51+
{
52+
Name = "test",
53+
Description = "Test prompt"
54+
};
55+
56+
Assert.Throws<ArgumentNullException>("client", () => new McpClientPrompt(null!, promptDefinition));
57+
}
58+
59+
[Fact]
60+
public async Task Constructor_WithNullPrompt_ThrowsArgumentNullException()
61+
{
62+
await using McpClient client = await CreateMcpClientForServer();
63+
64+
Assert.Throws<ArgumentNullException>("prompt", () => new McpClientPrompt(client, null!));
65+
}
66+
67+
[Fact]
68+
public async Task ReusePromptDefinition_AcrossDifferentClients_InvokesSuccessfully()
69+
{
70+
Prompt promptDefinition;
71+
{
72+
await using McpClient client1 = await CreateMcpClientForServer();
73+
var prompts = await client1.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
74+
var greetingPrompt = prompts.First(p => p.Name == "greeting");
75+
promptDefinition = greetingPrompt.ProtocolPrompt;
76+
}
77+
78+
await using McpClient client2 = await CreateMcpClientForServer();
79+
80+
var reusedPrompt = new McpClientPrompt(client2, promptDefinition);
81+
82+
var result = await reusedPrompt.GetAsync(
83+
new Dictionary<string, object?> { ["name"] = "World" },
84+
cancellationToken: TestContext.Current.CancellationToken);
85+
86+
Assert.NotNull(result);
87+
Assert.NotNull(result.Messages);
88+
var message = result.Messages.First();
89+
Assert.NotNull(message.Content);
90+
var textContent = message.Content as TextContentBlock;
91+
Assert.NotNull(textContent);
92+
Assert.Equal("Hello, World!", textContent.Text);
93+
}
94+
95+
[Fact]
96+
public async Task ReusePromptDefinition_PreservesPromptMetadata()
97+
{
98+
await using McpClient client = await CreateMcpClientForServer();
99+
100+
var prompts = await client.ListPromptsAsync(cancellationToken: TestContext.Current.CancellationToken);
101+
var originalPrompt = prompts.First();
102+
var promptDefinition = originalPrompt.ProtocolPrompt;
103+
104+
var reusedPrompt = new McpClientPrompt(client, promptDefinition);
105+
106+
Assert.Equal(originalPrompt.Name, reusedPrompt.Name);
107+
Assert.Equal(originalPrompt.Description, reusedPrompt.Description);
108+
Assert.Equal(originalPrompt.ProtocolPrompt.Name, reusedPrompt.ProtocolPrompt.Name);
109+
Assert.Equal(originalPrompt.ProtocolPrompt.Description, reusedPrompt.ProtocolPrompt.Description);
110+
}
111+
112+
[Fact]
113+
public async Task ManuallyConstructedPrompt_CanBeInvoked()
114+
{
115+
await using McpClient client = await CreateMcpClientForServer();
116+
117+
var manualPrompt = new Prompt
118+
{
119+
Name = "greeting",
120+
Description = "Generates a greeting prompt"
121+
};
122+
123+
var clientPrompt = new McpClientPrompt(client, manualPrompt);
124+
125+
var result = await clientPrompt.GetAsync(
126+
new Dictionary<string, object?> { ["name"] = "Test" },
127+
cancellationToken: TestContext.Current.CancellationToken);
128+
129+
Assert.NotNull(result);
130+
Assert.NotNull(result.Messages);
131+
var message = result.Messages.First();
132+
Assert.NotNull(message.Content);
133+
var textContent = message.Content as TextContentBlock;
134+
Assert.NotNull(textContent);
135+
Assert.Equal("Hello, Test!", textContent.Text);
136+
}
137+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using ModelContextProtocol.Client;
3+
using ModelContextProtocol.Protocol;
4+
using ModelContextProtocol.Server;
5+
using System.ComponentModel;
6+
7+
namespace ModelContextProtocol.Tests.Client;
8+
9+
public class McpClientResourceTemplateConstructorTests : ClientServerTestBase
10+
{
11+
public McpClientResourceTemplateConstructorTests(ITestOutputHelper outputHelper)
12+
: base(outputHelper)
13+
{
14+
}
15+
16+
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
17+
{
18+
mcpServerBuilder.WithResources<FileTemplateResources>();
19+
}
20+
21+
[McpServerResourceType]
22+
private sealed class FileTemplateResources
23+
{
24+
[McpServerResource, Description("A file template")]
25+
public static string FileTemplate([Description("The file path")] string path) => $"Content for {path}";
26+
}
27+
28+
[Fact]
29+
public async Task Constructor_WithValidParameters_CreatesInstance()
30+
{
31+
await using McpClient client = await CreateMcpClientForServer();
32+
33+
var templates = await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken);
34+
var originalTemplate = templates.First();
35+
var templateDefinition = originalTemplate.ProtocolResourceTemplate;
36+
37+
var newTemplate = new McpClientResourceTemplate(client, templateDefinition);
38+
39+
Assert.NotNull(newTemplate);
40+
Assert.Equal("file_template", newTemplate.Name);
41+
Assert.Equal("A file template", newTemplate.Description);
42+
Assert.Same(templateDefinition, newTemplate.ProtocolResourceTemplate);
43+
}
44+
45+
[Fact]
46+
public void Constructor_WithNullClient_ThrowsArgumentNullException()
47+
{
48+
var templateDefinition = new ResourceTemplate
49+
{
50+
UriTemplate = "file:///{path}",
51+
Name = "test",
52+
Description = "Test template"
53+
};
54+
55+
Assert.Throws<ArgumentNullException>("client", () => new McpClientResourceTemplate(null!, templateDefinition));
56+
}
57+
58+
[Fact]
59+
public async Task Constructor_WithNullResourceTemplate_ThrowsArgumentNullException()
60+
{
61+
await using McpClient client = await CreateMcpClientForServer();
62+
63+
Assert.Throws<ArgumentNullException>("resourceTemplate", () => new McpClientResourceTemplate(client, null!));
64+
}
65+
66+
[Fact]
67+
public async Task ReuseResourceTemplateDefinition_PreservesTemplateMetadata()
68+
{
69+
await using McpClient client = await CreateMcpClientForServer();
70+
71+
var templates = await client.ListResourceTemplatesAsync(cancellationToken: TestContext.Current.CancellationToken);
72+
var originalTemplate = templates.First();
73+
var templateDefinition = originalTemplate.ProtocolResourceTemplate;
74+
75+
var reusedTemplate = new McpClientResourceTemplate(client, templateDefinition);
76+
77+
Assert.Equal(originalTemplate.Name, reusedTemplate.Name);
78+
Assert.Equal(originalTemplate.Description, reusedTemplate.Description);
79+
Assert.Equal(originalTemplate.UriTemplate, reusedTemplate.UriTemplate);
80+
Assert.Equal(originalTemplate.ProtocolResourceTemplate.Name, reusedTemplate.ProtocolResourceTemplate.Name);
81+
Assert.Equal(originalTemplate.ProtocolResourceTemplate.Description, reusedTemplate.ProtocolResourceTemplate.Description);
82+
}
83+
84+
[Fact]
85+
public async Task ManuallyConstructedResourceTemplate_CreatesValidInstance()
86+
{
87+
await using McpClient client = await CreateMcpClientForServer();
88+
89+
var manualTemplate = new ResourceTemplate
90+
{
91+
UriTemplate = "file:///{path}",
92+
Name = "file_template",
93+
Description = "A file template"
94+
};
95+
96+
var clientTemplate = new McpClientResourceTemplate(client, manualTemplate);
97+
98+
Assert.NotNull(clientTemplate);
99+
Assert.Equal("file_template", clientTemplate.Name);
100+
Assert.Equal("A file template", clientTemplate.Description);
101+
Assert.Equal("file:///{path}", clientTemplate.UriTemplate);
102+
}
103+
}

0 commit comments

Comments
 (0)