diff --git a/samples/AspNetCoreSseServer/Program.cs b/samples/AspNetCoreSseServer/Program.cs index f24b6a17c..c21b328f6 100644 --- a/samples/AspNetCoreSseServer/Program.cs +++ b/samples/AspNetCoreSseServer/Program.cs @@ -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() - .WithTools(); + .WithTools() + .WithResources(); builder.Services.AddOpenTelemetry() .WithTracing(b => b.AddSource("*") diff --git a/samples/AspNetCoreSseServer/Resources/SimpleResourceType.cs b/samples/AspNetCoreSseServer/Resources/SimpleResourceType.cs new file mode 100644 index 000000000..e73ce133c --- /dev/null +++ b/samples/AspNetCoreSseServer/Resources/SimpleResourceType.cs @@ -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"; +} diff --git a/src/ModelContextProtocol.Core/Protocol/ResourcesCapability.cs b/src/ModelContextProtocol.Core/Protocol/ResourcesCapability.cs index 1332a6aa4..f6486488b 100644 --- a/src/ModelContextProtocol.Core/Protocol/ResourcesCapability.cs +++ b/src/ModelContextProtocol.Core/Protocol/ResourcesCapability.cs @@ -103,5 +103,5 @@ public sealed class ResourcesCapability /// /// [JsonIgnore] - public McpServerPrimitiveCollection? ResourceCollection { get; set; } + public McpServerResourceCollection? ResourceCollection { get; set; } } \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs index 487fed74c..88b3eee30 100644 --- a/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs +++ b/src/ModelContextProtocol.Core/Server/AIFunctionMcpServerResource.cs @@ -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); @@ -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)) { @@ -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; } diff --git a/src/ModelContextProtocol.Core/Server/McpServer.cs b/src/ModelContextProtocol.Core/Server/McpServer.cs index 829e0a865..6c5858f91 100644 --- a/src/ModelContextProtocol.Core/Server/McpServer.cs +++ b/src/ModelContextProtocol.Core/Server/McpServer.cs @@ -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 }) @@ -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; @@ -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, diff --git a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs index 7bfe0232f..f891858eb 100644 --- a/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs +++ b/src/ModelContextProtocol.Core/Server/McpServerPrimitiveCollection.cs @@ -10,13 +10,14 @@ public class McpServerPrimitiveCollection : ICollection, IReadOnlyCollecti where T : IMcpServerPrimitive { /// Concurrent dictionary of primitives, indexed by their names. - private readonly ConcurrentDictionary _primitives = []; + private readonly ConcurrentDictionary _primitives; /// /// Initializes a new instance of the class. /// - public McpServerPrimitiveCollection() + public McpServerPrimitiveCollection(IEqualityComparer? keyComparer = null) { + _primitives = new(keyComparer); } /// Occurs when the collection is changed. diff --git a/src/ModelContextProtocol.Core/Server/McpServerResourceCollection.cs b/src/ModelContextProtocol.Core/Server/McpServerResourceCollection.cs new file mode 100644 index 000000000..fb5f6b4e2 --- /dev/null +++ b/src/ModelContextProtocol.Core/Server/McpServerResourceCollection.cs @@ -0,0 +1,5 @@ +namespace ModelContextProtocol.Server; + +/// Provides a thread-safe collection of instances, indexed by their URI templates. +public sealed class McpServerResourceCollection() + : McpServerPrimitiveCollection(UriTemplate.UriTemplateComparer.Instance); \ No newline at end of file diff --git a/src/ModelContextProtocol.Core/UriTemplate.cs b/src/ModelContextProtocol.Core/UriTemplate.cs index bc6b70c9f..a822b6f2a 100644 --- a/src/ModelContextProtocol.Core/UriTemplate.cs +++ b/src/ModelContextProtocol.Core/UriTemplate.cs @@ -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; @@ -453,4 +454,53 @@ static void AppendHex(ref DefaultInterpolatedStringHandler builder, char c) } } } + + /// + /// 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. + /// + public sealed class UriTemplateComparer : IEqualityComparer + { + public static IEqualityComparer 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); + } + } } \ No newline at end of file diff --git a/src/ModelContextProtocol/McpServerOptionsSetup.cs b/src/ModelContextProtocol/McpServerOptionsSetup.cs index effa41463..7fe4f61cb 100644 --- a/src/ModelContextProtocol/McpServerOptionsSetup.cs +++ b/src/ModelContextProtocol/McpServerOptionsSetup.cs @@ -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 resourceCollection = options.Capabilities?.Resources?.ResourceCollection ?? []; + McpServerResourceCollection resourceCollection = options.Capabilities?.Resources?.ResourceCollection ?? []; foreach (var resource in serverResources) { resourceCollection.TryAdd(resource); diff --git a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs index 7cee174da..42e852782 100644 --- a/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs +++ b/tests/ModelContextProtocol.Tests/Configuration/McpServerBuilderExtensionsResourcesTests.cs @@ -220,7 +220,7 @@ public async Task Throws_When_Resource_Fails() await using IMcpClient client = await CreateMcpClientForServer(); await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( - $"resource://{nameof(SimpleResources.ThrowsException)}", + $"resource://mcp/{nameof(SimpleResources.ThrowsException)}", cancellationToken: TestContext.Current.CancellationToken)); } @@ -230,7 +230,7 @@ public async Task Throws_Exception_On_Unknown_Resource() await using IMcpClient client = await CreateMcpClientForServer(); var e = await Assert.ThrowsAsync(async () => await client.ReadResourceAsync( - "test://NotRegisteredResource", + "test:///NotRegisteredResource", cancellationToken: TestContext.Current.CancellationToken)); Assert.Contains("Resource not found", e.Message); @@ -268,8 +268,8 @@ public void Register_Resources_From_Current_Assembly() sc.AddMcpServer().WithResourcesFromAssembly(); IServiceProvider services = sc.BuildServiceProvider(); - Assert.Contains(services.GetServices(), t => t.ProtocolResource?.Uri == $"resource://{nameof(SimpleResources.SomeNeatDirectResource)}"); - Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://{nameof(SimpleResources.SomeNeatTemplatedResource)}{{?name}}"); + Assert.Contains(services.GetServices(), t => t.ProtocolResource?.Uri == $"resource://mcp/{nameof(SimpleResources.SomeNeatDirectResource)}"); + Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://mcp/{nameof(SimpleResources.SomeNeatTemplatedResource)}{{?name}}"); } [Fact] @@ -279,13 +279,13 @@ public void Register_Resources_From_Multiple_Sources() sc.AddMcpServer() .WithResources() .WithResources() - .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(), t => t.ProtocolResource?.Uri == $"resource://{nameof(SimpleResources.SomeNeatDirectResource)}"); - Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://{nameof(SimpleResources.SomeNeatTemplatedResource)}{{?name}}"); - Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://{nameof(MoreResources.AnotherNeatDirectResource)}"); - Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate.UriTemplate == "myResources://Returns42/{something}"); + Assert.Contains(services.GetServices(), t => t.ProtocolResource?.Uri == $"resource://mcp/{nameof(SimpleResources.SomeNeatDirectResource)}"); + Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://mcp/{nameof(SimpleResources.SomeNeatTemplatedResource)}{{?name}}"); + Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate?.UriTemplate == $"resource://mcp/{nameof(MoreResources.AnotherNeatDirectResource)}"); + Assert.Contains(services.GetServices(), t => t.ProtocolResourceTemplate.UriTemplate == "myResources:///returns42/{something}"); } [McpServerResourceType] diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs index 3e25d4e92..ef5965d2e 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs @@ -122,159 +122,188 @@ public void Create_InvalidArgs_Throws() public async Task UriTemplate_CreatedFromParameters_LotsOfTypesSupported() { const string Name = "Hello"; + McpServerResource t; ReadResourceResult? result; IMcpServer server = new Mock().Object; t = McpServerResource.Create(() => "42", new() { Name = Name }); - Assert.Equal($"resource://{Name}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal("resource://mcp/Hello", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}" } }, + new RequestContext(server) { Params = new() { Uri = "resource://mcp/Hello" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((IMcpServer server) => "42", new() { Name = Name }); - Assert.Equal($"resource://{Name}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal("resource://mcp/Hello", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}" } }, + new RequestContext(server) { Params = new() { Uri = "resource://mcp/Hello" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((string arg1) => arg1, new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?arg1}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?arg1}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?arg1=wOrLd" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?arg1=wOrLd" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("wOrLd", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((string arg1, string? arg2 = null) => arg1 + arg2, new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?arg1,arg2}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?arg1,arg2}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?arg1=wo&arg2=rld" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?arg1=wo&arg2=rld" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("world", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((object a1, bool a2, char a3, byte a4, sbyte a5) => a1.ToString() + a2 + a3 + a4 + a5, new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=hi&a2=true&a3=s&a4=12&a5=34" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a1=hi&a2=true&a3=s&a4=12&a5=34" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("hiTrues1234", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((ushort a1, short a2, uint a3, int a4, ulong a5) => (a1 + a2 + a3 + a4 + (long)a5).ToString(), new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=10&a2=20&a3=30&a4=40&a5=50" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a1=10&a2=20&a3=30&a4=40&a5=50" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("150", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((long a1, float a2, double a3, decimal a4, TimeSpan a5) => a5.ToString(), new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=1&a2=2&a3=3&a4=4&a5=5" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a1=1&a2=2&a3=3&a4=4&a5=5" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("5.00:00:00", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((DateTime a1, DateTimeOffset a2, Uri a3, Guid a4, Version a5) => a4.ToString("N") + a5, new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1={DateTime.UtcNow:r}&a2={DateTimeOffset.UtcNow:r}&a3=http%3A%2F%2Ftest&a4=14e5f43d-0d41-47d6-8207-8249cf669e41&a5=1.2.3.4" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a1={DateTime.UtcNow:r}&a2={DateTimeOffset.UtcNow:r}&a3=http%3A%2F%2Ftest&a4=14e5f43d-0d41-47d6-8207-8249cf669e41&a5=1.2.3.4" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("14e5f43d0d4147d682078249cf669e411.2.3.4", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((Half a2, Int128 a3, UInt128 a4, IntPtr a5) => (a3 + (Int128)a4 + a5).ToString(), new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a2=1.0&a3=3&a4=4&a5=5" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a2=1.0&a3=3&a4=4&a5=5" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("12", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((UIntPtr a1, DateOnly a2, TimeOnly a3) => a1.ToString(), new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a1,a2,a3}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a1,a2,a3}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=123&a2=0001-02-03&a3=01%3A02%3A03" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a1=123&a2=0001-02-03&a3=01%3A02%3A03" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("123", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((bool? a2, char? a3, byte? a4, sbyte? a5) => a2?.ToString() + a3 + a4 + a5, new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a2=true&a3=s&a4=12&a5=34" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a2=true&a3=s&a4=12&a5=34" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("Trues1234", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((ushort? a1, short? a2, uint? a3, int? a4, ulong? a5) => (a1 + a2 + a3 + a4 + (long?)a5).ToString(), new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=10&a2=20&a3=30&a4=40&a5=50" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a1=10&a2=20&a3=30&a4=40&a5=50" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("150", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((long? a1, float? a2, double? a3, decimal? a4, TimeSpan? a5) => a5?.ToString(), new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a1,a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=1&a2=2&a3=3&a4=4&a5=5" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a1=1&a2=2&a3=3&a4=4&a5=5" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("5.00:00:00", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((DateTime? a1, DateTimeOffset? a2, Guid? a4) => a4?.ToString("N"), new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a1,a2,a4}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a1,a2,a4}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1={DateTime.UtcNow:r}&a2={DateTimeOffset.UtcNow:r}&a4=14e5f43d-0d41-47d6-8207-8249cf669e41" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a1={DateTime.UtcNow:r}&a2={DateTimeOffset.UtcNow:r}&a4=14e5f43d-0d41-47d6-8207-8249cf669e41" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("14e5f43d0d4147d682078249cf669e41", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((Half? a2, Int128? a3, UInt128? a4, IntPtr? a5) => (a3 + (Int128?)a4 + a5).ToString(), new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a2,a3,a4,a5}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a2=1.0&a3=3&a4=4&a5=5" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a2=1.0&a3=3&a4=4&a5=5" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("12", ((TextResourceContents)result.Contents[0]).Text); t = McpServerResource.Create((UIntPtr? a1, DateOnly? a2, TimeOnly? a3) => a1?.ToString(), new() { Name = Name }); - Assert.Equal($"resource://{Name}{{?a1,a2,a3}}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal($"resource://mcp/Hello{{?a1,a2,a3}}", t.ProtocolResourceTemplate.UriTemplate); result = await t.ReadAsync( - new RequestContext(server) { Params = new() { Uri = $"resource://{Name}?a1=123&a2=0001-02-03&a3=01%3A02%3A03" } }, + new RequestContext(server) { Params = new() { Uri = $"resource://mcp/Hello?a1=123&a2=0001-02-03&a3=01%3A02%3A03" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("123", ((TextResourceContents)result.Contents[0]).Text); } [Theory] - [InlineData("resource://Hello?arg1=42&arg2=84")] - [InlineData("resource://Hello?arg1=42&arg2=84&arg3=123")] - [InlineData("resource://Hello#fragment")] + [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) { McpServerResource t = McpServerResource.Create((string arg1) => arg1, new() { Name = "Hello" }); - Assert.Equal("resource://Hello{?arg1}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal("resource://mcp/Hello{?arg1}", t.ProtocolResourceTemplate.UriTemplate); Assert.Null(await t.ReadAsync( new RequestContext(new Mock().Object) { Params = new() { Uri = uri } }, TestContext.Current.CancellationToken)); } [Theory] - [InlineData("resource://Hello?arg1=test")] - [InlineData("resource://Hello?arg2=test")] + [InlineData("resource://MyCoolResource", "resource://mycoolresource")] + [InlineData("resource://MyCoolResource{?arg1}", "resource://mycoolresource?arg1=42")] + public async Task UriTemplate_IsHostCaseInsensitive(string actualUri, string queriedUri) + { + McpServerResource t = McpServerResource.Create(() => "resource", new() { UriTemplate = actualUri }); + Assert.NotNull(await t.ReadAsync( + new RequestContext(new Mock().Object) { Params = new() { Uri = queriedUri } }, + TestContext.Current.CancellationToken)); + } + + [Fact] + public async Task ResourceCollection_UsesCaseInsensitiveHostLookup() + { + McpServerResource t1 = McpServerResource.Create(() => "resource", new() { UriTemplate = "resource://MyCoolResource" }); + McpServerResource t2 = McpServerResource.Create(() => "resource", new() { UriTemplate = "resource://MyCoolResource2" }); + McpServerResourceCollection collection = new() { t1, t2 }; + Assert.True(collection.TryGetPrimitive("resource://mycoolresource", out McpServerResource? result)); + Assert.Same(t1, result); + } + + [Fact] + public void MimeType_DefaultsToOctetStream() + { + McpServerResource t = McpServerResource.Create(() => "resource", new() { Name = "My Cool Resource" }); + Assert.Equal("application/octet-stream", t.ProtocolResourceTemplate.MimeType); + } + + [Theory] + [InlineData("resource://mcp/Hello?arg1=test")] + [InlineData("resource://mcp/Hello?arg2=test")] public async Task UriTemplate_MissingParameter_Throws(string uri) { McpServerResource t = McpServerResource.Create((string arg1, int arg2) => arg1, new() { Name = "Hello" }); - Assert.Equal("resource://Hello{?arg1,arg2}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal("resource://mcp/Hello{?arg1,arg2}", t.ProtocolResourceTemplate.UriTemplate); await Assert.ThrowsAsync(async () => await t.ReadAsync( new RequestContext(new Mock().Object) { Params = new() { Uri = uri } }, TestContext.Current.CancellationToken)); @@ -284,30 +313,30 @@ await Assert.ThrowsAsync(async () => await t.ReadAsync( public async Task UriTemplate_MissingOptionalParameter_Succeeds() { McpServerResource t = McpServerResource.Create((string? arg1 = null, int? arg2 = null) => arg1 + arg2, new() { Name = "Hello" }); - Assert.Equal("resource://Hello{?arg1,arg2}", t.ProtocolResourceTemplate.UriTemplate); + Assert.Equal("resource://mcp/Hello{?arg1,arg2}", t.ProtocolResourceTemplate.UriTemplate); ReadResourceResult? result; result = await t.ReadAsync( - new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://Hello" } }, + new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://mcp/Hello" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("", ((TextResourceContents)result.Contents[0]).Text); result = await t.ReadAsync( - new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://Hello?arg1=first" } }, + new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://mcp/Hello?arg1=first" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("first", ((TextResourceContents)result.Contents[0]).Text); result = await t.ReadAsync( - new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://Hello?arg2=42" } }, + new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://mcp/Hello?arg2=42" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); result = await t.ReadAsync( - new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://Hello?arg1=first&arg2=42" } }, + new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://mcp/Hello?arg1=first&arg2=42" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("first42", ((TextResourceContents)result.Contents[0]).Text); @@ -325,7 +354,7 @@ public async Task SupportsIMcpServer() }, new() { Name = "Test" }); var result = await resource.ReadAsync( - new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); @@ -376,11 +405,11 @@ public async Task SupportsServiceFromDI(ServiceLifetime injectedArgumentLifetime Mock mockServer = new(); await Assert.ThrowsAnyAsync(async () => await resource.ReadAsync( - new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken)); var result = await resource.ReadAsync( - new RequestContext(mockServer.Object) { Services = services, Params = new() { Uri = "resource://Test" } }, + new RequestContext(mockServer.Object) { Services = services, Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); @@ -402,7 +431,7 @@ public async Task SupportsOptionalServiceFromDI() }, new() { Services = services, Name = "Test" }); var result = await resource.ReadAsync( - new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://Test" } }, + new RequestContext(new Mock().Object) { Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal("42", ((TextResourceContents)result.Contents[0]).Text); @@ -436,7 +465,7 @@ public async Task CanReturnReadResult() return new ReadResourceResult { Contents = new List { new TextResourceContents { Text = "hello" } } }; }, new() { Name = "Test" }); var result = await resource.ReadAsync( - new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Contents); @@ -453,7 +482,7 @@ public async Task CanReturnResourceContents() return new TextResourceContents { Text = "hello" }; }, new() { Name = "Test", SerializerOptions = JsonContext6.Default.Options }); var result = await resource.ReadAsync( - new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Contents); @@ -474,7 +503,7 @@ public async Task CanReturnCollectionOfResourceContents() ]; }, new() { Name = "Test" }); var result = await resource.ReadAsync( - new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal(2, result.Contents.Count); @@ -492,7 +521,7 @@ public async Task CanReturnString() return "42"; }, new() { Name = "Test" }); var result = await resource.ReadAsync( - new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Contents); @@ -509,7 +538,7 @@ public async Task CanReturnCollectionOfStrings() return new List { "42", "43" }; }, new() { Name = "Test", SerializerOptions = JsonContext6.Default.Options }); var result = await resource.ReadAsync( - new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal(2, result.Contents.Count); @@ -527,7 +556,7 @@ public async Task CanReturnDataContent() return new DataContent(new byte[] { 0, 1, 2 }, "application/octet-stream"); }, new() { Name = "Test" }); var result = await resource.ReadAsync( - new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Single(result.Contents); @@ -549,7 +578,7 @@ public async Task CanReturnCollectionOfAIContent() }; }, new() { Name = "Test", SerializerOptions = JsonContext6.Default.Options }); var result = await resource.ReadAsync( - new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://Test" } }, + new RequestContext(mockServer.Object) { Params = new() { Uri = "resource://mcp/Test" } }, TestContext.Current.CancellationToken); Assert.NotNull(result); Assert.Equal(2, result.Contents.Count);