Skip to content

Add initial support for Resource Templates #18

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
38 changes: 38 additions & 0 deletions src/ModelContextProtocol/Client/McpClientExtensions.cs
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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>
6 changes: 6 additions & 0 deletions src/ModelContextProtocol/Protocol/Types/Capabilities.cs
Original file line number Diff line number Diff line change
@@ -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>
Original file line number Diff line number Diff line change
@@ -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; }
}
Original file line number Diff line number Diff line change
@@ -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; } = [];
}
42 changes: 42 additions & 0 deletions src/ModelContextProtocol/Protocol/Types/ResourceTemplate.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
6 changes: 6 additions & 0 deletions src/ModelContextProtocol/Server/McpServer.cs
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions src/ModelContextProtocol/Server/McpServerHandlers.cs
Original file line number Diff line number Diff line change
@@ -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
};
41 changes: 39 additions & 2 deletions tests/ModelContextProtocol.TestServer/Program.cs
Original file line number Diff line number Diff line change
@@ -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");
}
37 changes: 36 additions & 1 deletion tests/ModelContextProtocol.TestSseServer/Program.cs
Original file line number Diff line number Diff line change
@@ -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");

15 changes: 15 additions & 0 deletions tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs
Original file line number Diff line number Diff line change
@@ -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)
Original file line number Diff line number Diff line change
@@ -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()
{
Original file line number Diff line number Diff line change
@@ -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);
38 changes: 38 additions & 0 deletions tests/ModelContextProtocol.Tests/Server/McpServerTests.cs
Original file line number Diff line number Diff line change
@@ -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()
{