diff --git a/src/ModelContextProtocol/Client/McpClientExtensions.cs b/src/ModelContextProtocol/Client/McpClientExtensions.cs index 9c2ca38b..56b77f69 100644 --- a/src/ModelContextProtocol/Client/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Client/McpClientExtensions.cs @@ -139,6 +139,44 @@ public static Task<GetPromptResult> GetPromptAsync(this IMcpClient client, strin cancellationToken); } + /// <summary> + /// Retrieves a sequence of available resource templates from the server. + /// </summary> + /// <param name="client">The client.</param> + /// <param name="cancellationToken">A token to cancel the operation.</param> + /// <returns>An asynchronous sequence of resource template information.</returns> + public static async IAsyncEnumerable<ResourceTemplate> 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); + } + + /// <summary> + /// Retrieves a list of available resources from the server. + /// </summary> + /// <param name="client">The client.</param> + /// <param name="cursor">A cursor to paginate the results.</param> + /// <param name="cancellationToken">A token to cancel the operation.</param> + public static Task<ListResourceTemplatesResult> ListResourceTemplatesAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default) + { + Throw.IfNull(client); + + return client.SendRequestAsync<ListResourceTemplatesResult>( + CreateRequest("resources/templates/list", CreateCursorDictionary(cursor)), + cancellationToken); + } + /// <summary> /// Retrieves a sequence of available resources from the server. /// </summary> diff --git a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.Handler.cs b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.Handler.cs index 6e88992a..3612b925 100644 --- a/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.Handler.cs +++ b/src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.Handler.cs @@ -11,6 +11,19 @@ namespace ModelContextProtocol; /// </summary> public static partial class McpServerBuilderExtensions { + /// <summary> + /// Sets the handler for list resource templates requests. + /// </summary> + /// <param name="builder">The builder instance.</param> + /// <param name="handler">The handler.</param> + public static IMcpServerBuilder WithListResourceTemplatesHandler(this IMcpServerBuilder builder, Func<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, Task<ListResourceTemplatesResult>> handler) + { + Throw.IfNull(builder); + + builder.Services.Configure<McpServerHandlers>(s => s.ListResourceTemplatesHandler = handler); + return builder; + } + /// <summary> /// Sets the handler for list tools requests. /// </summary> diff --git a/src/ModelContextProtocol/Protocol/Types/Capabilities.cs b/src/ModelContextProtocol/Protocol/Types/Capabilities.cs index 600e20f2..3392f57c 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; } + /// <summary> + /// Gets or sets the handler for list resource templates requests. + /// </summary> + [JsonIgnore] + public Func<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, Task<ListResourceTemplatesResult>>? ListResourceTemplatesHandler { get; init; } + /// <summary> /// Gets or sets the handler for list resources requests. /// </summary> diff --git a/src/ModelContextProtocol/Protocol/Types/ListResourceTemplatesRequestParams.cs b/src/ModelContextProtocol/Protocol/Types/ListResourceTemplatesRequestParams.cs new file mode 100644 index 00000000..4b4eecf9 --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/ListResourceTemplatesRequestParams.cs @@ -0,0 +1,15 @@ +namespace ModelContextProtocol.Protocol.Types; + +/// <summary> +/// Sent from the client to request a list of resource templates the server has. +/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/2024-11-05/schema.json">See the schema for details</see> +/// </summary> +public class ListResourceTemplatesRequestParams +{ + /// <summary> + /// An opaque token representing the current pagination position. + /// If provided, the server should return results starting after this cursor. + /// </summary> + [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 00000000..d2337923 --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/ListResourceTemplatesResult.cs @@ -0,0 +1,16 @@ +using ModelContextProtocol.Protocol.Messages; + +namespace ModelContextProtocol.Protocol.Types; + +/// <summary> +/// The server's response to a resources/templates/list request from the client. +/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/2024-11-05/schema.json">See the schema for details</see> +/// </summary> +public class ListResourceTemplatesResult : PaginatedResult +{ + /// <summary> + /// A list of resource templates that the server offers. + /// </summary> + [System.Text.Json.Serialization.JsonPropertyName("resourceTemplates")] + public List<ResourceTemplate> 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 00000000..b32822a2 --- /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; + +/// <summary> +/// Represents a known resource template that the server is capable of reading. +/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/2024-11-05/schema.json">See the schema for details</see> +/// </summary> +public record ResourceTemplate +{ + /// <summary> + /// The URI template (according to RFC 6570) that can be used to construct resource URIs. + /// </summary> + [JsonPropertyName("uriTemplate")] + public required string UriTemplate { get; init; } + + /// <summary> + /// A human-readable name for this resource template. + /// </summary> + [JsonPropertyName("name")] + public required string Name { get; init; } + + /// <summary> + /// A description of what this resource template represents. + /// </summary> + [JsonPropertyName("description")] + public string? Description { get; init; } + + /// <summary> + /// The MIME type of this resource template, if known. + /// </summary> + [JsonPropertyName("mimeType")] + public string? MimeType { get; init; } + + /// <summary> + /// Optional annotations for the resource template. + /// </summary> + [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 5e813fef..6ad1defe 100644 --- a/src/ModelContextProtocol/Server/McpServer.cs +++ b/src/ModelContextProtocol/Server/McpServer.cs @@ -155,6 +155,12 @@ private void SetResourcesHandler(McpServerOptions options) SetRequestHandler<ListResourcesRequestParams, ListResourcesResult>("resources/list", (request, ct) => listResourcesHandler(new(this, request), ct)); SetRequestHandler<ReadResourceRequestParams, ReadResourceResult>("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<ListResourceTemplatesRequestParams, ListResourceTemplatesResult>("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 56327339..c41c601e 100644 --- a/src/ModelContextProtocol/Server/McpServerHandlers.cs +++ b/src/ModelContextProtocol/Server/McpServerHandlers.cs @@ -27,6 +27,11 @@ public sealed class McpServerHandlers /// </summary> public Func<RequestContext<GetPromptRequestParams>, CancellationToken, Task<GetPromptResult>>? GetPromptHandler { get; set; } + /// <summary> + /// Gets or sets the handler for list resource templates requests. + /// </summary> + public Func<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, Task<ListResourceTemplatesResult>>? ListResourceTemplatesHandler { get; set; } + /// <summary> /// Gets or sets the handler for list resources requests. /// </summary> @@ -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 a59e1ec7..8574a73a 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 7e9f5e44..712f845f 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 af13cc79..121c4eac 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -176,6 +176,21 @@ await Assert.ThrowsAsync<McpClientException>(() => 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<ResourceTemplate> 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 7cd5c8e7..9a0fe72b 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<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, Task<ListResourceTemplatesResult>> handler = (context, token) => Task.FromResult(new ListResourceTemplatesResult()); + + _builder.Object.WithListResourceTemplatesHandler(handler); + + var serviceProvider = _services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService<IOptions<McpServerHandlers>>().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 9ed75765..3f7d5f7c 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 e8ca56bc..560611b8 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<ListResourceTemplatesResult>(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() {