Skip to content

Commit 0b26b7b

Browse files
Fix URI template derivation and lookup (#530)
* Use lowercase host in resource URI template derivation. * Address feedback * Add casing testing for non-templated resource lookups. * Fix uri primitive lookup * Use a hardcoded mcp hostname in derived resource template URIs and ensure built-in resources work with the VS Code client. * Address feedback * Fix equality comparison * Use dedicated type for resource collections.
1 parent 283a052 commit 0b26b7b

File tree

11 files changed

+174
-75
lines changed

11 files changed

+174
-75
lines changed

samples/AspNetCoreSseServer/Program.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,14 @@
22
using OpenTelemetry.Metrics;
33
using OpenTelemetry.Trace;
44
using TestServerWithHosting.Tools;
5+
using TestServerWithHosting.Resources;
56

67
var builder = WebApplication.CreateBuilder(args);
78
builder.Services.AddMcpServer()
89
.WithHttpTransport()
910
.WithTools<EchoTool>()
10-
.WithTools<SampleLlmTool>();
11+
.WithTools<SampleLlmTool>()
12+
.WithResources<SimpleResourceType>();
1113

1214
builder.Services.AddOpenTelemetry()
1315
.WithTracing(b => b.AddSource("*")
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
using ModelContextProtocol.Protocol;
2+
using ModelContextProtocol.Server;
3+
using System.ComponentModel;
4+
5+
namespace TestServerWithHosting.Resources;
6+
7+
[McpServerResourceType]
8+
public class SimpleResourceType
9+
{
10+
[McpServerResource, Description("A direct text resource")]
11+
public static string DirectTextResource() => "This is a direct resource";
12+
}

src/ModelContextProtocol.Core/Protocol/ResourcesCapability.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,5 +103,5 @@ public sealed class ResourcesCapability
103103
/// </para>
104104
/// </remarks>
105105
[JsonIgnore]
106-
public McpServerPrimitiveCollection<McpServerResource>? ResourceCollection { get; set; }
106+
public McpServerResourceCollection? ResourceCollection { get; set; }
107107
}

src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -264,7 +264,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
264264
Name = name,
265265
Title = options?.Title,
266266
Description = options?.Description,
267-
MimeType = options?.MimeType,
267+
MimeType = options?.MimeType ?? "application/octet-stream",
268268
};
269269

270270
return new AIFunctionMcpServerResource(function, resource);
@@ -295,7 +295,7 @@ private static string DeriveUriTemplate(string name, AIFunction function)
295295
{
296296
StringBuilder template = new();
297297

298-
template.Append("resource://").Append(Uri.EscapeDataString(name));
298+
template.Append("resource://mcp/").Append(Uri.EscapeDataString(name));
299299

300300
if (function.JsonSchema.TryGetProperty("properties", out JsonElement properties))
301301
{
@@ -359,7 +359,7 @@ private AIFunctionMcpServerResource(AIFunction function, ResourceTemplate resour
359359
return null;
360360
}
361361
}
362-
else if (request.Params.Uri != ProtocolResource!.Uri)
362+
else if (!UriTemplate.UriTemplateComparer.Instance.Equals(request.Params.Uri, ProtocolResource!.Uri))
363363
{
364364
return null;
365365
}

src/ModelContextProtocol.Core/Server/McpServer.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -228,7 +228,7 @@ private void ConfigureResources(McpServerOptions options)
228228
var unsubscribeHandler = resourcesCapability.UnsubscribeFromResourcesHandler ?? (static async (_, __) => new EmptyResult());
229229
var resources = resourcesCapability.ResourceCollection;
230230
var listChanged = resourcesCapability.ListChanged;
231-
var subcribe = resourcesCapability.Subscribe;
231+
var subscribe = resourcesCapability.Subscribe;
232232

233233
// Handle resources provided via DI.
234234
if (resources is { IsEmpty: false })
@@ -309,7 +309,7 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure
309309
listChanged = true;
310310

311311
// TODO: Implement subscribe/unsubscribe logic for resource and resource template collections.
312-
// subcribe = true;
312+
// subscribe = true;
313313
}
314314

315315
ServerCapabilities.Resources.ListResourcesHandler = listResourcesHandler;
@@ -319,7 +319,7 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure
319319
ServerCapabilities.Resources.SubscribeToResourcesHandler = subscribeHandler;
320320
ServerCapabilities.Resources.UnsubscribeFromResourcesHandler = unsubscribeHandler;
321321
ServerCapabilities.Resources.ListChanged = listChanged;
322-
ServerCapabilities.Resources.Subscribe = subcribe;
322+
ServerCapabilities.Resources.Subscribe = subscribe;
323323

324324
SetHandler(
325325
RequestMethods.ResourcesList,

src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,14 @@ public class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
1010
where T : IMcpServerPrimitive
1111
{
1212
/// <summary>Concurrent dictionary of primitives, indexed by their names.</summary>
13-
private readonly ConcurrentDictionary<string, T> _primitives = [];
13+
private readonly ConcurrentDictionary<string, T> _primitives;
1414

1515
/// <summary>
1616
/// Initializes a new instance of the <see cref="McpServerPrimitiveCollection{T}"/> class.
1717
/// </summary>
18-
public McpServerPrimitiveCollection()
18+
public McpServerPrimitiveCollection(IEqualityComparer<string>? keyComparer = null)
1919
{
20+
_primitives = new(keyComparer);
2021
}
2122

2223
/// <summary>Occurs when the collection is changed.</summary>
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
namespace ModelContextProtocol.Server;
2+
3+
/// <summary>Provides a thread-safe collection of <see cref="McpServerResource"/> instances, indexed by their URI templates.</summary>
4+
public sealed class McpServerResourceCollection()
5+
: McpServerPrimitiveCollection<McpServerResource>(UriTemplate.UriTemplateComparer.Instance);

src/ModelContextProtocol.Core/UriTemplate.cs

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
using System.Buffers;
33
#endif
44
using System.Diagnostics;
5+
using System.Diagnostics.CodeAnalysis;
56
using System.Globalization;
67
using System.Runtime.CompilerServices;
78
using System.Text;
@@ -453,4 +454,53 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c)
453454
}
454455
}
455456
}
457+
458+
/// <summary>
459+
/// Defines an equality comparer for Uri templates as follows:
460+
/// 1. Non-templated Uris use regular System.Uri equality comparison (host name is case insensitive).
461+
/// 2. Templated Uris use regular string equality.
462+
///
463+
/// We do this because non-templated resources are looked up directly from the resource dictionary
464+
/// and we need to make sure equality is implemented correctly. Templated Uris are resolved in a
465+
/// fallback step using linear traversal of the resource dictionary, so their equality is only
466+
/// there to distinguish between different templates.
467+
/// </summary>
468+
public sealed class UriTemplateComparer : IEqualityComparer<string>
469+
{
470+
public static IEqualityComparer<string> Instance { get; } = new UriTemplateComparer();
471+
472+
public bool Equals(string? uriTemplate1, string? uriTemplate2)
473+
{
474+
if (TryParseAsNonTemplatedUri(uriTemplate1, out Uri? uri1) &&
475+
TryParseAsNonTemplatedUri(uriTemplate2, out Uri? uri2))
476+
{
477+
return uri1 == uri2;
478+
}
479+
480+
return string.Equals(uriTemplate1, uriTemplate2, StringComparison.Ordinal);
481+
}
482+
483+
public int GetHashCode([DisallowNull] string uriTemplate)
484+
{
485+
if (TryParseAsNonTemplatedUri(uriTemplate, out Uri? uri))
486+
{
487+
return uri.GetHashCode();
488+
}
489+
else
490+
{
491+
return StringComparer.Ordinal.GetHashCode(uriTemplate);
492+
}
493+
}
494+
495+
private static bool TryParseAsNonTemplatedUri(string? uriTemplate, [NotNullWhen(true)] out Uri? uri)
496+
{
497+
if (uriTemplate is null || uriTemplate.Contains('{'))
498+
{
499+
uri = null;
500+
return false;
501+
}
502+
503+
return Uri.TryCreate(uriTemplate, UriKind.Absolute, out uri);
504+
}
505+
}
456506
}

src/ModelContextProtocol/McpServerOptionsSetup.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,7 +63,7 @@ public void Configure(McpServerOptions options)
6363
// a collection, add to it, otherwise create a new one. We want to maintain the identity
6464
// of an existing collection in case someone has provided their own derived type, wants
6565
// change notifications, etc.
66-
McpServerPrimitiveCollection<McpServerResource> resourceCollection = options.Capabilities?.Resources?.ResourceCollection ?? [];
66+
McpServerResourceCollection resourceCollection = options.Capabilities?.Resources?.ResourceCollection ?? [];
6767
foreach (var resource in serverResources)
6868
{
6969
resourceCollection.TryAdd(resource);

tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,7 @@ public async Task Throws_When_Resource_Fails()
220220
await using IMcpClient client = await CreateMcpClientForServer();
221221

222222
await Assert.ThrowsAsync<McpException>(async () => await client.ReadResourceAsync(
223-
$"resource://{nameof(SimpleResources.ThrowsException)}",
223+
$"resource://mcp/{nameof(SimpleResources.ThrowsException)}",
224224
cancellationToken: TestContext.Current.CancellationToken));
225225
}
226226

@@ -230,7 +230,7 @@ public async Task Throws_Exception_On_Unknown_Resource()
230230
await using IMcpClient client = await CreateMcpClientForServer();
231231

232232
var e = await Assert.ThrowsAsync<McpException>(async () => await client.ReadResourceAsync(
233-
"test://NotRegisteredResource",
233+
"test:///NotRegisteredResource",
234234
cancellationToken: TestContext.Current.CancellationToken));
235235

236236
Assert.Contains("Resource not found", e.Message);
@@ -268,8 +268,8 @@ public void Register_Resources_From_Current_Assembly()
268268
sc.AddMcpServer().WithResourcesFromAssembly();
269269
IServiceProvider services = sc.BuildServiceProvider();
270270

271-
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResource?.Uri == $"resource://{nameof(SimpleResources.SomeNeatDirectResource)}");
272-
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://{nameof(SimpleResources.SomeNeatTemplatedResource)}{{?name}}");
271+
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResource?.Uri == $"resource://mcp/{nameof(SimpleResources.SomeNeatDirectResource)}");
272+
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://mcp/{nameof(SimpleResources.SomeNeatTemplatedResource)}{{?name}}");
273273
}
274274

275275
[Fact]
@@ -279,13 +279,13 @@ public void Register_Resources_From_Multiple_Sources()
279279
sc.AddMcpServer()
280280
.WithResources<SimpleResources>()
281281
.WithResources<MoreResources>()
282-
.WithResources([McpServerResource.Create(() => "42", new() { UriTemplate = "myResources://Returns42/{something}" })]);
282+
.WithResources([McpServerResource.Create(() => "42", new() { UriTemplate = "myResources:///returns42/{something}" })]);
283283
IServiceProvider services = sc.BuildServiceProvider();
284284

285-
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResource?.Uri == $"resource://{nameof(SimpleResources.SomeNeatDirectResource)}");
286-
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://{nameof(SimpleResources.SomeNeatTemplatedResource)}{{?name}}");
287-
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://{nameof(MoreResources.AnotherNeatDirectResource)}");
288-
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResourceTemplate.UriTemplate == "myResources://Returns42/{something}");
285+
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResource?.Uri == $"resource://mcp/{nameof(SimpleResources.SomeNeatDirectResource)}");
286+
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://mcp/{nameof(SimpleResources.SomeNeatTemplatedResource)}{{?name}}");
287+
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://mcp/{nameof(MoreResources.AnotherNeatDirectResource)}");
288+
Assert.Contains(services.GetServices<McpServerResource>(), t => t.ProtocolResourceTemplate.UriTemplate == "myResources:///returns42/{something}");
289289
}
290290

291291
[McpServerResourceType]

0 commit comments

Comments
 (0)