Skip to content
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

Add initial support for Resource Templates #18

Merged
Merged
38 changes: 38 additions & 0 deletions src/ModelContextProtocol/Client/McpClientExtensions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
6 changes: 6 additions & 0 deletions src/ModelContextProtocol/Protocol/Types/Capabilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down
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
Expand Up @@ -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;
Expand Down
7 changes: 7 additions & 0 deletions src/ModelContextProtocol/Server/McpServerHandlers.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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>
Expand Down Expand Up @@ -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
};
Expand Down
41 changes: 39 additions & 2 deletions tests/ModelContextProtocol.TestServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");

Expand All @@ -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");
}
Expand All @@ -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");
}
Expand Down
37 changes: 36 additions & 1 deletion tests/ModelContextProtocol.TestSseServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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");

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