diff --git a/src/ModelContextProtocol/Client/McpClientExtensions.cs b/src/ModelContextProtocol/Client/McpClientExtensions.cs index 9c2ca38b8..56b77f692 100644 --- a/src/ModelContextProtocol/Client/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Client/McpClientExtensions.cs @@ -139,6 +139,44 @@ public static Task GetPromptAsync(this IMcpClient client, strin cancellationToken); } + /// + /// Retrieves a sequence of available resource templates from the server. + /// + /// The client. + /// A token to cancel the operation. + /// An asynchronous sequence of resource template information. + public static async IAsyncEnumerable ListResourceTemplatesAsync( + this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + string? cursor = null; + do + { + var resources = await ListResourceTemplatesAsync(client, cursor, cancellationToken).ConfigureAwait(false); + foreach (var resource in resources.ResourceTemplates) + { + yield return resource; + } + + cursor = resources.NextCursor; + } + while (cursor is not null); + } + + /// + /// Retrieves a list of available resources from the server. + /// + /// The client. + /// A cursor to paginate the results. + /// A token to cancel the operation. + public static Task ListResourceTemplatesAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default) + { + Throw.IfNull(client); + + return client.SendRequestAsync( + CreateRequest("resources/templates/list", CreateCursorDictionary(cursor)), + cancellationToken); + } + /// /// Retrieves a sequence of available resources from the server. /// diff --git a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.Handler.cs b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.Handler.cs index 6e88992ac..3612b925e 100644 --- a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.Handler.cs +++ b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.Handler.cs @@ -11,6 +11,19 @@ namespace ModelContextProtocol; /// public static partial class McpServerBuilderExtensions { + /// + /// Sets the handler for list resource templates requests. + /// + /// The builder instance. + /// The handler. + public static IMcpServerBuilder WithListResourceTemplatesHandler(this IMcpServerBuilder builder, Func, CancellationToken, Task> handler) + { + Throw.IfNull(builder); + + builder.Services.Configure(s => s.ListResourceTemplatesHandler = handler); + return builder; + } + /// /// Sets the handler for list tools requests. /// diff --git a/src/ModelContextProtocol/Protocol/Types/Capabilities.cs b/src/ModelContextProtocol/Protocol/Types/Capabilities.cs index 600e20f2a..3392f57c1 100644 --- a/src/ModelContextProtocol/Protocol/Types/Capabilities.cs +++ b/src/ModelContextProtocol/Protocol/Types/Capabilities.cs @@ -110,6 +110,12 @@ public record ResourcesCapability [JsonPropertyName("listChanged")] public bool? ListChanged { get; init; } + /// + /// Gets or sets the handler for list resource templates requests. + /// + [JsonIgnore] + public Func, CancellationToken, Task>? ListResourceTemplatesHandler { get; init; } + /// /// Gets or sets the handler for list resources requests. /// diff --git a/src/ModelContextProtocol/Protocol/Types/ListResourceTemplatesRequestParams.cs b/src/ModelContextProtocol/Protocol/Types/ListResourceTemplatesRequestParams.cs new file mode 100644 index 000000000..4b4eecf90 --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/ListResourceTemplatesRequestParams.cs @@ -0,0 +1,15 @@ +namespace ModelContextProtocol.Protocol.Types; + +/// +/// Sent from the client to request a list of resource templates the server has. +/// See the schema for details +/// +public class ListResourceTemplatesRequestParams +{ + /// + /// An opaque token representing the current pagination position. + /// If provided, the server should return results starting after this cursor. + /// + [System.Text.Json.Serialization.JsonPropertyName("cursor")] + public string? Cursor { get; init; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Protocol/Types/ListResourceTemplatesResult.cs b/src/ModelContextProtocol/Protocol/Types/ListResourceTemplatesResult.cs new file mode 100644 index 000000000..d2337923b --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/ListResourceTemplatesResult.cs @@ -0,0 +1,16 @@ +using ModelContextProtocol.Protocol.Messages; + +namespace ModelContextProtocol.Protocol.Types; + +/// +/// The server's response to a resources/templates/list request from the client. +/// See the schema for details +/// +public class ListResourceTemplatesResult : PaginatedResult +{ + /// + /// A list of resource templates that the server offers. + /// + [System.Text.Json.Serialization.JsonPropertyName("resourceTemplates")] + public List ResourceTemplates { get; set; } = []; +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Protocol/Types/ResourceTemplate.cs b/src/ModelContextProtocol/Protocol/Types/ResourceTemplate.cs new file mode 100644 index 000000000..b32822a21 --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/ResourceTemplate.cs @@ -0,0 +1,42 @@ +using ModelContextProtocol.Protocol.Types; + +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol.Types; + +/// +/// Represents a known resource template that the server is capable of reading. +/// See the schema for details +/// +public record ResourceTemplate +{ + /// + /// The URI template (according to RFC 6570) that can be used to construct resource URIs. + /// + [JsonPropertyName("uriTemplate")] + public required string UriTemplate { get; init; } + + /// + /// A human-readable name for this resource template. + /// + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// + /// A description of what this resource template represents. + /// + [JsonPropertyName("description")] + public string? Description { get; init; } + + /// + /// The MIME type of this resource template, if known. + /// + [JsonPropertyName("mimeType")] + public string? MimeType { get; init; } + + /// + /// Optional annotations for the resource template. + /// + [JsonPropertyName("annotations")] + public Annotations? Annotations { get; init; } +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Server/McpServer.cs b/src/ModelContextProtocol/Server/McpServer.cs index 5e813fefd..6ad1defe1 100644 --- a/src/ModelContextProtocol/Server/McpServer.cs +++ b/src/ModelContextProtocol/Server/McpServer.cs @@ -155,6 +155,12 @@ private void SetResourcesHandler(McpServerOptions options) SetRequestHandler("resources/list", (request, ct) => listResourcesHandler(new(this, request), ct)); SetRequestHandler("resources/read", (request, ct) => readResourceHandler(new(this, request), ct)); + // Set the list resource templates handler, or use the default if not specified + var listResourceTemplatesHandler = resourcesCapability.ListResourceTemplatesHandler + ?? (static (_, _) => Task.FromResult(new ListResourceTemplatesResult())); + + SetRequestHandler("resources/templates/list", (request, ct) => listResourceTemplatesHandler(new(this, request), ct)); + if (resourcesCapability.Subscribe is not true) { return; diff --git a/src/ModelContextProtocol/Server/McpServerHandlers.cs b/src/ModelContextProtocol/Server/McpServerHandlers.cs index 56327339f..c41c601e2 100644 --- a/src/ModelContextProtocol/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol/Server/McpServerHandlers.cs @@ -27,6 +27,11 @@ public sealed class McpServerHandlers /// public Func, CancellationToken, Task>? GetPromptHandler { get; set; } + /// + /// Gets or sets the handler for list resource templates requests. + /// + public Func, CancellationToken, Task>? ListResourceTemplatesHandler { get; set; } + /// /// Gets or sets the handler for list resources requests. /// @@ -82,11 +87,13 @@ promptsCapability with resourcesCapability = resourcesCapability is null ? new() { + ListResourceTemplatesHandler = ListResourceTemplatesHandler, ListResourcesHandler = ListResourcesHandler, ReadResourceHandler = ReadResourceHandler } : resourcesCapability with { + ListResourceTemplatesHandler = ListResourceTemplatesHandler ?? resourcesCapability.ListResourceTemplatesHandler, ListResourcesHandler = ListResourcesHandler ?? resourcesCapability.ListResourcesHandler, ReadResourceHandler = ReadResourceHandler ?? resourcesCapability.ReadResourceHandler }; diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index a59e1ec7b..8574a73a7 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -313,6 +313,20 @@ private static ResourcesCapability ConfigureResources() return new() { + ListResourceTemplatesHandler = (request, cancellationToken) => + { + return Task.FromResult(new ListResourceTemplatesResult() + { + ResourceTemplates = [ + new ResourceTemplate() + { + UriTemplate = "test://dynamic/resource/{id}", + Name = "Dynamic Resource", + } + ] + }); + }, + ListResourcesHandler = (request, cancellationToken) => { int startIndex = 0; @@ -349,6 +363,27 @@ private static ResourcesCapability ConfigureResources() { throw new McpServerException("Missing required argument 'uri'"); } + + if (request.Params.Uri.StartsWith("test://dynamic/resource/")) + { + var id = request.Params.Uri.Split('/').LastOrDefault(); + if (string.IsNullOrEmpty(id)) + { + throw new McpServerException("Invalid resource URI"); + } + return Task.FromResult(new ReadResourceResult() + { + Contents = [ + new ResourceContents() + { + Uri = request.Params.Uri, + MimeType = "text/plain", + Text = $"Dynamic resource {id}: This is a plaintext resource" + } + ] + }); + } + ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) ?? throw new McpServerException("Resource not found"); @@ -364,7 +399,8 @@ private static ResourcesCapability ConfigureResources() { throw new McpServerException("Missing required argument 'uri'"); } - if (!request.Params.Uri.StartsWith("test://static/resource/")) + if (!request.Params.Uri.StartsWith("test://static/resource/") + && !request.Params.Uri.StartsWith("test://dynamic/resource/")) { throw new McpServerException("Invalid resource URI"); } @@ -383,7 +419,8 @@ private static ResourcesCapability ConfigureResources() { throw new McpServerException("Missing required argument 'uri'"); } - if (!request.Params.Uri.StartsWith("test://static/resource/")) + if (!request.Params.Uri.StartsWith("test://static/resource/") + && !request.Params.Uri.StartsWith("test://dynamic/resource/")) { throw new McpServerException("Invalid resource URI"); } diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index 7e9f5e44b..712f845f3 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -200,6 +200,21 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st }, Resources = new() { + ListResourceTemplatesHandler = (request, cancellationToken) => + { + + return Task.FromResult(new ListResourceTemplatesResult() + { + ResourceTemplates = [ + new ResourceTemplate() + { + UriTemplate = "test://dynamic/resource/{id}", + Name = "Dynamic Resource", + } + ] + }); + }, + ListResourcesHandler = (request, cancellationToken) => { int startIndex = 0; @@ -236,7 +251,27 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st { throw new McpServerException("Missing required argument 'uri'"); } - + + if (request.Params.Uri.StartsWith("test://dynamic/resource/")) + { + var id = request.Params.Uri.Split('/').LastOrDefault(); + if (string.IsNullOrEmpty(id)) + { + throw new McpServerException("Invalid resource URI"); + } + return Task.FromResult(new ReadResourceResult() + { + Contents = [ + new ResourceContents() + { + Uri = request.Params.Uri, + MimeType = "text/plain", + Text = $"Dynamic resource {id}: This is a plaintext resource" + } + ] + }); + } + ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) ?? throw new McpServerException("Resource not found"); diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index af13cc795..121c4eac8 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -176,6 +176,21 @@ await Assert.ThrowsAsync(() => client.GetPromptAsync("non_existent_prompt", null, CancellationToken.None)); } + [Theory] + [MemberData(nameof(GetClients))] + public async Task ListResourceTemplates_Stdio(string clientId) + { + // arrange + + // act + await using var client = await _fixture.CreateClientAsync(clientId); + + List allResourceTemplates = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken).ToListAsync(TestContext.Current.CancellationToken); + + // The server provides a single test resource template + Assert.Single(allResourceTemplates); + } + [Theory] [MemberData(nameof(GetClients))] public async Task ListResources_Stdio(string clientId) diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs index 7cd5c8e76..9a0fe72be 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs @@ -71,6 +71,19 @@ public void WithGetPromptHandler_Sets_Handler() Assert.Equal(handler, options.GetPromptHandler); } + [Fact] + public void WithListResourceTemplatesHandler_Sets_Handler() + { + Func, CancellationToken, Task> handler = (context, token) => Task.FromResult(new ListResourceTemplatesResult()); + + _builder.Object.WithListResourceTemplatesHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>().Value; + + Assert.Equal(handler, options.ListResourceTemplatesHandler); + } + [Fact] public void WithListResourcesHandler_Sets_Handler() { diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerDelegatesTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerDelegatesTests.cs index 9ed757655..3f7d5f7c1 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerDelegatesTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerDelegatesTests.cs @@ -14,6 +14,7 @@ public void AllPropertiesAreSettable() Assert.Null(handlers.CallToolHandler); Assert.Null(handlers.ListPromptsHandler); Assert.Null(handlers.GetPromptHandler); + Assert.Null(handlers.ListResourceTemplatesHandler); Assert.Null(handlers.ListResourcesHandler); Assert.Null(handlers.ReadResourceHandler); Assert.Null(handlers.GetCompletionHandler); @@ -24,6 +25,7 @@ public void AllPropertiesAreSettable() handlers.CallToolHandler = (p, c) => Task.FromResult(new CallToolResponse()); handlers.ListPromptsHandler = (p, c) => Task.FromResult(new ListPromptsResult()); handlers.GetPromptHandler = (p, c) => Task.FromResult(new GetPromptResult()); + handlers.ListResourceTemplatesHandler = (p, c) => Task.FromResult(new ListResourceTemplatesResult()); handlers.ListResourcesHandler = (p, c) => Task.FromResult(new ListResourcesResult()); handlers.ReadResourceHandler = (p, c) => Task.FromResult(new ReadResourceResult()); handlers.GetCompletionHandler = (p, c) => Task.FromResult(new CompleteResult()); @@ -34,6 +36,7 @@ public void AllPropertiesAreSettable() Assert.NotNull(handlers.CallToolHandler); Assert.NotNull(handlers.ListPromptsHandler); Assert.NotNull(handlers.GetPromptHandler); + Assert.NotNull(handlers.ListResourceTemplatesHandler); Assert.NotNull(handlers.ListResourcesHandler); Assert.NotNull(handlers.ReadResourceHandler); Assert.NotNull(handlers.GetCompletionHandler); diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index e8ca56bc4..560611b80 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -304,6 +304,44 @@ await Can_Handle_Requests( }); } + [Fact] + public async Task Can_Handle_ResourceTemplates_List_Requests() + { + await Can_Handle_Requests( + new ServerCapabilities + { + Resources = new() + { + ListResourceTemplatesHandler = (request, ct) => + { + return Task.FromResult(new ListResourceTemplatesResult + { + ResourceTemplates = [new() { UriTemplate = "test", Name = "Test Resource" }] + }); + }, + ListResourcesHandler = (request, ct) => + { + return Task.FromResult(new ListResourcesResult + { + Resources = [new() { Uri = "test", Name = "Test Resource" }] + }); + }, + ReadResourceHandler = (request, ct) => throw new NotImplementedException(), + } + }, + "resources/templates/list", + configureOptions: null, + assertResult: response => + { + Assert.IsType(response); + + var result = (ListResourceTemplatesResult)response; + Assert.NotNull(result.ResourceTemplates); + Assert.NotEmpty(result.ResourceTemplates); + Assert.Equal("test", result.ResourceTemplates[0].UriTemplate); + }); + } + [Fact] public async Task Can_Handle_Resources_List_Requests() {