diff --git a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs index 0be6467b..c622fd87 100644 --- a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs @@ -334,16 +334,16 @@ where t.GetCustomAttribute() is not null /// 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 IMcpServerBuilder WithResources(this IMcpServerBuilder builder, IE /// 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 bb26b8ca..dc740105 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 @@ public void Configure(McpServerOptions options) 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 a31a4a28..0e0aeb4c 100644 --- a/src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs +++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerPrompt.cs @@ -213,9 +213,6 @@ private AIFunctionMcpServerPrompt(AIFunction function, Prompt prompt) 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 be0cc84e..9fc54b57 100644 --- a/src/ModelContextProtocol/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerResource.cs @@ -328,9 +328,6 @@ private AIFunctionMcpServerResource(AIFunction function, ResourceTemplate resour } } - /// - 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 872f8868..47fda7c4 100644 --- a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs @@ -243,9 +243,6 @@ private AIFunctionMcpServerTool(AIFunction function, Tool tool) 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 16311c34..1e1aae5e 100644 --- a/src/ModelContextProtocol/Server/McpServer.cs +++ b/src/ModelContextProtocol/Server/McpServer.cs @@ -75,26 +75,19 @@ public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory? } // Now that everything has been configured, subscribe to any necessary notifications. - 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); + } } // And initialize the session. diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs new file mode 100644 index 00000000..3512277e --- /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()); + Assert.Throws("builder", () => nullBuilder.WithResources(Array.Empty())); + Assert.Throws("builder", () => nullBuilder.WithResourcesFromAssembly()); + } + + [Fact] + public void Empty_Enumerables_Is_Allowed() + { + IMcpServerBuilder builder = new ServiceCollection().AddMcpServer(); + + builder.WithResources(resourceTemplates: Array.Empty()); // no exception + builder.WithResources(resourceTemplateTypes: Array.Empty()); // no exception + builder.WithResources(); // no exception even though no resources exposed + builder.WithResourcesFromAssembly(typeof(AIFunction).Assembly); // no exception even though no resources exposed + } + + [Fact] + public void Register_Resources_From_Current_Assembly() + { + ServiceCollection sc = new(); + sc.AddMcpServer().WithResourcesFromAssembly(); + IServiceProvider services = sc.BuildServiceProvider(); + + Assert.Contains(services.GetServices(), t => t.ProtocolResource?.Uri == $"resource://{nameof(SimpleResources.SomeNeatDirectResource)}"); + Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://{nameof(SimpleResources.SomeNeatTemplatedResource)}{{?name}}"); + } + + [Fact] + public void Register_Resources_From_Multiple_Sources() + { + ServiceCollection sc = new(); + sc.AddMcpServer() + .WithResources() + .WithResources() + .WithResources([McpServerResource.Create(() => "42", new() { UriTemplate = "myResources://Returns42/{something}" })]); + IServiceProvider services = sc.BuildServiceProvider(); + + Assert.Contains(services.GetServices(), t => t.ProtocolResource?.Uri == $"resource://{nameof(SimpleResources.SomeNeatDirectResource)}"); + Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://{nameof(SimpleResources.SomeNeatTemplatedResource)}{{?name}}"); + Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://{nameof(MoreResources.AnotherNeatDirectResource)}"); + Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate.UriTemplate == "myResources://Returns42/{something}"); + } + + [McpServerResourceType] + public sealed class SimpleResources + { + [McpServerResource, Description("Some neat direct resource")] + public static string SomeNeatDirectResource() => "This is a neat resource"; + + [McpServerResource, Description("Some neat resource with parameters")] + public static string SomeNeatTemplatedResource(string name) => $"This is a neat resource with parameters: {name}"; + + [McpServerResource] + public static string ThrowsException() => throw new InvalidOperationException("uh oh"); + } + + [McpServerResourceType] + public sealed class MoreResources + { + [McpServerResource, Description("Another neat direct resource")] + public static string AnotherNeatDirectResource() => "This is a neat resource"; + } +}