Skip to content

Commit 8f2e21d

Browse files
Copilotstephentoub
andauthored
Fix: Synthesize handlers for empty tool/prompt/resource collections (#865)
* Initial plan * Allow handlers for empty tool/prompt/resource collections Changed condition from `IsEmpty: false` to `not null` check in McpServerImpl.cs for resources (line 298), prompts (line 459), and tools (line 547). This allows handlers to be synthesized even when collections are empty, enabling dynamic addition of items after initialization. --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Stephen Toub <stoub@microsoft.com>
1 parent bfc0980 commit 8f2e21d

File tree

9 files changed

+177
-13
lines changed

9 files changed

+177
-13
lines changed

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ subscribeHandler is null && unsubscribeHandler is null && resources is null &&
292292
var subscribe = resourcesCapability?.Subscribe;
293293

294294
// Handle resources provided via DI.
295-
if (resources is { IsEmpty: false })
295+
if (resources is not null)
296296
{
297297
var originalListResourcesHandler = listResourcesHandler;
298298
listResourcesHandler = async (request, cancellationToken) =>
@@ -453,7 +453,7 @@ private void ConfigurePrompts(McpServerOptions options)
453453
var listChanged = promptsCapability?.ListChanged;
454454

455455
// Handle tools provided via DI by augmenting the handlers to incorporate them.
456-
if (prompts is { IsEmpty: false })
456+
if (prompts is not null)
457457
{
458458
var originalListPromptsHandler = listPromptsHandler;
459459
listPromptsHandler = async (request, cancellationToken) =>
@@ -541,7 +541,7 @@ private void ConfigureTools(McpServerOptions options)
541541
var listChanged = toolsCapability?.ListChanged;
542542

543543
// Handle tools provided via DI by augmenting the handlers to incorporate them.
544-
if (tools is { IsEmpty: false })
544+
if (tools is not null)
545545
{
546546
var originalListToolsHandler = listToolsHandler;
547547
listToolsHandler = async (request, cancellationToken) =>

tests/Common/Utils/MockHttpHandler.cs

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
1-
using System.Net.Http;
2-
3-
namespace ModelContextProtocol.Tests.Utils;
1+
namespace ModelContextProtocol.Tests.Utils;
42

53
public class MockHttpHandler : HttpMessageHandler
64
{

tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,17 @@
4444
<PackageReference Include="Moq" />
4545
<PackageReference Include="OpenTelemetry" />
4646
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
47-
<PackageReference Include="System.Linq.AsyncEnumerable" />
4847
<PackageReference Include="xunit.v3" />
4948
<PackageReference Include="xunit.runner.visualstudio">
5049
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
5150
<PrivateAssets>all</PrivateAssets>
5251
</PackageReference>
5352
</ItemGroup>
5453

54+
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0'))">
55+
<PackageReference Include="System.Linq.AsyncEnumerable" />
56+
</ItemGroup>
57+
5558
<ItemGroup>
5659
<ProjectReference Include="..\..\samples\TestServerWithHosting\TestServerWithHosting.csproj" />
5760
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />

tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,6 @@
99
</PropertyGroup>
1010

1111
<ItemGroup>
12-
<PackageReference Include="Microsoft.Extensions.Logging" />
13-
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
1412
<PackageReference Include="Serilog.Extensions.Logging" />
1513
<PackageReference Include="Serilog.Sinks.File" />
1614
</ItemGroup>

tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
using Moq;
88
using System.Collections;
99
using System.ComponentModel;
10-
using System.Text.Json;
1110
using System.Threading.Channels;
1211
using static ModelContextProtocol.Tests.Configuration.McpServerBuilderExtensionsPromptsTests;
1312

tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,6 @@
5252
<PackageReference Include="OpenTelemetry" />
5353
<PackageReference Include="OpenTelemetry.Exporter.InMemory" />
5454
<PackageReference Include="Serilog" />
55-
<PackageReference Include="System.Linq.AsyncEnumerable" />
5655
<PackageReference Include="JsonSchema.Net" />
5756
<PackageReference Include="xunit.v3" />
5857
<PackageReference Include="xunit.runner.visualstudio">
@@ -61,6 +60,10 @@
6160
</PackageReference>
6261
</ItemGroup>
6362

63+
<ItemGroup Condition="!$([MSBuild]::IsTargetFrameworkCompatible('$(TargetFramework)', 'net10.0'))">
64+
<PackageReference Include="System.Linq.AsyncEnumerable" />
65+
</ItemGroup>
66+
6467
<ItemGroup>
6568
<ProjectReference Include="..\..\src\ModelContextProtocol.Core\ModelContextProtocol.Core.csproj" />
6669
<ProjectReference Include="..\ModelContextProtocol.TestServer\ModelContextProtocol.TestServer.csproj" />

tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
using Microsoft.Extensions.DependencyInjection;
2-
using ModelContextProtocol.Client;
32
using ModelContextProtocol.Protocol;
43
using ModelContextProtocol.Server;
54

tests/ModelContextProtocol.Tests/Protocol/ElicitationTests.cs

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
using Microsoft.Extensions.DependencyInjection;
22
using ModelContextProtocol.Client;
33
using ModelContextProtocol.Protocol;
4-
using ModelContextProtocol.Server;
54
using System.Text.Json;
65

76
namespace ModelContextProtocol.Tests.Configuration;
Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
using Microsoft.Extensions.AI;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using ModelContextProtocol.Protocol;
4+
using ModelContextProtocol.Server;
5+
6+
namespace ModelContextProtocol.Tests.Server;
7+
8+
/// <summary>
9+
/// Tests to verify that handlers are synthesized for empty collections that can be populated dynamically.
10+
/// This addresses the issue where handlers were only created when collections had items.
11+
/// </summary>
12+
public class EmptyCollectionTests : ClientServerTestBase
13+
{
14+
public EmptyCollectionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { }
15+
16+
private McpServerResourceCollection _resourceCollection = [];
17+
private McpServerPrimitiveCollection<McpServerTool> _toolCollection = [];
18+
private McpServerPrimitiveCollection<McpServerPrompt> _promptCollection = [];
19+
20+
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) =>
21+
mcpServerBuilder.Services.Configure<McpServerOptions>(options =>
22+
{
23+
options.ResourceCollection = _resourceCollection;
24+
options.ToolCollection = _toolCollection;
25+
options.PromptCollection = _promptCollection;
26+
});
27+
28+
[Fact]
29+
public async Task EmptyResourceCollection_CanAddResourcesDynamically()
30+
{
31+
var client = await CreateMcpClientForServer();
32+
33+
// Initially, the resource collection is empty
34+
var initialResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
35+
Assert.Empty(initialResources);
36+
37+
// Add a resource dynamically
38+
_resourceCollection.Add(McpServerResource.Create(
39+
() => "test content",
40+
new() { UriTemplate = "test://resource/1" }));
41+
42+
// The resource should now be listed
43+
var updatedResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
44+
Assert.Single(updatedResources);
45+
Assert.Equal("test://resource/1", updatedResources[0].Uri);
46+
}
47+
48+
[Fact]
49+
public async Task EmptyToolCollection_CanAddToolsDynamically()
50+
{
51+
var client = await CreateMcpClientForServer();
52+
53+
// Initially, the tool collection is empty
54+
var initialTools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
55+
Assert.Empty(initialTools);
56+
57+
// Add a tool dynamically
58+
_toolCollection.Add(McpServerTool.Create(
59+
() => "test result",
60+
new() { Name = "test_tool", Description = "A test tool" }));
61+
62+
// The tool should now be listed
63+
var updatedTools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken);
64+
Assert.Single(updatedTools);
65+
Assert.Equal("test_tool", updatedTools[0].Name);
66+
}
67+
68+
[Fact]
69+
public async Task EmptyPromptCollection_CanAddPromptsDynamically()
70+
{
71+
var client = await CreateMcpClientForServer();
72+
73+
// Initially, the prompt collection is empty
74+
var initialPrompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
75+
Assert.Empty(initialPrompts);
76+
77+
// Add a prompt dynamically
78+
_promptCollection.Add(McpServerPrompt.Create(
79+
() => new ChatMessage(ChatRole.User, "test prompt"),
80+
new() { Name = "test_prompt", Description = "A test prompt" }));
81+
82+
// The prompt should now be listed
83+
var updatedPrompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken);
84+
Assert.Single(updatedPrompts);
85+
Assert.Equal("test_prompt", updatedPrompts[0].Name);
86+
}
87+
88+
[Fact]
89+
public async Task EmptyResourceCollection_CanCallReadResourceAfterAddingDynamically()
90+
{
91+
var client = await CreateMcpClientForServer();
92+
93+
// Add a resource dynamically
94+
_resourceCollection.Add(McpServerResource.Create(
95+
() => "dynamic content",
96+
new() { UriTemplate = "test://resource/dynamic" }));
97+
98+
// Read the resource
99+
var result = await client.ReadResourceAsync("test://resource/dynamic", TestContext.Current.CancellationToken);
100+
Assert.NotNull(result);
101+
Assert.Single(result.Contents);
102+
Assert.IsType<TextResourceContents>(result.Contents[0]);
103+
Assert.Equal("dynamic content", ((TextResourceContents)result.Contents[0]).Text);
104+
}
105+
106+
[Fact]
107+
public async Task EmptyToolCollection_CanCallToolAfterAddingDynamically()
108+
{
109+
var client = await CreateMcpClientForServer();
110+
111+
// Add a tool dynamically
112+
_toolCollection.Add(McpServerTool.Create(
113+
() => "dynamic result",
114+
new() { Name = "dynamic_tool", Description = "A dynamic tool" }));
115+
116+
// Call the tool
117+
var result = await client.CallToolAsync("dynamic_tool", cancellationToken: TestContext.Current.CancellationToken);
118+
Assert.NotNull(result);
119+
Assert.Single(result.Content);
120+
Assert.IsType<TextContentBlock>(result.Content[0]);
121+
Assert.Equal("dynamic result", ((TextContentBlock)result.Content[0]).Text);
122+
}
123+
124+
[Fact]
125+
public async Task EmptyPromptCollection_CanGetPromptAfterAddingDynamically()
126+
{
127+
var client = await CreateMcpClientForServer();
128+
129+
// Add a prompt dynamically
130+
_promptCollection.Add(McpServerPrompt.Create(
131+
() => new ChatMessage(ChatRole.User, "dynamic prompt content"),
132+
new() { Name = "dynamic_prompt", Description = "A dynamic prompt" }));
133+
134+
// Get the prompt
135+
var result = await client.GetPromptAsync("dynamic_prompt", cancellationToken: TestContext.Current.CancellationToken);
136+
Assert.NotNull(result);
137+
Assert.Single(result.Messages);
138+
Assert.Equal(Role.User, result.Messages[0].Role);
139+
Assert.IsType<TextContentBlock>(result.Messages[0].Content);
140+
Assert.Equal("dynamic prompt content", ((TextContentBlock)result.Messages[0].Content).Text);
141+
}
142+
}
143+
144+
/// <summary>
145+
/// Tests to verify that handlers are NOT synthesized when collections are null.
146+
/// This ensures we don't unnecessarily create capabilities when nothing is configured.
147+
/// </summary>
148+
public class NullCollectionTests : ClientServerTestBase
149+
{
150+
public NullCollectionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { }
151+
152+
[Fact]
153+
public async Task ListFails()
154+
{
155+
Assert.Null(Server.ServerOptions.Capabilities?.Resources);
156+
Assert.Null(Server.ServerOptions.Capabilities?.Tools);
157+
Assert.Null(Server.ServerOptions.Capabilities?.Prompts);
158+
159+
var client = await CreateMcpClientForServer();
160+
161+
await Assert.ThrowsAsync<McpProtocolException>(async () => await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken));
162+
await Assert.ThrowsAsync<McpProtocolException>(async () => await client.ListPromptsAsync(TestContext.Current.CancellationToken));
163+
await Assert.ThrowsAsync<McpProtocolException>(async () => await client.ListResourcesAsync(TestContext.Current.CancellationToken));
164+
}
165+
}

0 commit comments

Comments
 (0)