Skip to content

Commit fe6b401

Browse files
Redthstephentoubhalter73
authored
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 <[email protected]> * Fix spaces Co-authored-by: Stephen Toub <[email protected]> * Fix spaces Co-authored-by: Stephen Toub <[email protected]> * 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 <[email protected]> * Make Defaults internal Co-authored-by: Stephen Toub <[email protected]> * Simplify test call Co-authored-by: Stephen Toub <[email protected]> * Make delegate static Co-authored-by: Stephen Toub <[email protected]> * Fix test comment * Spaces * Fix xml doc Co-authored-by: Stephen Toub <[email protected]> * Make default handler inline * Spaces Co-authored-by: Stephen Halter <[email protected]> --------- Co-authored-by: Stephen Toub <[email protected]> Co-authored-by: Stephen Halter <[email protected]>
1 parent 50cc488 commit fe6b401

File tree

14 files changed

+287
-3
lines changed

14 files changed

+287
-3
lines changed

src/ModelContextProtocol/Client/McpClientExtensions.cs

+38
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,44 @@ public static Task<GetPromptResult> GetPromptAsync(this IMcpClient client, strin
139139
cancellationToken);
140140
}
141141

142+
/// <summary>
143+
/// Retrieves a sequence of available resource templates from the server.
144+
/// </summary>
145+
/// <param name="client">The client.</param>
146+
/// <param name="cancellationToken">A token to cancel the operation.</param>
147+
/// <returns>An asynchronous sequence of resource template information.</returns>
148+
public static async IAsyncEnumerable<ResourceTemplate> ListResourceTemplatesAsync(
149+
this IMcpClient client, [EnumeratorCancellation] CancellationToken cancellationToken = default)
150+
{
151+
string? cursor = null;
152+
do
153+
{
154+
var resources = await ListResourceTemplatesAsync(client, cursor, cancellationToken).ConfigureAwait(false);
155+
foreach (var resource in resources.ResourceTemplates)
156+
{
157+
yield return resource;
158+
}
159+
160+
cursor = resources.NextCursor;
161+
}
162+
while (cursor is not null);
163+
}
164+
165+
/// <summary>
166+
/// Retrieves a list of available resources from the server.
167+
/// </summary>
168+
/// <param name="client">The client.</param>
169+
/// <param name="cursor">A cursor to paginate the results.</param>
170+
/// <param name="cancellationToken">A token to cancel the operation.</param>
171+
public static Task<ListResourceTemplatesResult> ListResourceTemplatesAsync(this IMcpClient client, string? cursor, CancellationToken cancellationToken = default)
172+
{
173+
Throw.IfNull(client);
174+
175+
return client.SendRequestAsync<ListResourceTemplatesResult>(
176+
CreateRequest("resources/templates/list", CreateCursorDictionary(cursor)),
177+
cancellationToken);
178+
}
179+
142180
/// <summary>
143181
/// Retrieves a sequence of available resources from the server.
144182
/// </summary>

src/ModelContextProtocol/Configuration/McpServerBuilderExtensions.Handler.cs

+13
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,19 @@ namespace ModelContextProtocol;
1111
/// </summary>
1212
public static partial class McpServerBuilderExtensions
1313
{
14+
/// <summary>
15+
/// Sets the handler for list resource templates requests.
16+
/// </summary>
17+
/// <param name="builder">The builder instance.</param>
18+
/// <param name="handler">The handler.</param>
19+
public static IMcpServerBuilder WithListResourceTemplatesHandler(this IMcpServerBuilder builder, Func<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, Task<ListResourceTemplatesResult>> handler)
20+
{
21+
Throw.IfNull(builder);
22+
23+
builder.Services.Configure<McpServerHandlers>(s => s.ListResourceTemplatesHandler = handler);
24+
return builder;
25+
}
26+
1427
/// <summary>
1528
/// Sets the handler for list tools requests.
1629
/// </summary>

src/ModelContextProtocol/Protocol/Types/Capabilities.cs

+6
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,12 @@ public record ResourcesCapability
110110
[JsonPropertyName("listChanged")]
111111
public bool? ListChanged { get; init; }
112112

113+
/// <summary>
114+
/// Gets or sets the handler for list resource templates requests.
115+
/// </summary>
116+
[JsonIgnore]
117+
public Func<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, Task<ListResourceTemplatesResult>>? ListResourceTemplatesHandler { get; init; }
118+
113119
/// <summary>
114120
/// Gets or sets the handler for list resources requests.
115121
/// </summary>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace ModelContextProtocol.Protocol.Types;
2+
3+
/// <summary>
4+
/// Sent from the client to request a list of resource templates the server has.
5+
/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/2024-11-05/schema.json">See the schema for details</see>
6+
/// </summary>
7+
public class ListResourceTemplatesRequestParams
8+
{
9+
/// <summary>
10+
/// An opaque token representing the current pagination position.
11+
/// If provided, the server should return results starting after this cursor.
12+
/// </summary>
13+
[System.Text.Json.Serialization.JsonPropertyName("cursor")]
14+
public string? Cursor { get; init; }
15+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
using ModelContextProtocol.Protocol.Messages;
2+
3+
namespace ModelContextProtocol.Protocol.Types;
4+
5+
/// <summary>
6+
/// The server's response to a resources/templates/list request from the client.
7+
/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/2024-11-05/schema.json">See the schema for details</see>
8+
/// </summary>
9+
public class ListResourceTemplatesResult : PaginatedResult
10+
{
11+
/// <summary>
12+
/// A list of resource templates that the server offers.
13+
/// </summary>
14+
[System.Text.Json.Serialization.JsonPropertyName("resourceTemplates")]
15+
public List<ResourceTemplate> ResourceTemplates { get; set; } = [];
16+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using ModelContextProtocol.Protocol.Types;
2+
3+
using System.Text.Json.Serialization;
4+
5+
namespace ModelContextProtocol.Protocol.Types;
6+
7+
/// <summary>
8+
/// Represents a known resource template that the server is capable of reading.
9+
/// <see href="https://github.com/modelcontextprotocol/specification/blob/main/schema/2024-11-05/schema.json">See the schema for details</see>
10+
/// </summary>
11+
public record ResourceTemplate
12+
{
13+
/// <summary>
14+
/// The URI template (according to RFC 6570) that can be used to construct resource URIs.
15+
/// </summary>
16+
[JsonPropertyName("uriTemplate")]
17+
public required string UriTemplate { get; init; }
18+
19+
/// <summary>
20+
/// A human-readable name for this resource template.
21+
/// </summary>
22+
[JsonPropertyName("name")]
23+
public required string Name { get; init; }
24+
25+
/// <summary>
26+
/// A description of what this resource template represents.
27+
/// </summary>
28+
[JsonPropertyName("description")]
29+
public string? Description { get; init; }
30+
31+
/// <summary>
32+
/// The MIME type of this resource template, if known.
33+
/// </summary>
34+
[JsonPropertyName("mimeType")]
35+
public string? MimeType { get; init; }
36+
37+
/// <summary>
38+
/// Optional annotations for the resource template.
39+
/// </summary>
40+
[JsonPropertyName("annotations")]
41+
public Annotations? Annotations { get; init; }
42+
}

src/ModelContextProtocol/Server/McpServer.cs

+6
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,12 @@ private void SetResourcesHandler(McpServerOptions options)
155155
SetRequestHandler<ListResourcesRequestParams, ListResourcesResult>("resources/list", (request, ct) => listResourcesHandler(new(this, request), ct));
156156
SetRequestHandler<ReadResourceRequestParams, ReadResourceResult>("resources/read", (request, ct) => readResourceHandler(new(this, request), ct));
157157

158+
// Set the list resource templates handler, or use the default if not specified
159+
var listResourceTemplatesHandler = resourcesCapability.ListResourceTemplatesHandler
160+
?? (static (_, _) => Task.FromResult(new ListResourceTemplatesResult()));
161+
162+
SetRequestHandler<ListResourceTemplatesRequestParams, ListResourceTemplatesResult>("resources/templates/list", (request, ct) => listResourceTemplatesHandler(new(this, request), ct));
163+
158164
if (resourcesCapability.Subscribe is not true)
159165
{
160166
return;

src/ModelContextProtocol/Server/McpServerHandlers.cs

+7
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@ public sealed class McpServerHandlers
2727
/// </summary>
2828
public Func<RequestContext<GetPromptRequestParams>, CancellationToken, Task<GetPromptResult>>? GetPromptHandler { get; set; }
2929

30+
/// <summary>
31+
/// Gets or sets the handler for list resource templates requests.
32+
/// </summary>
33+
public Func<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, Task<ListResourceTemplatesResult>>? ListResourceTemplatesHandler { get; set; }
34+
3035
/// <summary>
3136
/// Gets or sets the handler for list resources requests.
3237
/// </summary>
@@ -82,11 +87,13 @@ promptsCapability with
8287
resourcesCapability = resourcesCapability is null ?
8388
new()
8489
{
90+
ListResourceTemplatesHandler = ListResourceTemplatesHandler,
8591
ListResourcesHandler = ListResourcesHandler,
8692
ReadResourceHandler = ReadResourceHandler
8793
} :
8894
resourcesCapability with
8995
{
96+
ListResourceTemplatesHandler = ListResourceTemplatesHandler ?? resourcesCapability.ListResourceTemplatesHandler,
9097
ListResourcesHandler = ListResourcesHandler ?? resourcesCapability.ListResourcesHandler,
9198
ReadResourceHandler = ReadResourceHandler ?? resourcesCapability.ReadResourceHandler
9299
};

tests/ModelContextProtocol.TestServer/Program.cs

+39-2
Original file line numberDiff line numberDiff line change
@@ -313,6 +313,20 @@ private static ResourcesCapability ConfigureResources()
313313

314314
return new()
315315
{
316+
ListResourceTemplatesHandler = (request, cancellationToken) =>
317+
{
318+
return Task.FromResult(new ListResourceTemplatesResult()
319+
{
320+
ResourceTemplates = [
321+
new ResourceTemplate()
322+
{
323+
UriTemplate = "test://dynamic/resource/{id}",
324+
Name = "Dynamic Resource",
325+
}
326+
]
327+
});
328+
},
329+
316330
ListResourcesHandler = (request, cancellationToken) =>
317331
{
318332
int startIndex = 0;
@@ -349,6 +363,27 @@ private static ResourcesCapability ConfigureResources()
349363
{
350364
throw new McpServerException("Missing required argument 'uri'");
351365
}
366+
367+
if (request.Params.Uri.StartsWith("test://dynamic/resource/"))
368+
{
369+
var id = request.Params.Uri.Split('/').LastOrDefault();
370+
if (string.IsNullOrEmpty(id))
371+
{
372+
throw new McpServerException("Invalid resource URI");
373+
}
374+
return Task.FromResult(new ReadResourceResult()
375+
{
376+
Contents = [
377+
new ResourceContents()
378+
{
379+
Uri = request.Params.Uri,
380+
MimeType = "text/plain",
381+
Text = $"Dynamic resource {id}: This is a plaintext resource"
382+
}
383+
]
384+
});
385+
}
386+
352387
ResourceContents contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri)
353388
?? throw new McpServerException("Resource not found");
354389

@@ -364,7 +399,8 @@ private static ResourcesCapability ConfigureResources()
364399
{
365400
throw new McpServerException("Missing required argument 'uri'");
366401
}
367-
if (!request.Params.Uri.StartsWith("test://static/resource/"))
402+
if (!request.Params.Uri.StartsWith("test://static/resource/")
403+
&& !request.Params.Uri.StartsWith("test://dynamic/resource/"))
368404
{
369405
throw new McpServerException("Invalid resource URI");
370406
}
@@ -383,7 +419,8 @@ private static ResourcesCapability ConfigureResources()
383419
{
384420
throw new McpServerException("Missing required argument 'uri'");
385421
}
386-
if (!request.Params.Uri.StartsWith("test://static/resource/"))
422+
if (!request.Params.Uri.StartsWith("test://static/resource/")
423+
&& !request.Params.Uri.StartsWith("test://dynamic/resource/"))
387424
{
388425
throw new McpServerException("Invalid resource URI");
389426
}

tests/ModelContextProtocol.TestSseServer/Program.cs

+36-1
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,21 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st
200200
},
201201
Resources = new()
202202
{
203+
ListResourceTemplatesHandler = (request, cancellationToken) =>
204+
{
205+
206+
return Task.FromResult(new ListResourceTemplatesResult()
207+
{
208+
ResourceTemplates = [
209+
new ResourceTemplate()
210+
{
211+
UriTemplate = "test://dynamic/resource/{id}",
212+
Name = "Dynamic Resource",
213+
}
214+
]
215+
});
216+
},
217+
203218
ListResourcesHandler = (request, cancellationToken) =>
204219
{
205220
int startIndex = 0;
@@ -236,7 +251,27 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st
236251
{
237252
throw new McpServerException("Missing required argument 'uri'");
238253
}
239-
254+
255+
if (request.Params.Uri.StartsWith("test://dynamic/resource/"))
256+
{
257+
var id = request.Params.Uri.Split('/').LastOrDefault();
258+
if (string.IsNullOrEmpty(id))
259+
{
260+
throw new McpServerException("Invalid resource URI");
261+
}
262+
return Task.FromResult(new ReadResourceResult()
263+
{
264+
Contents = [
265+
new ResourceContents()
266+
{
267+
Uri = request.Params.Uri,
268+
MimeType = "text/plain",
269+
Text = $"Dynamic resource {id}: This is a plaintext resource"
270+
}
271+
]
272+
});
273+
}
274+
240275
ResourceContents? contents = resourceContents.FirstOrDefault(r => r.Uri == request.Params.Uri) ??
241276
throw new McpServerException("Resource not found");
242277

tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs

+15
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,21 @@ await Assert.ThrowsAsync<McpClientException>(() =>
176176
client.GetPromptAsync("non_existent_prompt", null, CancellationToken.None));
177177
}
178178

179+
[Theory]
180+
[MemberData(nameof(GetClients))]
181+
public async Task ListResourceTemplates_Stdio(string clientId)
182+
{
183+
// arrange
184+
185+
// act
186+
await using var client = await _fixture.CreateClientAsync(clientId);
187+
188+
List<ResourceTemplate> allResourceTemplates = await client.ListResourceTemplatesAsync(TestContext.Current.CancellationToken).ToListAsync(TestContext.Current.CancellationToken);
189+
190+
// The server provides a single test resource template
191+
Assert.Single(allResourceTemplates);
192+
}
193+
179194
[Theory]
180195
[MemberData(nameof(GetClients))]
181196
public async Task ListResources_Stdio(string clientId)

tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsHandlerTests.cs

+13
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,19 @@ public void WithGetPromptHandler_Sets_Handler()
7171
Assert.Equal(handler, options.GetPromptHandler);
7272
}
7373

74+
[Fact]
75+
public void WithListResourceTemplatesHandler_Sets_Handler()
76+
{
77+
Func<RequestContext<ListResourceTemplatesRequestParams>, CancellationToken, Task<ListResourceTemplatesResult>> handler = (context, token) => Task.FromResult(new ListResourceTemplatesResult());
78+
79+
_builder.Object.WithListResourceTemplatesHandler(handler);
80+
81+
var serviceProvider = _services.BuildServiceProvider();
82+
var options = serviceProvider.GetRequiredService<IOptions<McpServerHandlers>>().Value;
83+
84+
Assert.Equal(handler, options.ListResourceTemplatesHandler);
85+
}
86+
7487
[Fact]
7588
public void WithListResourcesHandler_Sets_Handler()
7689
{

tests/ModelContextProtocol.Tests/Server/McpServerDelegatesTests.cs

+3
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ public void AllPropertiesAreSettable()
1414
Assert.Null(handlers.CallToolHandler);
1515
Assert.Null(handlers.ListPromptsHandler);
1616
Assert.Null(handlers.GetPromptHandler);
17+
Assert.Null(handlers.ListResourceTemplatesHandler);
1718
Assert.Null(handlers.ListResourcesHandler);
1819
Assert.Null(handlers.ReadResourceHandler);
1920
Assert.Null(handlers.GetCompletionHandler);
@@ -24,6 +25,7 @@ public void AllPropertiesAreSettable()
2425
handlers.CallToolHandler = (p, c) => Task.FromResult(new CallToolResponse());
2526
handlers.ListPromptsHandler = (p, c) => Task.FromResult(new ListPromptsResult());
2627
handlers.GetPromptHandler = (p, c) => Task.FromResult(new GetPromptResult());
28+
handlers.ListResourceTemplatesHandler = (p, c) => Task.FromResult(new ListResourceTemplatesResult());
2729
handlers.ListResourcesHandler = (p, c) => Task.FromResult(new ListResourcesResult());
2830
handlers.ReadResourceHandler = (p, c) => Task.FromResult(new ReadResourceResult());
2931
handlers.GetCompletionHandler = (p, c) => Task.FromResult(new CompleteResult());
@@ -34,6 +36,7 @@ public void AllPropertiesAreSettable()
3436
Assert.NotNull(handlers.CallToolHandler);
3537
Assert.NotNull(handlers.ListPromptsHandler);
3638
Assert.NotNull(handlers.GetPromptHandler);
39+
Assert.NotNull(handlers.ListResourceTemplatesHandler);
3740
Assert.NotNull(handlers.ListResourcesHandler);
3841
Assert.NotNull(handlers.ReadResourceHandler);
3942
Assert.NotNull(handlers.GetCompletionHandler);

0 commit comments

Comments
 (0)