diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index 91dc2042..c0edb9d6 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -314,7 +314,28 @@ private AIFunctionMcpServerResource(AIFunction function, ResourceTemplate resour public override IReadOnlyList Metadata => _metadata; /// - public override async ValueTask ReadAsync( + public override bool CanReadUri(string uri) + { + Throw.IfNull(uri); + return TryMatch(uri, out _); + } + + 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; + } + + /// + public override async ValueTask ReadAsync( RequestContext request, CancellationToken cancellationToken = default) { Throw.IfNull(request); @@ -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. diff --git a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerResource.cs index ee321037..4b491a8f 100644 --- a/src/ModelContextProtocol.Core/Server/DelegatingMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/DelegatingMcpServerResource.cs @@ -26,7 +26,10 @@ protected DelegatingMcpServerResource(McpServerResource innerResource) public override ResourceTemplate ProtocolResourceTemplate => _innerResource.ProtocolResourceTemplate; /// - public override ValueTask ReadAsync(RequestContext request, CancellationToken cancellationToken = default) => + public override bool CanReadUri(string uri) => _innerResource.CanReadUri(uri); + + /// + public override ValueTask ReadAsync(RequestContext request, CancellationToken cancellationToken = default) => _innerResource.ReadAsync(request, cancellationToken); /// diff --git a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs index f827fd31..31078a6b 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerImpl.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerImpl.cs @@ -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); @@ -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; } diff --git a/src/ModelContextProtocol.Core/Server/McpServerResource.cs b/src/ModelContextProtocol.Core/Server/McpServerResource.cs index 00d89c77..cceccc02 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerResource.cs @@ -162,6 +162,14 @@ protected McpServerResource() /// public abstract IReadOnlyList Metadata { get; } + /// + /// Evaluates whether the matches the + /// and can be used as the passed to . + /// + /// The URI being evaluated for this resource. + /// if the matches the ; otherwise, . + public abstract bool CanReadUri(string uri); + /// /// Gets the resource, rendering it with the provided request parameters and returning the resource result. /// @@ -174,12 +182,14 @@ protected McpServerResource() /// /// /// A representing the asynchronous operation, containing a with - /// the resource content and messages. If and only if this doesn't match the , - /// the method returns . + /// the resource content and messages. /// /// is . - /// The resource implementation returned or an unsupported result type. - public abstract ValueTask ReadAsync( + /// + /// The did not match the for this resource, + /// the resource implementation returned , or the resource implementation returned an unsupported result type. + /// + public abstract ValueTask ReadAsync( RequestContext request, CancellationToken cancellationToken = default); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs new file mode 100644 index 00000000..1c82fcae --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerResourceRoutingTests.cs @@ -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(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); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs index 135633b8..abeb39c3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs @@ -142,7 +142,7 @@ public async Task UriTemplate_CreatedFromParameters_LotsOfTypesSupported() const string Name = "Hello"; McpServerResource t; - ReadResourceResult? result; + ReadResourceResult result; McpServer server = new Mock().Object; t = McpServerResource.Create(() => "42", new() { Name = Name }); @@ -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(async () => await t.ReadAsync( new RequestContext(new Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Uri = uri } }, TestContext.Current.CancellationToken)); } @@ -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(new Mock().Object, CreateTestJsonRpcRequest()) { Params = new() { Uri = "resource://mcp/Hello" } },