diff --git a/src/ModelContextProtocol/AIContentExtensions.cs b/src/ModelContextProtocol/AIContentExtensions.cs index be5eabe2f..0fe00fb4d 100644 --- a/src/ModelContextProtocol/AIContentExtensions.cs +++ b/src/ModelContextProtocol/AIContentExtensions.cs @@ -39,11 +39,7 @@ public static AIContent ToAIContent(this Content content) } else if (content is { Type: "resource" } && content.Resource is { } resourceContents) { - ac = resourceContents.Blob is not null && resourceContents.MimeType is not null ? - new DataContent(Convert.FromBase64String(resourceContents.Blob), resourceContents.MimeType) : - new TextContent(resourceContents.Text); - - (ac.AdditionalProperties ??= [])["uri"] = resourceContents.Uri; + ac = resourceContents.ToAIContent(); } else { @@ -62,9 +58,12 @@ public static AIContent ToAIContent(this ResourceContents content) { Throw.IfNull(content); - AIContent ac = content.Blob is not null && content.MimeType is not null ? - new DataContent(Convert.FromBase64String(content.Blob), content.MimeType) : - new TextContent(content.Text); + AIContent ac = content switch + { + BlobResourceContents blobResource => new DataContent(Convert.FromBase64String(blobResource.Blob), blobResource.MimeType ?? "application/octet-stream"), + TextResourceContents textResource => new TextContent(textResource.Text), + _ => throw new NotSupportedException($"Resource type '{content.GetType().Name}' is not supported.") + }; (ac.AdditionalProperties ??= [])["uri"] = content.Uri; ac.RawRepresentation = content; @@ -79,7 +78,7 @@ public static IList ToAIContents(this IEnumerable contents) { Throw.IfNull(contents); - return contents.Select(ToAIContent).ToList(); + return [.. contents.Select(ToAIContent)]; } /// Creates a list of from a sequence of . @@ -89,7 +88,7 @@ public static IList ToAIContents(this IEnumerable c { Throw.IfNull(contents); - return contents.Select(ToAIContent).ToList(); + return [.. contents.Select(ToAIContent)]; } /// Extracts the data from a as a Base64 string. diff --git a/src/ModelContextProtocol/Client/McpClientExtensions.cs b/src/ModelContextProtocol/Client/McpClientExtensions.cs index ced728e7a..436adb264 100644 --- a/src/ModelContextProtocol/Client/McpClientExtensions.cs +++ b/src/ModelContextProtocol/Client/McpClientExtensions.cs @@ -475,17 +475,7 @@ internal static (IList Messages, ChatOptions? Options) ToChatClient } else if (sm.Content is { Type: "resource", Resource: not null }) { - ResourceContents resource = sm.Content.Resource; - - if (resource.Text is not null) - { - message.Contents.Add(new TextContent(resource.Text)); - } - - if (resource.Blob is not null && resource.MimeType is not null) - { - message.Contents.Add(new DataContent(Convert.FromBase64String(resource.Blob), resource.MimeType)); - } + message.Contents.Add(sm.Content.Resource.ToAIContent()); } messages.Add(message); diff --git a/src/ModelContextProtocol/Protocol/Types/BlobResourceContents.cs b/src/ModelContextProtocol/Protocol/Types/BlobResourceContents.cs new file mode 100644 index 000000000..f0a16eaf5 --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/BlobResourceContents.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol.Types; + +/// +/// Binary contents of a resource. +/// See the schema for details +/// +public class BlobResourceContents : ResourceContents +{ + /// + /// The base64-encoded string representing the binary data of the item. + /// + [JsonPropertyName("blob")] + public string Blob { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/ModelContextProtocol/Protocol/Types/Resource.cs b/src/ModelContextProtocol/Protocol/Types/Resource.cs index 249c58464..b5fc15feb 100644 --- a/src/ModelContextProtocol/Protocol/Types/Resource.cs +++ b/src/ModelContextProtocol/Protocol/Types/Resource.cs @@ -16,12 +16,16 @@ public record Resource /// /// A human-readable name for this resource. + /// + /// This can be used by clients to populate UI elements. /// [JsonPropertyName("name")] public required string Name { get; init; } /// /// A description of what this resource represents. + /// + /// This can be used by clients to improve the LLM's understanding of available resources. It can be thought of like a \"hint\" to the model. /// [JsonPropertyName("description")] public string? Description { get; init; } @@ -32,6 +36,14 @@ public record Resource [JsonPropertyName("mimeType")] public string? MimeType { get; init; } + /// + /// The size of the raw resource content, in bytes (i.e., before base64 encoding or any tokenization), if known. + /// + /// This can be used by Hosts to display file sizes and estimate context window usage. + /// + [JsonPropertyName("size")] + public long? Size { get; init; } + /// /// Optional annotations for the resource. /// diff --git a/src/ModelContextProtocol/Protocol/Types/ResourceContents.cs b/src/ModelContextProtocol/Protocol/Types/ResourceContents.cs index 4dc524a2c..1daece16b 100644 --- a/src/ModelContextProtocol/Protocol/Types/ResourceContents.cs +++ b/src/ModelContextProtocol/Protocol/Types/ResourceContents.cs @@ -1,4 +1,7 @@ -using System.Text.Json.Serialization; +using System.ComponentModel; +using System.Diagnostics; +using System.Text.Json; +using System.Text.Json.Serialization; namespace ModelContextProtocol.Protocol.Types; @@ -6,8 +9,13 @@ namespace ModelContextProtocol.Protocol.Types; /// Represents the content of a resource. /// See the schema for details /// -public class ResourceContents +[JsonConverter(typeof(Converter))] +public abstract class ResourceContents { + internal ResourceContents() + { + } + /// /// The URI of the resource. /// @@ -20,16 +28,104 @@ public class ResourceContents [JsonPropertyName("mimeType")] public string? MimeType { get; set; } + /// - /// The text content of the resource. + /// Converter for . /// - [JsonPropertyName("text")] - public string? Text { get; set; } + [EditorBrowsable(EditorBrowsableState.Never)] + public class Converter : JsonConverter + { + /// + public override ResourceContents? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) + { + if (reader.TokenType == JsonTokenType.Null) + { + return null; + } + if (reader.TokenType != JsonTokenType.StartObject) + { + throw new JsonException(); + } - /// - /// The base64-encoded binary content of the resource. - /// - [JsonPropertyName("blob")] - public string? Blob { get; set; } -} \ No newline at end of file + string? uri = null; + string? mimeType = null; + string? blob = null; + string? text = null; + + while (reader.Read() && reader.TokenType != JsonTokenType.EndObject) + { + if (reader.TokenType != JsonTokenType.PropertyName) + { + continue; + } + + string? propertyName = reader.GetString(); + + switch (propertyName) + { + case "uri": + uri = reader.GetString(); + break; + case "mimeType": + mimeType = reader.GetString(); + break; + case "blob": + blob = reader.GetString(); + break; + case "text": + text = reader.GetString(); + break; + default: + break; + } + } + + if (blob is not null) + { + return new BlobResourceContents + { + Uri = uri ?? string.Empty, + MimeType = mimeType, + Blob = blob + }; + } + + if (text is not null) + { + return new TextResourceContents + { + Uri = uri ?? string.Empty, + MimeType = mimeType, + Text = text + }; + } + + return null; + } + + /// + public override void Write(Utf8JsonWriter writer, ResourceContents value, JsonSerializerOptions options) + { + if (value is null) + { + writer.WriteNullValue(); + return; + } + + writer.WriteStartObject(); + writer.WriteString("uri", value.Uri); + writer.WriteString("mimeType", value.MimeType); + Debug.Assert(value is BlobResourceContents or TextResourceContents); + if (value is BlobResourceContents blobResource) + { + writer.WriteString("blob", blobResource.Blob); + } + else if (value is TextResourceContents textResource) + { + writer.WriteString("text", textResource.Text); + } + writer.WriteEndObject(); + } + } +} diff --git a/src/ModelContextProtocol/Protocol/Types/TextResourceContents.cs b/src/ModelContextProtocol/Protocol/Types/TextResourceContents.cs new file mode 100644 index 000000000..dd14a6c25 --- /dev/null +++ b/src/ModelContextProtocol/Protocol/Types/TextResourceContents.cs @@ -0,0 +1,16 @@ +using System.Text.Json.Serialization; + +namespace ModelContextProtocol.Protocol.Types; + +/// +/// Text contents of a resource. +/// See the schema for details +/// +public class TextResourceContents : ResourceContents +{ + /// + /// The text of the item. This must only be set if the item can actually be represented as text (not binary data). + /// + [JsonPropertyName("text")] + public string Text { get; set; } = string.Empty; +} diff --git a/src/ModelContextProtocol/Server/McpServer.cs b/src/ModelContextProtocol/Server/McpServer.cs index af2c66443..f2ed6e3ed 100644 --- a/src/ModelContextProtocol/Server/McpServer.cs +++ b/src/ModelContextProtocol/Server/McpServer.cs @@ -218,19 +218,21 @@ private void SetResourcesHandler(McpServerOptions options) return; } - if (resourcesCapability.ListResourcesHandler is not { } listResourcesHandler || + var listResourcesHandler = resourcesCapability.ListResourcesHandler; + var listResourceTemplatesHandler = resourcesCapability.ListResourceTemplatesHandler; + + if ((listResourcesHandler is not { } && listResourceTemplatesHandler is not { }) || resourcesCapability.ReadResourceHandler is not { } readResourceHandler) { throw new McpServerException("Resources capability was enabled, but ListResources and/or ReadResource handlers were not specified."); } + listResourcesHandler ??= (static (_, _) => Task.FromResult(new ListResourcesResult())); + SetRequestHandler("resources/list", (request, ct) => listResourcesHandler(new(this, request), ct)); SetRequestHandler("resources/read", (request, ct) => readResourceHandler(new(this, request), ct)); - // Set the list resource templates handler, or use the default if not specified - var listResourceTemplatesHandler = resourcesCapability.ListResourceTemplatesHandler - ?? (static (_, _) => Task.FromResult(new ListResourceTemplatesResult())); - + listResourceTemplatesHandler ??= (static (_, _) => Task.FromResult(new ListResourceTemplatesResult())); SetRequestHandler("resources/templates/list", (request, ct) => listResourceTemplatesHandler(new(this, request), ct)); if (resourcesCapability.Subscribe is not true) diff --git a/tests/ModelContextProtocol.TestServer/Program.cs b/tests/ModelContextProtocol.TestServer/Program.cs index 6cb30a1eb..312013fa5 100644 --- a/tests/ModelContextProtocol.TestServer/Program.cs +++ b/tests/ModelContextProtocol.TestServer/Program.cs @@ -327,7 +327,7 @@ private static ResourcesCapability ConfigureResources() Name = $"Resource {i + 1}", MimeType = "text/plain" }); - resourceContents.Add(new ResourceContents() + resourceContents.Add(new TextResourceContents() { Uri = uri, MimeType = "text/plain", @@ -343,7 +343,7 @@ private static ResourcesCapability ConfigureResources() Name = $"Resource {i + 1}", MimeType = "application/octet-stream" }); - resourceContents.Add(new ResourceContents() + resourceContents.Add(new BlobResourceContents() { Uri = uri, MimeType = "application/octet-stream", @@ -417,7 +417,7 @@ private static ResourcesCapability ConfigureResources() return Task.FromResult(new ReadResourceResult() { Contents = [ - new ResourceContents() + new TextResourceContents() { Uri = request.Params.Uri, MimeType = "text/plain", diff --git a/tests/ModelContextProtocol.TestSseServer/Program.cs b/tests/ModelContextProtocol.TestSseServer/Program.cs index df722a333..b8751121b 100644 --- a/tests/ModelContextProtocol.TestSseServer/Program.cs +++ b/tests/ModelContextProtocol.TestSseServer/Program.cs @@ -81,7 +81,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st Name = $"Resource {i + 1}", MimeType = "text/plain" }); - resourceContents.Add(new ResourceContents() + resourceContents.Add(new TextResourceContents() { Uri = uri, MimeType = "text/plain", @@ -97,7 +97,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st Name = $"Resource {i + 1}", MimeType = "application/octet-stream" }); - resourceContents.Add(new ResourceContents() + resourceContents.Add(new BlobResourceContents() { Uri = uri, MimeType = "application/octet-stream", @@ -262,7 +262,7 @@ static CreateMessageRequestParams CreateRequestSamplingParams(string context, st return Task.FromResult(new ReadResourceResult() { Contents = [ - new ResourceContents() + new TextResourceContents() { Uri = request.Params.Uri, MimeType = "text/plain", diff --git a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs index 8b8be8049..76d91f656 100644 --- a/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/ClientIntegrationTests.cs @@ -224,7 +224,9 @@ public async Task ReadResource_Stdio_TextResource(string clientId) Assert.NotNull(result); Assert.Single(result.Contents); - Assert.NotNull(result.Contents[0].Text); + + TextResourceContents textResource = Assert.IsType(result.Contents[0]); + Assert.NotNull(textResource.Text); } [Theory] @@ -241,7 +243,9 @@ public async Task ReadResource_Stdio_BinaryResource(string clientId) Assert.NotNull(result); Assert.Single(result.Contents); - Assert.NotNull(result.Contents[0].Blob); + + BlobResourceContents blobResource = Assert.IsType(result.Contents[0]); + Assert.NotNull(blobResource.Blob); } // Not supported by "everything" server version on npx diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs new file mode 100644 index 000000000..ff9463331 --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerResourceTests.cs @@ -0,0 +1,100 @@ +using Microsoft.Extensions.DependencyInjection; +using ModelContextProtocol.Protocol.Types; +using ModelContextProtocol.Server; + +namespace ModelContextProtocol.Tests.Server; + +public class McpServerResourceTests +{ + [Fact] + public void CanCreateServerWithResourceTemplates() + { + var services = new ServiceCollection(); + + services.AddMcpServer() + .WithStdioServerTransport() + .WithListResourceTemplatesHandler((ctx, ct) => + { + return Task.FromResult(new ListResourceTemplatesResult + { + ResourceTemplates = + [ + new ResourceTemplate { Name = "Static Resource", Description = "A static resource with a numeric ID", UriTemplate = "test://static/resource/{id}" } + ] + }); + }) + .WithReadResourceHandler((ctx, ct) => + { + return Task.FromResult(new ReadResourceResult + { + Contents = [new TextResourceContents + { + Uri = ctx.Params!.Uri!, + Text = "Static Resource", + MimeType = "text/plain", + }] + }); + }); + + var provider = services.BuildServiceProvider(); + + provider.GetRequiredService(); + } + + [Fact] + public void CanCreateServerWithResources() + { + var services = new ServiceCollection(); + + services.AddMcpServer() + .WithStdioServerTransport() + .WithListResourcesHandler((ctx, ct) => + { + return Task.FromResult(new ListResourcesResult + { + Resources = + [ + new Resource { Name = "Static Resource", Description = "A static resource with a numeric ID", Uri = "test://static/resource/foo.txt" } + ] + }); + }) + .WithReadResourceHandler((ctx, ct) => + { + return Task.FromResult(new ReadResourceResult + { + Contents = [new TextResourceContents + { + Uri = ctx.Params!.Uri!, + Text = "Static Resource", + MimeType = "text/plain", + }] + }); + }); + + var provider = services.BuildServiceProvider(); + + provider.GetRequiredService(); + } + + [Fact] + public void CreatingReadHandlerWithNoListHandlerFails() + { + var services = new ServiceCollection(); + services.AddMcpServer() + .WithStdioServerTransport() + .WithReadResourceHandler((ctx, ct) => + { + return Task.FromResult(new ReadResourceResult + { + Contents = [new TextResourceContents + { + Uri = ctx.Params!.Uri!, + Text = "Static Resource", + MimeType = "text/plain", + }] + }); + }); + var sp = services.BuildServiceProvider(); + Assert.Throws(() => sp.GetRequiredService()); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs index 12b8f5256..f7455291a 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerTests.cs @@ -349,7 +349,7 @@ await Can_Handle_Requests( { return Task.FromResult(new ReadResourceResult { - Contents = [new() { Text = "test" }] + Contents = [new TextResourceContents() { Text = "test" }] }); }, ListResourcesHandler = (request, ct) => throw new NotImplementedException(), @@ -364,7 +364,9 @@ await Can_Handle_Requests( var result = (ReadResourceResult)response; Assert.NotNull(result.Contents); Assert.NotEmpty(result.Contents); - Assert.Equal("test", result.Contents[0].Text); + + TextResourceContents textResource = Assert.IsType(result.Contents[0]); + Assert.Equal("test", textResource.Text); }); } diff --git a/tests/ModelContextProtocol.Tests/SseServerIntegrationTests.cs b/tests/ModelContextProtocol.Tests/SseServerIntegrationTests.cs index 82b1e637b..0f6d90d63 100644 --- a/tests/ModelContextProtocol.Tests/SseServerIntegrationTests.cs +++ b/tests/ModelContextProtocol.Tests/SseServerIntegrationTests.cs @@ -120,7 +120,9 @@ public async Task ReadResource_Sse_TextResource() Assert.NotNull(result); Assert.Single(result.Contents); - Assert.NotNull(result.Contents[0].Text); + + TextResourceContents textContent = Assert.IsType(result.Contents[0]); + Assert.NotNull(textContent.Text); } [Fact] @@ -137,7 +139,9 @@ public async Task ReadResource_Sse_BinaryResource() Assert.NotNull(result); Assert.Single(result.Contents); - Assert.NotNull(result.Contents[0].Blob); + + BlobResourceContents blobContent = Assert.IsType(result.Contents[0]); + Assert.NotNull(blobContent.Blob); } [Fact]