Skip to content
4 changes: 3 additions & 1 deletion samples/AspNetCoreSseServer/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using TestServerWithHosting.Tools;
using TestServerWithHosting.Resources;

var builder = WebApplication.CreateBuilder(args);
builder.Services.AddMcpServer()
.WithHttpTransport()
.WithTools<EchoTool>()
.WithTools<SampleLlmTool>();
.WithTools<SampleLlmTool>()
.WithResources<SimpleResourceType>();

builder.Services.AddOpenTelemetry()
.WithTracing(b => b.AddSource("*")
Expand Down
12 changes: 12 additions & 0 deletions samples/AspNetCoreSseServer/Resources/SimpleResourceType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using System.ComponentModel;

namespace TestServerWithHosting.Resources;

[McpServerResourceType]
public class SimpleResourceType
{
[McpServerResource, Description("A direct text resource")]
public static string DirectTextResource() => "This is a direct resource";
}
Original file line number Diff line number Diff line change
Expand Up @@ -103,5 +103,5 @@ public sealed class ResourcesCapability
/// </para>
/// </remarks>
[JsonIgnore]
public McpServerPrimitiveCollection<McpServerResource>? ResourceCollection { get; set; }
public McpServerResourceCollection? ResourceCollection { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ private static AIFunctionFactoryOptions CreateAIFunctionFactoryOptions(
Name = name,
Title = options?.Title,
Description = options?.Description,
MimeType = options?.MimeType,
MimeType = options?.MimeType ?? "application/octet-stream",
};

return new AIFunctionMcpServerResource(function, resource);
Expand Down Expand Up @@ -295,7 +295,7 @@ private static string DeriveUriTemplate(string name, AIFunction function)
{
StringBuilder template = new();

template.Append("resource://").Append(Uri.EscapeDataString(name));
template.Append("resource://mcp/").Append(Uri.EscapeDataString(name));

if (function.JsonSchema.TryGetProperty("properties", out JsonElement properties))
{
Expand Down Expand Up @@ -359,7 +359,7 @@ private AIFunctionMcpServerResource(AIFunction function, ResourceTemplate resour
return null;
}
}
else if (request.Params.Uri != ProtocolResource!.Uri)
else if (!UriTemplate.UriTemplateComparer.Instance.Equals(request.Params.Uri, ProtocolResource!.Uri))
{
return null;
}
Expand Down
6 changes: 3 additions & 3 deletions src/ModelContextProtocol.Core/Server/McpServer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ private void ConfigureResources(McpServerOptions options)
var unsubscribeHandler = resourcesCapability.UnsubscribeFromResourcesHandler ?? (static async (_, __) => new EmptyResult());
var resources = resourcesCapability.ResourceCollection;
var listChanged = resourcesCapability.ListChanged;
var subcribe = resourcesCapability.Subscribe;
var subscribe = resourcesCapability.Subscribe;

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

// TODO: Implement subscribe/unsubscribe logic for resource and resource template collections.
// subcribe = true;
// subscribe = true;
}

ServerCapabilities.Resources.ListResourcesHandler = listResourcesHandler;
Expand All @@ -319,7 +319,7 @@ await originalListResourceTemplatesHandler(request, cancellationToken).Configure
ServerCapabilities.Resources.SubscribeToResourcesHandler = subscribeHandler;
ServerCapabilities.Resources.UnsubscribeFromResourcesHandler = unsubscribeHandler;
ServerCapabilities.Resources.ListChanged = listChanged;
ServerCapabilities.Resources.Subscribe = subcribe;
ServerCapabilities.Resources.Subscribe = subscribe;

SetHandler(
RequestMethods.ResourcesList,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,14 @@ public class McpServerPrimitiveCollection<T> : ICollection<T>, IReadOnlyCollecti
where T : IMcpServerPrimitive
{
/// <summary>Concurrent dictionary of primitives, indexed by their names.</summary>
private readonly ConcurrentDictionary<string, T> _primitives = [];
private readonly ConcurrentDictionary<string, T> _primitives;

/// <summary>
/// Initializes a new instance of the <see cref="McpServerPrimitiveCollection{T}"/> class.
/// </summary>
public McpServerPrimitiveCollection()
public McpServerPrimitiveCollection(IEqualityComparer<string>? keyComparer = null)
{
_primitives = new(keyComparer);
}

/// <summary>Occurs when the collection is changed.</summary>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace ModelContextProtocol.Server;

/// <summary>Provides a thread-safe collection of <see cref="McpServerResource"/> instances, indexed by their URI templates.</summary>
public sealed class McpServerResourceCollection()
: McpServerPrimitiveCollection<McpServerResource>(UriTemplate.UriTemplateComparer.Instance);
50 changes: 50 additions & 0 deletions src/ModelContextProtocol.Core/UriTemplate.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Buffers;
#endif
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
using System.Runtime.CompilerServices;
using System.Text;
Expand Down Expand Up @@ -453,4 +454,53 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c)
}
}
}

/// <summary>
/// Defines an equality comparer for Uri templates as follows:
/// 1. Non-templated Uris use regular System.Uri equality comparison (host name is case insensitive).
/// 2. Templated Uris use regular string equality.
///
/// We do this because non-templated resources are looked up directly from the resource dictionary
/// and we need to make sure equality is implemented correctly. Templated Uris are resolved in a
/// fallback step using linear traversal of the resource dictionary, so their equality is only
/// there to distinguish between different templates.
/// </summary>
public sealed class UriTemplateComparer : IEqualityComparer<string>
{
public static IEqualityComparer<string> Instance { get; } = new UriTemplateComparer();

public bool Equals(string? uriTemplate1, string? uriTemplate2)
{
if (TryParseAsNonTemplatedUri(uriTemplate1, out Uri? uri1) &&
TryParseAsNonTemplatedUri(uriTemplate2, out Uri? uri2))
{
return uri1 == uri2;
}

return string.Equals(uriTemplate1, uriTemplate2, StringComparison.Ordinal);
}

public int GetHashCode([DisallowNull] string uriTemplate)
{
if (TryParseAsNonTemplatedUri(uriTemplate, out Uri? uri))
{
return uri.GetHashCode();
}
else
{
return StringComparer.Ordinal.GetHashCode(uriTemplate);
}
}

private static bool TryParseAsNonTemplatedUri(string? uriTemplate, [NotNullWhen(true)] out Uri? uri)
{
if (uriTemplate is null || uriTemplate.Contains('{'))
{
uri = null;
return false;
}

return Uri.TryCreate(uriTemplate, UriKind.Absolute, out uri);
}
}
}
2 changes: 1 addition & 1 deletion src/ModelContextProtocol/McpServerOptionsSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ public void Configure(McpServerOptions options)
// a collection, add to it, otherwise create a new one. We want to maintain the identity
// of an existing collection in case someone has provided their own derived type, wants
// change notifications, etc.
McpServerPrimitiveCollection<McpServerResource> resourceCollection = options.Capabilities?.Resources?.ResourceCollection ?? [];
McpServerResourceCollection resourceCollection = options.Capabilities?.Resources?.ResourceCollection ?? [];
foreach (var resource in serverResources)
{
resourceCollection.TryAdd(resource);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,7 @@ public async Task Throws_When_Resource_Fails()
await using IMcpClient client = await CreateMcpClientForServer();

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

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

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

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

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

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

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

[McpServerResourceType]
Expand Down
Loading