From c8cb71c7e00ca95fc459b3ae77c347b8cdce8446 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 14 Oct 2025 17:37:34 +0000 Subject: [PATCH 1/2] Initial plan From ff6716836e6e4dd5969e7eeac07f789b5d2f64d3 Mon Sep 17 00:00:00 2001 From: Stephen Toub Date: Wed, 15 Oct 2025 11:32:09 -0400 Subject: [PATCH 2/2] 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. --- .../Server/McpServerImpl.cs | 6 +- tests/Common/Utils/MockHttpHandler.cs | 4 +- ...delContextProtocol.AspNetCore.Tests.csproj | 5 +- .../ModelContextProtocol.TestSseServer.csproj | 2 - ...cpServerBuilderExtensionsResourcesTests.cs | 1 - .../ModelContextProtocol.Tests.csproj | 5 +- .../Protocol/CancellationTests.cs | 1 - .../Protocol/ElicitationTests.cs | 1 - .../Server/EmptyCollectionTests.cs | 165 ++++++++++++++++++ 9 files changed, 177 insertions(+), 13 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/EmptyCollectionTests.cs diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index 8e264741b..a4a3d6910 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -295,7 +295,7 @@ subscribeHandler is null && unsubscribeHandler is null && resources is null && var subscribe = resourcesCapability?.Subscribe; // Handle resources provided via DI. - if (resources is { IsEmpty: false }) + if (resources is not null) { var originalListResourcesHandler = listResourcesHandler; listResourcesHandler = async (request, cancellationToken) => @@ -456,7 +456,7 @@ private void ConfigurePrompts(McpServerOptions options) var listChanged = promptsCapability?.ListChanged; // Handle tools provided via DI by augmenting the handlers to incorporate them. - if (prompts is { IsEmpty: false }) + if (prompts is not null) { var originalListPromptsHandler = listPromptsHandler; listPromptsHandler = async (request, cancellationToken) => @@ -544,7 +544,7 @@ private void ConfigureTools(McpServerOptions options) var listChanged = toolsCapability?.ListChanged; // Handle tools provided via DI by augmenting the handlers to incorporate them. - if (tools is { IsEmpty: false }) + if (tools is not null) { var originalListToolsHandler = listToolsHandler; listToolsHandler = async (request, cancellationToken) => diff --git a/tests/Common/Utils/MockHttpHandler.cs b/tests/Common/Utils/MockHttpHandler.cs index d15ec3dc0..5e58a6cd5 100644 --- a/tests/Common/Utils/MockHttpHandler.cs +++ b/tests/Common/Utils/MockHttpHandler.cs @@ -1,6 +1,4 @@ -using System.Net.Http; - -namespace ModelContextProtocol.Tests.Utils; +namespace ModelContextProtocol.Tests.Utils; public class MockHttpHandler : HttpMessageHandler { diff --git a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj index 7adeb03d3..5d21d0a0a 100644 --- a/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj +++ b/tests/ModelContextProtocol.AspNetCore.Tests/ModelContextProtocol.AspNetCore.Tests.csproj @@ -44,7 +44,6 @@ - runtime; build; native; contentfiles; analyzers; buildtransitive @@ -52,6 +51,10 @@ + + + + diff --git a/tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj b/tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj index 3296ff481..e2a1c16c2 100644 --- a/tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj +++ b/tests/ModelContextProtocol.TestSseServer/ModelContextProtocol.TestSseServer.csproj @@ -9,8 +9,6 @@ - - diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 7d037fb2d..c2e2b9d51 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -7,7 +7,6 @@ using Moq; using System.Collections; using System.ComponentModel; -using System.Text.Json; using System.Threading.Channels; using static ModelContextProtocol.Tests.Configuration.McpServerBuilderExtensionsPromptsTests; diff --git a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj index 944eec4d5..425944624 100644 --- a/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj +++ b/tests/ModelContextProtocol.Tests/ModelContextProtocol.Tests.csproj @@ -52,7 +52,6 @@ - @@ -61,6 +60,10 @@ + + + + diff --git a/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs index 80c6b1ed9..e21f1f952 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/CancellationTests.cs @@ -1,5 +1,4 @@ using Microsoft.Extensions.DependencyInjection; -using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; using ModelContextProtocol.Server; diff --git a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTests.cs b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTests.cs index f3ae33ed5..76f967bed 100644 --- a/tests/ModelContextProtocol.Tests/Protocol/ElicitationTests.cs +++ b/tests/ModelContextProtocol.Tests/Protocol/ElicitationTests.cs @@ -1,7 +1,6 @@ using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; -using ModelContextProtocol.Server; using System.Text.Json; namespace ModelContextProtocol.Tests.Configuration; diff --git a/tests/ModelContextProtocol.Tests/Server/EmptyCollectionTests.cs b/tests/ModelContextProtocol.Tests/Server/EmptyCollectionTests.cs new file mode 100644 index 000000000..882fd5045 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/EmptyCollectionTests.cs @@ -0,0 +1,165 @@ +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +/// +/// Tests to verify that handlers are synthesized for empty collections that can be populated dynamically. +/// This addresses the issue where handlers were only created when collections had items. +/// +public class EmptyCollectionTests : ClientServerTestBase +{ + public EmptyCollectionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { } + + private McpServerResourceCollection _resourceCollection = []; + private McpServerPrimitiveCollection _toolCollection = []; + private McpServerPrimitiveCollection _promptCollection = []; + + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) => + mcpServerBuilder.Services.Configure(options => + { + options.ResourceCollection = _resourceCollection; + options.ToolCollection = _toolCollection; + options.PromptCollection = _promptCollection; + }); + + [Fact] + public async Task EmptyResourceCollection_CanAddResourcesDynamically() + { + var client = await CreateMcpClientForServer(); + + // Initially, the resource collection is empty + var initialResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + Assert.Empty(initialResources); + + // Add a resource dynamically + _resourceCollection.Add(McpServerResource.Create( + () => "test content", + new() { UriTemplate = "test://resource/1" })); + + // The resource should now be listed + var updatedResources = await client.ListResourcesAsync(TestContext.Current.CancellationToken); + Assert.Single(updatedResources); + Assert.Equal("test://resource/1", updatedResources[0].Uri); + } + + [Fact] + public async Task EmptyToolCollection_CanAddToolsDynamically() + { + var client = await CreateMcpClientForServer(); + + // Initially, the tool collection is empty + var initialTools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Empty(initialTools); + + // Add a tool dynamically + _toolCollection.Add(McpServerTool.Create( + () => "test result", + new() { Name = "test_tool", Description = "A test tool" })); + + // The tool should now be listed + var updatedTools = await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken); + Assert.Single(updatedTools); + Assert.Equal("test_tool", updatedTools[0].Name); + } + + [Fact] + public async Task EmptyPromptCollection_CanAddPromptsDynamically() + { + var client = await CreateMcpClientForServer(); + + // Initially, the prompt collection is empty + var initialPrompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken); + Assert.Empty(initialPrompts); + + // Add a prompt dynamically + _promptCollection.Add(McpServerPrompt.Create( + () => new ChatMessage(ChatRole.User, "test prompt"), + new() { Name = "test_prompt", Description = "A test prompt" })); + + // The prompt should now be listed + var updatedPrompts = await client.ListPromptsAsync(TestContext.Current.CancellationToken); + Assert.Single(updatedPrompts); + Assert.Equal("test_prompt", updatedPrompts[0].Name); + } + + [Fact] + public async Task EmptyResourceCollection_CanCallReadResourceAfterAddingDynamically() + { + var client = await CreateMcpClientForServer(); + + // Add a resource dynamically + _resourceCollection.Add(McpServerResource.Create( + () => "dynamic content", + new() { UriTemplate = "test://resource/dynamic" })); + + // Read the resource + var result = await client.ReadResourceAsync("test://resource/dynamic", TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Single(result.Contents); + Assert.IsType(result.Contents[0]); + Assert.Equal("dynamic content", ((TextResourceContents)result.Contents[0]).Text); + } + + [Fact] + public async Task EmptyToolCollection_CanCallToolAfterAddingDynamically() + { + var client = await CreateMcpClientForServer(); + + // Add a tool dynamically + _toolCollection.Add(McpServerTool.Create( + () => "dynamic result", + new() { Name = "dynamic_tool", Description = "A dynamic tool" })); + + // Call the tool + var result = await client.CallToolAsync("dynamic_tool", cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Single(result.Content); + Assert.IsType(result.Content[0]); + Assert.Equal("dynamic result", ((TextContentBlock)result.Content[0]).Text); + } + + [Fact] + public async Task EmptyPromptCollection_CanGetPromptAfterAddingDynamically() + { + var client = await CreateMcpClientForServer(); + + // Add a prompt dynamically + _promptCollection.Add(McpServerPrompt.Create( + () => new ChatMessage(ChatRole.User, "dynamic prompt content"), + new() { Name = "dynamic_prompt", Description = "A dynamic prompt" })); + + // Get the prompt + var result = await client.GetPromptAsync("dynamic_prompt", cancellationToken: TestContext.Current.CancellationToken); + Assert.NotNull(result); + Assert.Single(result.Messages); + Assert.Equal(Role.User, result.Messages[0].Role); + Assert.IsType(result.Messages[0].Content); + Assert.Equal("dynamic prompt content", ((TextContentBlock)result.Messages[0].Content).Text); + } +} + +/// +/// Tests to verify that handlers are NOT synthesized when collections are null. +/// This ensures we don't unnecessarily create capabilities when nothing is configured. +/// +public class NullCollectionTests : ClientServerTestBase +{ + public NullCollectionTests(ITestOutputHelper testOutputHelper) : base(testOutputHelper) { } + + [Fact] + public async Task ListFails() + { + Assert.Null(Server.ServerOptions.Capabilities?.Resources); + Assert.Null(Server.ServerOptions.Capabilities?.Tools); + Assert.Null(Server.ServerOptions.Capabilities?.Prompts); + + var client = await CreateMcpClientForServer(); + + await Assert.ThrowsAsync(async () => await client.ListToolsAsync(cancellationToken: TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.ListPromptsAsync(TestContext.Current.CancellationToken)); + await Assert.ThrowsAsync(async () => await client.ListResourcesAsync(TestContext.Current.CancellationToken)); + } +}