diff --git a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs index b389979e6..97b92e9e2 100644 --- a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs +++ b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.cs @@ -297,6 +297,68 @@ where t.GetCustomAttribute() is not null } #endregion + #region WithResources + + private static IMcpServerBuilder AddResource( + this IMcpServerBuilder builder, + McpServerResource resource) + { + builder.Services.Configure(s => + { + var capabilities = s.Capabilities ??= new(); + var resources = capabilities.Resources ??= new(); + var collection = resources.ResourceCollection ??= []; + collection.Add(resource); + }); + + return builder; + } + + private static IMcpServerBuilder AddResources( + this IMcpServerBuilder builder, + IEnumerable resources) + { + foreach (var resource in resources) + { + builder = builder.AddResource(resource); + } + return builder; + } + + /// + /// Adds a resource to the server's capabilities. + /// + /// The builder instance. + /// The resource to add. + /// The instance. + public static IMcpServerBuilder WithResource( + this IMcpServerBuilder builder, + McpServerResource resource) + { + Throw.IfNull(builder); + Throw.IfNull(resource); + + return builder.AddResource(resource); + } + + /// + /// Adds a collection of resources to the server's capabilities. + /// + /// The builder instance. + /// The collection of the resources. + /// The instance. + public static IMcpServerBuilder WithResources( + this IMcpServerBuilder builder, + params IEnumerable resources) + { + Throw.IfNull(builder); + Throw.IfNull(resources); + + return builder.AddResources(resources); + } + + #endregion + #region Handlers /// /// Configures a handler for listing resource templates available from the Model Context Protocol server. diff --git a/src/ModelContextProtocol/Protocol/Types/IListCapability.cs b/src/ModelContextProtocol/Protocol/Types/IListCapability.cs new file mode 100644 index 000000000..2c72f053f --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/IListCapability.cs @@ -0,0 +1,24 @@ +using ModelContextProtocol.Server; +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol.Types; + +/// +/// Represents the tools capability configuration. +/// +/// The type of the primitive. +internal interface IListCapability + where TPrimitive : IMcpServerPrimitive +{ + /// + /// Gets or sets whether this server supports notifications for changes to the tool list. + /// + [JsonPropertyName("listChanged")] + public bool? ListChanged { get; set; } + + /// + /// Gets or sets the handler for list tools requests. + /// + [JsonIgnore] + public McpServerPrimitiveCollection? Collection { get; set; } +} diff --git a/src/ModelContextProtocol/Protocol/Types/PromptsCapability.cs b/src/ModelContextProtocol/Protocol/Types/PromptsCapability.cs index 53aa8043f..0d64f5308 100644 --- a/src/ModelContextProtocol/Protocol/Types/PromptsCapability.cs +++ b/src/ModelContextProtocol/Protocol/Types/PromptsCapability.cs @@ -17,7 +17,7 @@ namespace ModelContextProtocol.Protocol.Types; /// See the schema for details. /// /// -public class PromptsCapability +public class PromptsCapability : IListCapability { /// /// Gets or sets whether this server supports notifications for changes to the prompt list. @@ -80,4 +80,10 @@ public class PromptsCapability /// [JsonIgnore] public McpServerPrimitiveCollection? PromptCollection { get; set; } + + McpServerPrimitiveCollection? IListCapability.Collection + { + get => PromptCollection; + set => PromptCollection = value; + } } \ No newline at end of file diff --git a/src/ModelContextProtocol/Protocol/Types/ReadResourceRequestParams.cs b/src/ModelContextProtocol/Protocol/Types/ReadResourceRequestParams.cs index ae75d1a1e..17475f8cc 100644 --- a/src/ModelContextProtocol/Protocol/Types/ReadResourceRequestParams.cs +++ b/src/ModelContextProtocol/Protocol/Types/ReadResourceRequestParams.cs @@ -16,5 +16,5 @@ public class ReadResourceRequestParams : RequestParams /// The URI of the resource to read. The URI can use any protocol; it is up to the server how to interpret it. /// [JsonPropertyName("uri")] - public string? Uri { get; init; } + public required string Uri { get; init; } } diff --git a/src/ModelContextProtocol/Protocol/Types/ResourcesCapability.cs b/src/ModelContextProtocol/Protocol/Types/ResourcesCapability.cs index 3bb76378e..0daae147c 100644 --- a/src/ModelContextProtocol/Protocol/Types/ResourcesCapability.cs +++ b/src/ModelContextProtocol/Protocol/Types/ResourcesCapability.cs @@ -10,7 +10,7 @@ namespace ModelContextProtocol.Protocol.Types; /// /// See the schema for details. /// -public class ResourcesCapability +public class ResourcesCapability : IListCapability { /// /// Gets or sets whether this server supports subscribing to resource updates. @@ -87,4 +87,16 @@ public class ResourcesCapability /// [JsonIgnore] public Func, CancellationToken, ValueTask>? UnsubscribeFromResourcesHandler { get; set; } + + /// + /// The list of resource templates that the server supports. + /// + [JsonIgnore] + public McpServerPrimitiveCollection? ResourceCollection { get; set; } + + McpServerPrimitiveCollection? IListCapability.Collection + { + get => ResourceCollection; + set => ResourceCollection = value; + } } \ No newline at end of file diff --git a/src/ModelContextProtocol/Protocol/Types/ToolsCapability.cs b/src/ModelContextProtocol/Protocol/Types/ToolsCapability.cs index 87554398f..37e4a74b5 100644 --- a/src/ModelContextProtocol/Protocol/Types/ToolsCapability.cs +++ b/src/ModelContextProtocol/Protocol/Types/ToolsCapability.cs @@ -8,7 +8,7 @@ namespace ModelContextProtocol.Protocol.Types; /// Represents the tools capability configuration. /// See the schema for details. /// -public class ToolsCapability +public class ToolsCapability : IListCapability { /// /// Gets or sets whether this server supports notifications for changes to the tool list. @@ -61,4 +61,10 @@ public class ToolsCapability /// [JsonIgnore] public McpServerPrimitiveCollection? ToolCollection { get; set; } + + McpServerPrimitiveCollection? IListCapability.Collection + { + get => ToolCollection; + set => ToolCollection = value; + } } \ No newline at end of file diff --git a/src/ModelContextProtocol/Server/McpServer.cs b/src/ModelContextProtocol/Server/McpServer.cs index ae0e7afc5..99e3adfc7 100644 --- a/src/ModelContextProtocol/Server/McpServer.cs +++ b/src/ModelContextProtocol/Server/McpServer.cs @@ -23,9 +23,6 @@ internal sealed class McpServer : McpEndpoint, IMcpServer private readonly ITransport _sessionTransport; private readonly bool _servicesScopePerRequest; - private readonly EventHandler? _toolsChangedDelegate; - private readonly EventHandler? _promptsChangedDelegate; - private string _endpointName; private int _started; @@ -35,6 +32,7 @@ internal sealed class McpServer : McpEndpoint, IMcpServer /// rather than a nullable to be able to manipulate it atomically. /// private StrongBox? _loggingLevel; + private readonly List _disposables = []; /// /// Creates a new instance of . @@ -68,32 +66,17 @@ public McpServer(ITransport transport, McpServerOptions options, ILoggerFactory? SetCompletionHandler(options); SetPingHandler(); + var capabilities = options.Capabilities; + // Register any notification handlers that were provided. - if (options.Capabilities?.NotificationHandlers is { } notificationHandlers) + if (capabilities?.NotificationHandlers is { } notificationHandlers) { NotificationHandlers.RegisterRange(notificationHandlers); } - - // Now that everything has been configured, subscribe to any necessary notifications. - if (ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools) - { - _toolsChangedDelegate = delegate - { - _ = SendMessageAsync(new JsonRpcNotification() { Method = NotificationMethods.ToolListChangedNotification }); - }; - - tools.Changed += _toolsChangedDelegate; - } - - if (ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts) - { - _promptsChangedDelegate = delegate - { - _ = SendMessageAsync(new JsonRpcNotification() { Method = NotificationMethods.PromptListChangedNotification }); - }; - - prompts.Changed += _promptsChangedDelegate; - } + + RegisterListChange(capabilities?.Tools, NotificationMethods.ToolListChangedNotification); + RegisterListChange(capabilities?.Prompts, NotificationMethods.PromptListChangedNotification); + RegisterListChange(capabilities?.Resources, NotificationMethods.ResourceListChangedNotification); // And initialize the session. InitializeSession(transport); @@ -140,18 +123,11 @@ public async Task RunAsync(CancellationToken cancellationToken = default) public override async ValueTask DisposeUnsynchronizedAsync() { - if (_toolsChangedDelegate is not null && - ServerOptions.Capabilities?.Tools?.ToolCollection is { } tools) - { - tools.Changed -= _toolsChangedDelegate; - } - - if (_promptsChangedDelegate is not null && - ServerOptions.Capabilities?.Prompts?.PromptCollection is { } prompts) + foreach (var disposable in _disposables) { - prompts.Changed -= _promptsChangedDelegate; + disposable(); } - + _disposables.Clear(); await base.DisposeUnsynchronizedAsync().ConfigureAwait(false); } @@ -216,9 +192,26 @@ private void SetResourcesHandler(McpServerOptions options) var listResourcesHandler = resourcesCapability.ListResourcesHandler; var listResourceTemplatesHandler = resourcesCapability.ListResourceTemplatesHandler; + var readResourceHandler = resourcesCapability.ReadResourceHandler; + var resourceCollection = resourcesCapability.ResourceCollection; - if ((listResourcesHandler is not { } && listResourceTemplatesHandler is not { }) || - resourcesCapability.ReadResourceHandler is not { } readResourceHandler) + var originalListResourcesHandler = listResourcesHandler; + listResourcesHandler = async (request, cancellationToken) => + { + ListResourcesResult result = originalListResourcesHandler is not null ? + await originalListResourcesHandler(request, cancellationToken).ConfigureAwait(false) : + new(); + + if (request.Params?.Cursor is null && resourceCollection is not null) + { + result.Resources.AddRange(resourceCollection.Select(t => t.ProtocolResource)); + } + + return result; + }; + + var isMissingListResourceHandlers = originalListResourcesHandler is null && listResourceTemplatesHandler is null; + if (resourceCollection is not { IsEmpty: false } && (isMissingListResourceHandlers || readResourceHandler is not { })) { throw new InvalidOperationException( $"{nameof(ServerCapabilities)}.{nameof(ServerCapabilities.Resources)} was enabled, " + @@ -233,6 +226,7 @@ private void SetResourcesHandler(McpServerOptions options) McpJsonUtilities.JsonContext.Default.ListResourcesRequestParams, McpJsonUtilities.JsonContext.Default.ListResourcesResult); + readResourceHandler ??= static async (_, _) => new(); SetHandler( RequestMethods.ResourcesRead, readResourceHandler, @@ -551,6 +545,21 @@ private void SetHandler( requestTypeInfo, responseTypeInfo); } + private void RegisterListChange(IListCapability? capability, string methodName) + where T : IMcpServerPrimitive + { + // https://modelcontextprotocol.io/specification/2024-11-05/server/tools#capabilities + // Look to spec for guidance on ListChanged over collection existance. + if (capability?.Collection is { } collection) + //&& capability.ListChanged is true) + { + void ChangedDelegate(object? sender, EventArgs e) + => _ = this.SendNotificationAsync(methodName); + collection.Changed += ChangedDelegate; + _disposables.Add(() => collection.Changed -= ChangedDelegate); + } + } + /// Maps a to a . internal static LoggingLevel ToLoggingLevel(LogLevel level) => level switch diff --git a/src/ModelContextProtocol/Server/McpServerResource.cs b/src/ModelContextProtocol/Server/McpServerResource.cs new file mode 100644 index 000000000..541166ec7 --- /dev/null +++ b/src/ModelContextProtocol/Server/McpServerResource.cs @@ -0,0 +1,28 @@ +using Microsoft.Extensions.FileProviders; +using ModelContextProtocol.Protocol.Types; + +namespace ModelContextProtocol.Server; + +/// +/// Represents a resource that the server supports. +/// +public abstract class McpServerResource : IMcpServerPrimitive +{ + /// + /// The resource instance. + /// + public abstract required Resource ProtocolResource { get; init; } + + /// + public string Name => ProtocolResource.Name; + + /// + /// Gets the resource URI. + /// + /// The request context. + /// The cancellation token. + /// The file info of the resource. + public abstract Task GetFileInfoAsync( + RequestContext request, + CancellationToken cancellationToken = default); +} \ No newline at end of file diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs new file mode 100644 index 000000000..a26e05b86 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -0,0 +1,136 @@ +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.FileProviders; +using Microsoft.Extensions.Options; +using ModelContextProtocol.Client; +using ModelContextProtocol.Protocol.Messages; +using ModelContextProtocol.Protocol.Types; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Configuration; + +public class McpServerBuilderExtensionsResourcesTests(ITestOutputHelper testOutputHelper) + : ClientServerTestBase(testOutputHelper) +{ + protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder) + { + mcpServerBuilder = mcpServerBuilder.WithResources( + new FakeMcpServerResource() + { + ProtocolResource = new() + { + Name = "test", + Uri = "test.txt", + }, + }, + new FakeMcpServerResource() + { + ProtocolResource = new() + { + Name = "test2", + Uri = "test2.txt", + }, + }); + base.ConfigureServices(services, mcpServerBuilder); + } + + private class FakeFileInfo : IFileInfo + { + public string Name { get; set; } = "test.txt"; + public long Length { get; set; } = 0; + public bool Exists => true; + + public string? PhysicalPath { get; set; } = "test.txt"; + + public DateTimeOffset LastModified { get; set; } = DateTimeOffset.UtcNow; + + public bool IsDirectory => false; + + public Stream CreateReadStream() => new MemoryStream(); + } + + private class FakeMcpServerResource : McpServerResource + { + public override required Resource ProtocolResource { get; init; } = new() + { + Name = "test", + Uri = "test.txt", + }; + + public override Task GetFileInfoAsync( + RequestContext request, + CancellationToken cancellationToken = default) + { + return Task.FromResult(new FakeFileInfo() + { + Name = request?.Params?.Uri ?? "test.txt", + PhysicalPath = request?.Params?.Uri ?? "test.txt", + Length = 0, + }); + } + } + + [Fact] + public void Adds_Resources_To_Server() + { + var serverOptions = ServiceProvider.GetRequiredService>().Value; + var resources = serverOptions?.Capabilities?.Resources?.ResourceCollection; + Assert.NotNull(resources); + Assert.Equal(2, resources.Count); + Assert.Equal("test", resources["test"].ProtocolResource.Name); + Assert.Equal("test2", resources["test2"].ProtocolResource.Name); + } + + [Fact] + public async Task Can_List_Resources() + { + // Arrange + var token = TestContext.Current.CancellationToken; + var client = await CreateMcpClientForServer(); + + // Act + var resources = await client.ListResourcesAsync(token); + + // Assert + Assert.NotNull(resources); + Assert.Equal(2, resources.Count); + } + + [Fact] + public async Task Can_Be_Notified_Of_ResourceList_Changes() + { + // Arrange + var token = TestContext.Current.CancellationToken; + var client = await CreateMcpClientForServer(); + var serverOptions = ServiceProvider + .GetRequiredService>() + .Value; + TaskCompletionSource changeReceived = new(); + await using var _ = client.RegisterNotificationHandler( + NotificationMethods.ResourceListChangedNotification, + (notification, token) => + { + changeReceived.SetResult(notification); + return default; + }); + + // Act + var resources = await client.ListResourcesAsync(token); + Assert.NotNull(resources); + Assert.Equal(2, resources.Count); + + serverOptions?.Capabilities?.Resources?.ResourceCollection?.Add(new FakeMcpServerResource + { + ProtocolResource = new() + { + Name = "new resource", + Uri = "test3.txt", + }, + }); + + // Assert + await changeReceived.Task.WaitAsync(TimeSpan.FromSeconds(3), token); + var updatedResources = await client.ListResourcesAsync(token); + Assert.NotNull(updatedResources); + Assert.Equal(3, updatedResources.Count); + } +}