Skip to content

Commit 1343dd3

Browse files
authored
Fix regression matching templated McpServerResources (#897)
1 parent 8e607e6 commit 1343dd3

File tree

9 files changed

+107
-46
lines changed

9 files changed

+107
-46
lines changed

src/ModelContextProtocol.AspNetCore/HttpServerTransportOptions.cs

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,11 +23,12 @@ public class HttpServerTransportOptions
2323
public Func<HttpContext, McpServer, CancellationToken, Task>? RunSessionHandler { get; set; }
2424

2525
/// <summary>
26-
/// Gets or sets whether the server should run in a stateless mode which allows for load balancing without session affinity.
26+
/// Gets or sets whether the server should run in a stateless mode which does not track state between requests
27+
/// allowing for load balancing without session affinity.
2728
/// </summary>
2829
/// <remarks>
29-
/// If <see langword="true"/>, <see cref="RunSessionHandler"/> is called once for every request for each request,
30-
/// the "/sse" endpoint will be disabled, and the "MCP-Session-Id" header will not be used.
30+
/// If <see langword="true"/>, <see cref="McpSession.SessionId"/> will be null, and the "MCP-Session-Id" header will not be used,
31+
/// the <see cref="RunSessionHandler"/> will be called once for for each request, and the "/sse" endpoint will be disabled.
3132
/// Unsolicited server-to-client messages and all server-to-client requests are also unsupported, because any responses
3233
/// may arrive at another ASP.NET Core application process.
3334
/// Client sampling and roots capabilities are also disabled in stateless mode, because the server cannot make requests.

src/ModelContextProtocol.Core/McpException.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ namespace ModelContextProtocol;
1212
///
1313
/// This exception type can be thrown by MCP tools or tool call filters to propagate detailed error messages
1414
/// from <see cref="Exception.Message"/> when a tool execution fails via a <see cref="CallToolResult"/>.
15-
/// For non-tool calls, this exception controls the message propogated via a <see cref="JsonRpcError"/>.
16-
///
15+
/// For non-tool calls, this exception controls the message propagated via a <see cref="JsonRpcError"/>.
16+
///
1717
/// <see cref="McpProtocolException"/> is a derived type that can be used to also specify the
1818
/// <see cref="McpErrorCode"/> that should be used for the resulting <see cref="JsonRpcError"/>.
1919
/// </remarks>

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs

Lines changed: 24 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -314,7 +314,28 @@ private AIFunctionMcpServerResource(AIFunction function, ResourceTemplate resour
314314
public override IReadOnlyList<object> Metadata => _metadata;
315315

316316
/// <inheritdoc />
317-
public override async ValueTask<ReadResourceResult?> ReadAsync(
317+
public override bool IsMatch(string uri)
318+
{
319+
Throw.IfNull(uri);
320+
return TryMatch(uri, out _);
321+
}
322+
323+
private bool TryMatch(string uri, out Match? match)
324+
{
325+
// For templates, use the Regex to parse. For static resources, we can just compare the URIs.
326+
if (_uriParser is null)
327+
{
328+
// This resource is not templated.
329+
match = null;
330+
return UriTemplate.UriTemplateComparer.Instance.Equals(uri, ProtocolResourceTemplate.UriTemplate);
331+
}
332+
333+
match = _uriParser.Match(uri);
334+
return match.Success;
335+
}
336+
337+
/// <inheritdoc />
338+
public override async ValueTask<ReadResourceResult> ReadAsync(
318339
RequestContext<ReadResourceRequestParams> request, CancellationToken cancellationToken = default)
319340
{
320341
Throw.IfNull(request);
@@ -323,20 +344,9 @@ private AIFunctionMcpServerResource(AIFunction function, ResourceTemplate resour
323344

324345
cancellationToken.ThrowIfCancellationRequested();
325346

326-
// Check to see if this URI template matches the request URI. If it doesn't, return null.
327-
// For templates, use the Regex to parse. For static resources, we can just compare the URIs.
328-
Match? match = null;
329-
if (_uriParser is not null)
330-
{
331-
match = _uriParser.Match(request.Params.Uri);
332-
if (!match.Success)
333-
{
334-
return null;
335-
}
336-
}
337-
else if (!UriTemplate.UriTemplateComparer.Instance.Equals(request.Params.Uri, ProtocolResource!.Uri))
347+
if (!TryMatch(request.Params.Uri, out Match? match))
338348
{
339-
return null;
349+
throw new InvalidOperationException($"Resource '{ProtocolResourceTemplate.UriTemplate}' does not match the provided URI '{request.Params.Uri}'.");
340350
}
341351

342352
// Build up the arguments for the AIFunction call, including all of the name/value pairs from the URI.

src/ModelContextProtocol.Core/Server/DelegatingMcpServerResource.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,10 @@ protected DelegatingMcpServerResource(McpServerResource innerResource)
2626
public override ResourceTemplate ProtocolResourceTemplate => _innerResource.ProtocolResourceTemplate;
2727

2828
/// <inheritdoc />
29-
public override ValueTask<ReadResourceResult?> ReadAsync(RequestContext<ReadResourceRequestParams> request, CancellationToken cancellationToken = default) =>
29+
public override bool IsMatch(string uri) => _innerResource.IsMatch(uri);
30+
31+
/// <inheritdoc />
32+
public override ValueTask<ReadResourceResult> ReadAsync(RequestContext<ReadResourceRequestParams> request, CancellationToken cancellationToken = default) =>
3033
_innerResource.ReadAsync(request, cancellationToken);
3134

3235
/// <inheritdoc />

src/ModelContextProtocol.Core/Server/McpServerImpl.cs

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -342,10 +342,7 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure
342342
{
343343
if (request.MatchedPrimitive is McpServerResource matchedResource)
344344
{
345-
if (await matchedResource.ReadAsync(request, cancellationToken).ConfigureAwait(false) is { } result)
346-
{
347-
return result;
348-
}
345+
return await matchedResource.ReadAsync(request, cancellationToken).ConfigureAwait(false);
349346
}
350347

351348
return await originalReadResourceHandler(request, cancellationToken).ConfigureAwait(false);
@@ -366,22 +363,17 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure
366363
if (request.Params?.Uri is { } uri && resources is not null)
367364
{
368365
// First try an O(1) lookup by exact match.
369-
if (resources.TryGetPrimitive(uri, out var resource))
366+
if (resources.TryGetPrimitive(uri, out var resource) && !resource.IsTemplated)
370367
{
371368
request.MatchedPrimitive = resource;
372369
}
373370
else
374371
{
375372
// Fall back to an O(N) lookup, trying to match against each URI template.
376-
// The number of templates is controlled by the server developer, and the number is expected to be
377-
// not terribly large. If that changes, this can be tweaked to enable a more efficient lookup.
378373
foreach (var resourceTemplate in resources)
379374
{
380-
// Check if this template would handle the request by testing if ReadAsync would succeed
381-
if (resourceTemplate.IsTemplated)
375+
if (resourceTemplate.IsMatch(uri))
382376
{
383-
// This is a simplified check - a more robust implementation would match the URI pattern
384-
// For now, we'll let the actual handler attempt the match
385377
request.MatchedPrimitive = resourceTemplate;
386378
break;
387379
}

src/ModelContextProtocol.Core/Server/McpServerResource.cs

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,16 @@ protected McpServerResource()
162162
/// </remarks>
163163
public abstract IReadOnlyList<object> Metadata { get; }
164164

165+
/// <summary>
166+
/// Evaluates whether the <paramref name="uri"/> matches the <see cref="ProtocolResourceTemplate"/>
167+
/// and can be used as the <see cref="ReadResourceRequestParams.Uri"/> passed to <see cref="ReadAsync"/>.
168+
/// </summary>
169+
/// <param name="uri">The URI being evaluated for this resource.</param>
170+
/// <returns>
171+
/// <see langword="true"/> if the <paramref name="uri"/> matches the <see cref="ProtocolResourceTemplate"/>; otherwise, <see langword="false"/>.
172+
/// </returns>
173+
public abstract bool IsMatch(string uri);
174+
165175
/// <summary>
166176
/// Gets the resource, rendering it with the provided request parameters and returning the resource result.
167177
/// </summary>
@@ -174,12 +184,14 @@ protected McpServerResource()
174184
/// </param>
175185
/// <returns>
176186
/// A <see cref="ValueTask{ReadResourceResult}"/> representing the asynchronous operation, containing a <see cref="ReadResourceResult"/> with
177-
/// the resource content and messages. If and only if this <see cref="McpServerResource"/> doesn't match the <see cref="ReadResourceRequestParams.Uri"/>,
178-
/// the method returns <see langword="null"/>.
187+
/// the resource content and messages.
179188
/// </returns>
180189
/// <exception cref="ArgumentNullException"><paramref name="request"/> is <see langword="null"/>.</exception>
181-
/// <exception cref="InvalidOperationException">The resource implementation returned <see langword="null"/> or an unsupported result type.</exception>
182-
public abstract ValueTask<ReadResourceResult?> ReadAsync(
190+
/// <exception cref="InvalidOperationException">
191+
/// The <see cref="ReadResourceRequestParams.Uri"/> did not match the <see cref="ProtocolResourceTemplate"/> for this resource,
192+
/// the resource implementation returned <see langword="null"/>, or the resource implementation returned an unsupported result type.
193+
/// </exception>
194+
public abstract ValueTask<ReadResourceResult> ReadAsync(
183195
RequestContext<ReadResourceRequestParams> request,
184196
CancellationToken cancellationToken = default);
185197

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using ModelContextProtocol.Client;
3+
using ModelContextProtocol.Protocol;
4+
using ModelContextProtocol.Server;
5+
6+
namespace ModelContextProtocol.Tests.Configuration;
7+
8+
public sealed class McpServerResourceRoutingTests(ITestOutputHelper testOutputHelper) : ClientServerTestBase(testOutputHelper)
9+
{
10+
protected override void ConfigureServices(ServiceCollection services, IMcpServerBuilder mcpServerBuilder)
11+
{
12+
mcpServerBuilder.WithResources([
13+
McpServerResource.Create(options: new() { UriTemplate = "test://resource/non-templated" } , method: () => "static"),
14+
McpServerResource.Create(options: new() { UriTemplate = "test://resource/{id}" }, method: (string id) => $"template: {id}"),
15+
McpServerResource.Create(options: new() { UriTemplate = "test://params{?a1,a2,a3}" }, method: (string a1, string a2, string a3) => $"params: {a1}, {a2}, {a3}"),
16+
]);
17+
}
18+
19+
[Fact]
20+
public async Task MultipleTemplatedResources_MatchesCorrectResource()
21+
{
22+
// Verify that when multiple templated resources exist, the correct one is matched based on the URI pattern, not just the first one.
23+
// Regression test for https://github.com/modelcontextprotocol/csharp-sdk/issues/821.
24+
await using McpClient client = await CreateMcpClientForServer();
25+
26+
var nonTemplatedResult = await client.ReadResourceAsync("test://resource/non-templated", TestContext.Current.CancellationToken);
27+
Assert.Equal("static", ((TextResourceContents)nonTemplatedResult.Contents[0]).Text);
28+
29+
var templatedResult = await client.ReadResourceAsync("test://resource/12345", TestContext.Current.CancellationToken);
30+
Assert.Equal("template: 12345", ((TextResourceContents)templatedResult.Contents[0]).Text);
31+
32+
var exactTemplatedResult = await client.ReadResourceAsync("test://resource/{id}", TestContext.Current.CancellationToken);
33+
Assert.Equal("template: {id}", ((TextResourceContents)exactTemplatedResult.Contents[0]).Text);
34+
35+
var paramsResult = await client.ReadResourceAsync("test://params?a1=a&a2=b&a3=c", TestContext.Current.CancellationToken);
36+
Assert.Equal("params: a, b, c", ((TextResourceContents)paramsResult.Contents[0]).Text);
37+
38+
var mcpEx = await Assert.ThrowsAsync<McpProtocolException>(async () => await client.ReadResourceAsync("test://params{?a1,a2,a3}", TestContext.Current.CancellationToken));
39+
Assert.Equal(McpErrorCode.InvalidParams, mcpEx.ErrorCode);
40+
Assert.Equal("Request failed (remote): Unknown resource URI: 'test://params{?a1,a2,a3}'", mcpEx.Message);
41+
}
42+
}

tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,7 +142,7 @@ public async Task UriTemplate_CreatedFromParameters_LotsOfTypesSupported()
142142
const string Name = "Hello";
143143

144144
McpServerResource t;
145-
ReadResourceResult? result;
145+
ReadResourceResult result;
146146
McpServer server = new Mock<McpServer>().Object;
147147

148148
t = McpServerResource.Create(() => "42", new() { Name = Name });
@@ -282,11 +282,12 @@ public async Task UriTemplate_CreatedFromParameters_LotsOfTypesSupported()
282282
[InlineData("resource://mcp/Hello?arg1=42&arg2=84")]
283283
[InlineData("resource://mcp/Hello?arg1=42&arg2=84&arg3=123")]
284284
[InlineData("resource://mcp/Hello#fragment")]
285-
public async Task UriTemplate_NonMatchingUri_ReturnsNull(string uri)
285+
public async Task UriTemplate_NonMatchingUri_DoesNotMatch(string uri)
286286
{
287287
McpServerResource t = McpServerResource.Create((string arg1) => arg1, new() { Name = "Hello" });
288288
Assert.Equal("resource://mcp/Hello{?arg1}", t.ProtocolResourceTemplate.UriTemplate);
289-
Assert.Null(await t.ReadAsync(
289+
Assert.False(t.IsMatch(uri));
290+
await Assert.ThrowsAsync<InvalidOperationException>(async () => await t.ReadAsync(
290291
new RequestContext<ReadResourceRequestParams>(new Mock<McpServer>().Object, CreateTestJsonRpcRequest()) { Params = new() { Uri = uri } },
291292
TestContext.Current.CancellationToken));
292293
}
@@ -337,7 +338,7 @@ public async Task UriTemplate_MissingOptionalParameter_Succeeds()
337338
McpServerResource t = McpServerResource.Create((string? arg1 = null, int? arg2 = null) => arg1 + arg2, new() { Name = "Hello" });
338339
Assert.Equal("resource://mcp/Hello{?arg1,arg2}", t.ProtocolResourceTemplate.UriTemplate);
339340

340-
ReadResourceResult? result;
341+
ReadResourceResult result;
341342

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

tests/ModelContextProtocol.Tests/Server/McpServerTests.cs

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -369,7 +369,7 @@ await Can_Handle_Requests(
369369
new ServerCapabilities
370370
{
371371
Resources = new()
372-
},
372+
},
373373
method: RequestMethods.ResourcesRead,
374374
configureOptions: options =>
375375
{
@@ -438,7 +438,7 @@ public async Task Can_Handle_List_Prompts_Requests_Throws_Exception_If_No_Handle
438438
public async Task Can_Handle_Get_Prompts_Requests()
439439
{
440440
await Can_Handle_Requests(
441-
new ServerCapabilities
441+
new ServerCapabilities
442442
{
443443
Prompts = new()
444444
},
@@ -466,7 +466,7 @@ public async Task Can_Handle_Get_Prompts_Requests_Throws_Exception_If_No_Handler
466466
public async Task Can_Handle_List_Tools_Requests()
467467
{
468468
await Can_Handle_Requests(
469-
new ServerCapabilities
469+
new ServerCapabilities
470470
{
471471
Tools = new()
472472
},
@@ -504,7 +504,7 @@ await Can_Handle_Requests(
504504
new ServerCapabilities
505505
{
506506
Tools = new()
507-
},
507+
},
508508
method: RequestMethods.ToolsCall,
509509
configureOptions: options =>
510510
{
@@ -626,7 +626,7 @@ await transport.SendMessageAsync(
626626
TestContext.Current.CancellationToken
627627
);
628628

629-
var error = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
629+
var error = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
630630
Assert.NotNull(error);
631631
Assert.NotNull(error.Error);
632632
Assert.Equal((int)errorCode, error.Error.Code);
@@ -662,7 +662,7 @@ await transport.SendMessageAsync(
662662
}
663663
);
664664

665-
var response = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(5));
665+
var response = await receivedMessage.Task.WaitAsync(TimeSpan.FromSeconds(10));
666666
Assert.NotNull(response);
667667

668668
assertResult(server, response.Result);
@@ -779,7 +779,7 @@ public override Task<JsonRpcResponse> SendRequestAsync(JsonRpcRequest request, C
779779
};
780780

781781
return Task.FromResult(new JsonRpcResponse
782-
{
782+
{
783783
Id = new RequestId("0"),
784784
Result = JsonSerializer.SerializeToNode(result, McpJsonUtilities.DefaultOptions),
785785
});

0 commit comments

Comments
 (0)