Add initial support for Resource Templates (#18)

* Add initial support for Resource Templates

* Make ListResourceTemplatesHandler not required

The server was throwing if this handler wasn't specified, and it should probably be optional anyway.

* Remove extra line

Co-authored-by: Stephen Toub <stoub@microsoft.com>

* Fix spaces

Co-authored-by: Stephen Toub <stoub@microsoft.com>

* Fix spaces

Co-authored-by: Stephen Toub <stoub@microsoft.com>

* Add and use a default ListResourceTemplatesHandler

If no ListResourceTemplatesHandler is specified, use a default one that returns an empty result.

* Fix spaces

* Spaces

* Add Client extensions for ListResourceTemplates

* Integration Test for Client ListResourceTemplates

* Fix comment

Co-authored-by: Stephen Toub <stoub@microsoft.com>

* Make Defaults internal

Co-authored-by: Stephen Toub <stoub@microsoft.com>

* Simplify test call

Co-authored-by: Stephen Toub <stoub@microsoft.com>

* Make delegate static

Co-authored-by: Stephen Toub <stoub@microsoft.com>

* Fix test comment

* Spaces

* Fix xml doc

Co-authored-by: Stephen Toub <stoub@microsoft.com>

* Make default handler inline

* Spaces

Co-authored-by: Stephen Halter <halter73@gmail.com>

---------

Co-authored-by: Stephen Toub <stoub@microsoft.com>
Co-authored-by: Stephen Halter <halter73@gmail.com>
This commit is contained in:
Jonathan Dick 2025-03-21 14:09:45 -04:00 • committed by GitHub
commit fe6b401014
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 287 additions and 3 deletions

View file

@ -139,6 +139,44 @@ public static class McpClientExtensions
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>

View file

@ -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>

View file

@ -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>

View file

@ -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; }
}

View file

@ -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; } = [];
}

View file

@ -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; }
}

View file

@ -155,6 +155,12 @@ internal sealed class McpServer : McpJsonRpcEndpoint, IMcpServer
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;

View file

@ -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 @@ public sealed class McpServerHandlers
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
};

View file

@ -313,6 +313,20 @@ internal static class Program
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 @@ internal static class Program
{
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 @@ internal static class Program
{
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 @@ internal static class Program
{
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");
}

View file

@ -200,6 +200,21 @@ internal class Program
},
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 @@ internal class Program
{
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");

View file

@ -176,6 +176,21 @@ public class ClientIntegrationTests : IClassFixture<ClientIntegrationTestFixture
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)

View file

@ -71,6 +71,19 @@ public class McpServerBuilderExtensionsHandlerTests
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()
{

View file

@ -14,6 +14,7 @@ public class McpServerHandlerTests
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 class McpServerHandlerTests
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 class McpServerHandlerTests
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);

View file

@ -297,6 +297,44 @@ public class McpServerTests
});
}
[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()
{