Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 24 additions & 14 deletions src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,28 @@ private AIFunctionMcpServerResource(AIFunction function, ResourceTemplate resour
public override IReadOnlyList<object> Metadata => _metadata;

/// <inheritdoc />
public override async ValueTask<ReadResourceResult?> ReadAsync(
public override bool CanReadUri(string uri)
{
Throw.IfNull(uri);
return TryMatch(uri, out _);
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is going to create the Match object if it does match. It'd be better if the Can path used IsMatch instead

}

private bool TryMatch(string uri, out Match? match)
{
// For templates, use the Regex to parse. For static resources, we can just compare the URIs.
if (_uriParser is null)
{
// This resource is not templated.
match = null;
return UriTemplate.UriTemplateComparer.Instance.Equals(uri, ProtocolResourceTemplate.UriTemplate);
}

match = _uriParser.Match(uri);
return match.Success;
}

/// <inheritdoc />
public override async ValueTask<ReadResourceResult> ReadAsync(
RequestContext<ReadResourceRequestParams> request, CancellationToken cancellationToken = default)
{
Throw.IfNull(request);
Expand All @@ -323,20 +344,9 @@ private AIFunctionMcpServerResource(AIFunction function, ResourceTemplate resour

cancellationToken.ThrowIfCancellationRequested();

// Check to see if this URI template matches the request URI. If it doesn't, return null.
// For templates, use the Regex to parse. For static resources, we can just compare the URIs.
Match? match = null;
if (_uriParser is not null)
{
match = _uriParser.Match(request.Params.Uri);
if (!match.Success)
{
return null;
}
}
else if (!UriTemplate.UriTemplateComparer.Instance.Equals(request.Params.Uri, ProtocolResource!.Uri))
if (!TryMatch(request.Params.Uri, out Match? match))
{
return null;
throw new InvalidOperationException($"Resource '{ProtocolResourceTemplate.UriTemplate}' does not match the provided URI '{request.Params.Uri}'.");
}

// Build up the arguments for the AIFunction call, including all of the name/value pairs from the URI.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,10 @@ protected DelegatingMcpServerResource(McpServerResource innerResource)
public override ResourceTemplate ProtocolResourceTemplate => _innerResource.ProtocolResourceTemplate;

/// <inheritdoc />
public override ValueTask<ReadResourceResult?> ReadAsync(RequestContext<ReadResourceRequestParams> request, CancellationToken cancellationToken = default) =>
public override bool CanReadUri(string uri) => _innerResource.CanReadUri(uri);

/// <inheritdoc />
public override ValueTask<ReadResourceResult> ReadAsync(RequestContext<ReadResourceRequestParams> request, CancellationToken cancellationToken = default) =>
_innerResource.ReadAsync(request, cancellationToken);

/// <inheritdoc />
Expand Down
14 changes: 3 additions & 11 deletions src/ModelContextProtocol.Core/Server/McpServerImpl.cs
Original file line number Diff line number Diff line change
Expand Up @@ -342,10 +342,7 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure
{
if (request.MatchedPrimitive is McpServerResource matchedResource)
{
if (await matchedResource.ReadAsync(request, cancellationToken).ConfigureAwait(false) is { } result)
{
return result;
}
return await matchedResource.ReadAsync(request, cancellationToken).ConfigureAwait(false);
}

return await originalReadResourceHandler(request, cancellationToken).ConfigureAwait(false);
Expand All @@ -366,22 +363,17 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure
if (request.Params?.Uri is { } uri && resources is not null)
{
// First try an O(1) lookup by exact match.
if (resources.TryGetPrimitive(uri, out var resource))
if (resources.TryGetPrimitive(uri, out var resource) && !resource.IsTemplated)
{
request.MatchedPrimitive = resource;
}
else
{
// Fall back to an O(N) lookup, trying to match against each URI template.
// The number of templates is controlled by the server developer, and the number is expected to be
// not terribly large. If that changes, this can be tweaked to enable a more efficient lookup.
foreach (var resourceTemplate in resources)
{
// Check if this template would handle the request by testing if ReadAsync would succeed
if (resourceTemplate.IsTemplated)
if (resourceTemplate.CanReadUri(uri))
{
// This is a simplified check - a more robust implementation would match the URI pattern
// For now, we'll let the actual handler attempt the match
request.MatchedPrimitive = resourceTemplate;
break;
}
Expand Down
18 changes: 14 additions & 4 deletions src/ModelContextProtocol.Core/Server/McpServerResource.cs
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,14 @@ protected McpServerResource()
/// </remarks>
public abstract IReadOnlyList<object> Metadata { get; }

/// <summary>
/// Evaluates whether the <paramref name="uri"/> matches the <see cref="ProtocolResourceTemplate"/>
/// and can be used as the <see cref="ReadResourceRequestParams.Uri"/> passed to <see cref="ReadAsync"/>.
/// </summary>
/// <param name="uri">The URI being evaluated for this resource.</param>
/// <returns><see langword="true"/> if the <paramref name="uri"/> matches the <see cref="ProtocolResourceTemplate"/>; otherwise, <see langword="false"/>.</returns>
public abstract bool CanReadUri(string uri);
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't like the name CanReadUri. Would people prefer MatchesUri? Or something else?

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Matches is much better than CanRead for this, imo.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm fine with both. Others like HandlesUri would be fine, too.


/// <summary>
/// Gets the resource, rendering it with the provided request parameters and returning the resource result.
/// </summary>
Expand All @@ -174,12 +182,14 @@ protected McpServerResource()
/// </param>
/// <returns>
/// A <see cref="ValueTask{ReadResourceResult}"/> representing the asynchronous operation, containing a <see cref="ReadResourceResult"/> with
/// the resource content and messages. If and only if this <see cref="McpServerResource"/> doesn't match the <see cref="ReadResourceRequestParams.Uri"/>,
/// the method returns <see langword="null"/>.
/// the resource content and messages.
/// </returns>
/// <exception cref="ArgumentNullException"><paramref name="request"/> is <see langword="null"/>.</exception>
/// <exception cref="InvalidOperationException">The resource implementation returned <see langword="null"/> or an unsupported result type.</exception>
public abstract ValueTask<ReadResourceResult?> ReadAsync(
/// <exception cref="InvalidOperationException">
/// The <see cref="ReadResourceRequestParams.Uri"/> did not match the <see cref="ProtocolResourceTemplate"/> for this resource,
/// the resource implementation returned <see langword="null"/>, or the resource implementation returned an unsupported result type.
/// </exception>
public abstract ValueTask<ReadResourceResult> ReadAsync(
RequestContext<ReadResourceRequestParams> request,
CancellationToken cancellationToken = default);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;

namespace ModelContextProtocol.Tests.Configuration;

public sealed class McpServerResourceRoutingTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper)
{
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
{
mcpServerBuilder.WithResources([
McpServerResource.Create(options: new() { UriTemplate = "test://resource/non-templated" } , method: () => "static"),
McpServerResource.Create(options: new() { UriTemplate = "test://resource/{id}" }, method: (string id) => $"template: {id}"),
McpServerResource.Create(options: new() { UriTemplate = "test://params{?a1,a2,a3}" }, method: (string a1, string a2, string a3) => $"params: {a1}, {a2}, {a3}"),
]);
}

[Fact]
public async Task MultipleTemplatedResources_MatchesCorrectResource()
{
// Verify that when multiple templated resources exist, the correct one is matched based on the URI pattern, not just the first one.
// Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/821.
await using McpClient client = await CreateMcpClientForServer();

var nonTemplatedResult = await client.ReadResourceAsync("test://resource/non-templated", TestContext.Current.CancellationToken);
Assert.Equal("static", ((TextResourceContents)nonTemplatedResult.Contents[0]).Text);

var templatedResult = await client.ReadResourceAsync("test://resource/12345", TestContext.Current.CancellationToken);
Assert.Equal("template: 12345", ((TextResourceContents)templatedResult.Contents[0]).Text);

var paramsResult = await client.ReadResourceAsync("test://params?a1=a&a2=b&a3=c", TestContext.Current.CancellationToken);
Assert.Equal("params: a, b, c", ((TextResourceContents)paramsResult.Contents[0]).Text);

var mcpEx = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.ReadResourceAsync("test://params{?a1,a2,a3}", TestContext.Current.CancellationToken));
Assert.Equal(McpErrorCode.InvalidParams, mcpEx.ErrorCode);
Assert.Equal("Request failed (remote): Unknown resource URI: 'test://params{?a1,a2,a3}'", mcpEx.Message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ public async Task UriTemplate_CreatedFromParameters_LotsOfTypesSupported()
const string Name = "Hello";

McpServerResource t;
ReadResourceResult? result;
ReadResourceResult result;
McpServer server = new Mock<McpServer>().Object;

t = McpServerResource.Create(() => "42", new() { Name = Name });
Expand Down Expand Up @@ -282,11 +282,12 @@ public async Task UriTemplate_CreatedFromParameters_LotsOfTypesSupported()
[InlineData("resource://mcp/Hello?arg1=42&arg2=84")]
[InlineData("resource://mcp/Hello?arg1=42&arg2=84&arg3=123")]
[InlineData("resource://mcp/Hello#fragment")]
public async Task UriTemplate_NonMatchingUri_ReturnsNull(string uri)
public async Task UriTemplate_NonMatchingUri_DoesNotMatch(string uri)
{
McpServerResource t = McpServerResource.Create((string arg1) => arg1, new() { Name = "Hello" });
Assert.Equal("resource://mcp/Hello{?arg1}", t.ProtocolResourceTemplate.UriTemplate);
Assert.Null(await t.ReadAsync(
Assert.False(t.CanReadUri(uri));
await Assert.ThrowsAsync<InvalidOperationException>(async () => await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(new Mock<McpServer>().Object, CreateTestJsonRpcRequest()) { Params = new() { Uri = uri } },
TestContext.Current.CancellationToken));
}
Expand Down Expand Up @@ -337,7 +338,7 @@ public async Task UriTemplate_MissingOptionalParameter_Succeeds()
McpServerResource t = McpServerResource.Create((string? arg1 = null, int? arg2 = null) => arg1 + arg2, new() { Name = "Hello" });
Assert.Equal("resource://mcp/Hello{?arg1,arg2}", t.ProtocolResourceTemplate.UriTemplate);

ReadResourceResult? result;
ReadResourceResult result;

result = await t.ReadAsync(
new RequestContext<ReadResourceRequestParams>(new Mock<McpServer>().Object, CreateTestJsonRpcRequest()) { Params = new() { Uri = "resource://mcp/Hello" } },
Expand Down