diff --git a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs
index 45fdadd..3e8ca58 100644
--- a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs
+++ b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs
@@ -334,16 +334,16 @@ public static partial class McpServerBuilderExtensions
/// Adds instances to the service collection backing .
/// The builder instance.
- /// The instances to add to the server.
+ /// The instances to add to the server.
/// The builder provided in .
/// is .
- /// is .
- public static IMcpServerBuilder WithResources(this IMcpServerBuilder builder, IEnumerable resourcetemplates)
+ /// is .
+ public static IMcpServerBuilder WithResources(this IMcpServerBuilder builder, IEnumerable resourceTemplates)
{
Throw.IfNull(builder);
- Throw.IfNull(resourcetemplates);
+ Throw.IfNull(resourceTemplates);
- foreach (var resourceTemplate in resourcetemplates)
+ foreach (var resourceTemplate in resourceTemplates)
{
if (resourceTemplate is not null)
{
@@ -409,7 +409,7 @@ public static partial class McpServerBuilderExtensions
/// of the containing class will be constructed for each invocation of the resource.
///
///
- /// Resource templates registered through this method can be discovered by clients using the list_resourcetemplates request
+ /// Resource templates registered through this method can be discovered by clients using the list_resourceTemplates request
/// and invoked using the read_resource request.
///
///
diff --git a/src/ModelContextProtocol/Configuration/McpServerOptionsSetup.cs b/src/ModelContextProtocol/Configuration/McpServerOptionsSetup.cs
index bb26b8c..dc74010 100644
--- a/src/ModelContextProtocol/Configuration/McpServerOptionsSetup.cs
+++ b/src/ModelContextProtocol/Configuration/McpServerOptionsSetup.cs
@@ -10,10 +10,12 @@ namespace Microsoft.Extensions.DependencyInjection;
/// The server handlers configuration options.
/// Tools individually registered.
/// Prompts individually registered.
+/// Resources individually registered.
internal sealed class McpServerOptionsSetup(
IOptions serverHandlers,
IEnumerable serverTools,
- IEnumerable serverPrompts) : IConfigureOptions
+ IEnumerable serverPrompts,
+ IEnumerable serverResources) : IConfigureOptions
{
///
/// Configures the given McpServerOptions instance by setting server information
@@ -58,6 +60,23 @@ internal sealed class McpServerOptionsSetup(
options.Capabilities.Prompts.PromptCollection = promptCollection;
}
+ // Collect all of the provided resources into a resources collection. If the options already has
+ // a collection, add to it, otherwise create a new one. We want to maintain the identity
+ // of an existing collection in case someone has provided their own derived type, wants
+ // change notifications, etc.
+ McpServerPrimitiveCollection resourceCollection = options.Capabilities?.Resources?.ResourceCollection ?? [];
+ foreach (var resource in serverResources)
+ {
+ resourceCollection.TryAdd(resource);
+ }
+
+ if (!resourceCollection.IsEmpty)
+ {
+ options.Capabilities ??= new();
+ options.Capabilities.Resources ??= new();
+ options.Capabilities.Resources.ResourceCollection = resourceCollection;
+ }
+
// Apply custom server handlers.
serverHandlers.Value.OverwriteWithSetHandlers(options);
}
diff --git a/src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs b/src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs
index 4cad449..2f7e1a1 100644
--- a/src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs
+++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs
@@ -216,9 +216,6 @@ internal sealed class AIFunctionMcpServerPrompt : McpServerPrompt
ProtocolPrompt = prompt;
}
- ///
- public override string ToString() => AIFunction.ToString();
-
///
public override Prompt ProtocolPrompt { get; }
diff --git a/src/ModelContextProtocol/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol/Server/AIFunctionMcpServerResource.cs
index deb562f..19eb499 100644
--- a/src/ModelContextProtocol/Server/AIFunctionMcpServerResource.cs
+++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerResource.cs
@@ -331,9 +331,6 @@ internal sealed class AIFunctionMcpServerResource : McpServerResource
}
}
- ///
- public override string ToString() => AIFunction.ToString();
-
///
public override ResourceTemplate ProtocolResourceTemplate { get; }
diff --git a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs
index f02b300..cec6924 100644
--- a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs
+++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs
@@ -247,9 +247,6 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool
ProtocolTool = tool;
}
- ///
- public override string ToString() => AIFunction.ToString();
-
///
public override Tool ProtocolTool { get; }
diff --git a/src/ModelContextProtocol/Server/McpServer.cs b/src/ModelContextProtocol/Server/McpServer.cs
index d37fcf6..a6f8c36 100644
--- a/src/ModelContextProtocol/Server/McpServer.cs
+++ b/src/ModelContextProtocol/Server/McpServer.cs
@@ -81,26 +81,19 @@ internal sealed class McpServer : McpEndpoint, IMcpServer
// Now that everything has been configured, subscribe to any necessary notifications.
if (transport is not StreamableHttpServerTransport streamableHttpTransport || streamableHttpTransport.Stateless is false)
{
- if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools)
- {
- EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.ToolListChangedNotification);
- tools.Changed += changed;
- _disposables.Add(() => tools.Changed -= changed);
- }
+ Register(ServerOptions.Capabilities?.Tools?.ToolCollection, NotificationMethods.ToolListChangedNotification);
+ Register(ServerOptions.Capabilities?.Prompts?.PromptCollection, NotificationMethods.PromptListChangedNotification);
+ Register(ServerOptions.Capabilities?.Resources?.ResourceCollection, NotificationMethods.ResourceListChangedNotification);
- if (ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts)
+ void Register(McpServerPrimitiveCollection? collection, string notificationMethod)
+ where TPrimitive : IMcpServerPrimitive
{
- EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
- prompts.Changed += changed;
- _disposables.Add(() => prompts.Changed -= changed);
- }
-
- var resources = ServerOptions.Capabilities?.Resources?.ResourceCollection;
- if (resources is not null)
- {
- EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(NotificationMethods.PromptListChangedNotification);
- resources.Changed += changed;
- _disposables.Add(() => resources.Changed -= changed);
+ if (collection is not null)
+ {
+ EventHandler changed = (sender, e) => _ = this.SendNotificationAsync(notificationMethod);
+ collection.Changed += changed;
+ _disposables.Add(() => collection.Changed -= changed);
+ }
}
}
diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs
new file mode 100644
index 0000000..3512277
--- /dev/null
+++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs
@@ -0,0 +1,293 @@
+using Microsoft.Extensions.AI;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol.Messages;
+using ModelContextProtocol.Protocol.Types;
+using ModelContextProtocol.Server;
+using System.ComponentModel;
+using System.Threading.Channels;
+
+namespace ModelContextProtocol.Tests.Configuration;
+
+public partial class McpServerBuilderExtensionsResourcesTests : ClientServerTestBase
+{
+ public McpServerBuilderExtensionsResourcesTests(ITestOutputHelper testOutputHelper)
+ : base(testOutputHelper)
+ {
+ }
+
+ protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
+ {
+ mcpServerBuilder
+ .WithListResourcesHandler(async (request, cancellationToken) =>
+ {
+ var cursor = request.Params?.Cursor;
+ switch (cursor)
+ {
+ case null:
+ return new()
+ {
+ NextCursor = "abc",
+ Resources = [new()
+ {
+ Name = "Resource1",
+ Uri = "test://resource1",
+ }],
+ };
+
+ case "abc":
+ return new()
+ {
+ NextCursor = "def",
+ Resources = [new()
+ {
+ Name = "Resource2",
+ Uri = "test://resource2",
+ }],
+ };
+
+ case "def":
+ return new()
+ {
+ NextCursor = null,
+ Resources = [new()
+ {
+ Name = "Resource3",
+ Uri = "test://resource3",
+ }],
+ };
+
+ default:
+ throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
+ }
+ })
+ .WithListResourceTemplatesHandler(async (request, cancellationToken) =>
+ {
+ var cursor = request.Params?.Cursor;
+ switch (cursor)
+ {
+ case null:
+ return new()
+ {
+ NextCursor = "abc",
+ ResourceTemplates = [new()
+ {
+ Name = "ResourceTemplate1",
+ UriTemplate = "test://resourceTemplate/{id}",
+ }],
+ };
+ case "abc":
+ return new()
+ {
+ NextCursor = null,
+ ResourceTemplates = [new()
+ {
+ Name = "ResourceTemplate2",
+ UriTemplate = "test://resourceTemplate2/{id}",
+ }],
+ };
+ default:
+ throw new McpException($"Unexpected cursor: '{cursor}'", McpErrorCode.InvalidParams);
+ }
+ })
+ .WithReadResourceHandler(async (request, cancellationToken) =>
+ {
+ switch (request.Params?.Uri)
+ {
+ case "test://Resource1":
+ case "test://Resource2":
+ case "test://Resource3":
+ case "test://ResourceTemplate1":
+ case "test://ResourceTemplate2":
+ return new ReadResourceResult()
+ {
+ Contents = [new TextResourceContents() { Text = request.Params?.Uri ?? "(null)" }]
+ };
+ }
+
+ throw new McpException($"Resource not found: {request.Params?.Uri}");
+ })
+ .WithResources();
+ }
+
+ [Fact]
+ public void Adds_Resources_To_Server()
+ {
+ var serverOptions = ServiceProvider.GetRequiredService>().Value;
+ var resources = serverOptions?.Capabilities?.Resources?.ResourceCollection;
+ Assert.NotNull(resources);
+ Assert.NotEmpty(resources);
+ }
+
+ [Fact]
+ public async Task Can_List_And_Call_Registered_Resources()
+ {
+ await using IMcpClient client = await CreateMcpClientForServer();
+
+ Assert.NotNull(client.ServerCapabilities.Resources);
+
+ var resources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(5, resources.Count);
+
+ var resource = resources.First(t => t.Name == nameof(SimpleResources.SomeNeatDirectResource));
+ Assert.Equal("Some neat direct resource", resource.Description);
+
+ var result = await resource.ReadAsync(cancellationToken: TestContext.Current.CancellationToken);
+ Assert.NotNull(result);
+ Assert.Single(result.Contents);
+ Assert.Equal("This is a neat resource", Assert.IsType(result.Contents[0]).Text);
+ }
+
+ [Fact]
+ public async Task Can_List_And_Call_Registered_ResourceTemplates()
+ {
+ await using IMcpClient client = await CreateMcpClientForServer();
+
+ var resources = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(3, resources.Count);
+
+ var resource = resources.First(t => t.Name == nameof(SimpleResources.SomeNeatTemplatedResource));
+ Assert.Equal("Some neat resource with parameters", resource.Description);
+
+ var result = await resource.ReadAsync(new Dictionary() { ["name"] = "hello" }, cancellationToken: TestContext.Current.CancellationToken);
+ Assert.NotNull(result);
+ Assert.Single(result.Contents);
+ Assert.Equal("This is a neat resource with parameters: hello", Assert.IsType(result.Contents[0]).Text);
+ }
+
+ [Fact]
+ public async Task Can_Be_Notified_Of_Resource_Changes()
+ {
+ await using IMcpClient client = await CreateMcpClientForServer();
+
+ var resources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(5, resources.Count);
+
+ Channel listChanged = Channel.CreateUnbounded();
+ var notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
+ Assert.False(notificationRead.IsCompleted);
+
+ var serverOptions = ServiceProvider.GetRequiredService>().Value;
+ var serverResources = serverOptions.Capabilities?.Resources?.ResourceCollection;
+ Assert.NotNull(serverResources);
+
+ var newResource = McpServerResource.Create([McpServerResource(Name = "NewResource")] () => "42");
+ await using (client.RegisterNotificationHandler("notifications/resources/list_changed", (notification, cancellationToken) =>
+ {
+ listChanged.Writer.TryWrite(notification);
+ return default;
+ }))
+ {
+ serverResources.Add(newResource);
+ await notificationRead;
+
+ resources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(6, resources.Count);
+ Assert.Contains(resources, t => t.Name == "NewResource");
+
+ notificationRead = listChanged.Reader.ReadAsync(TestContext.Current.CancellationToken);
+ Assert.False(notificationRead.IsCompleted);
+ serverResources.Remove(newResource);
+ await notificationRead;
+ }
+
+ resources = await client.ListResourcesAsync(TestContext.Current.CancellationToken);
+ Assert.Equal(5, resources.Count);
+ Assert.DoesNotContain(resources, t => t.Name == "NewResource");
+ }
+
+ [Fact]
+ public async Task Throws_When_Resource_Fails()
+ {
+ await using IMcpClient client = await CreateMcpClientForServer();
+
+ await Assert.ThrowsAsync(async () => await client.ReadResourceAsync(
+ $"resource://{nameof(SimpleResources.ThrowsException)}",
+ cancellationToken: TestContext.Current.CancellationToken));
+ }
+
+ [Fact]
+ public async Task Throws_Exception_On_Unknown_Resource()
+ {
+ await using IMcpClient client = await CreateMcpClientForServer();
+
+ var e = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync(
+ "test://NotRegisteredResource",
+ cancellationToken: TestContext.Current.CancellationToken));
+
+ Assert.Contains("Resource not found", e.Message);
+ }
+
+ [Fact]
+ public void WithResources_InvalidArgs_Throws()
+ {
+ IMcpServerBuilder builder = new ServiceCollection().AddMcpServer();
+
+ Assert.Throws("resourceTemplates", () => builder.WithResources((IEnumerable)null!));
+ Assert.Throws("resourceTemplateTypes", () => builder.WithResources((IEnumerable)null!));
+
+ IMcpServerBuilder nullBuilder = null!;
+ Assert.Throws("builder", () => nullBuilder.WithResources