From 5bbf1c264d2240315bca19a91e2a44dbb8315093 Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 27 Mar 2025 14:10:43 +1100 Subject: [PATCH 1/4] Adding support for returning collections from tools This will mean we can return multiple AIContent objects from a tool, such as a mixed text/image set Contributes to #68 --- .../Server/AIFunctionMcpServerTool.cs | 82 ++++++++++--------- 1 file changed, 42 insertions(+), 40 deletions(-) diff --git a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs index ff3f92887..3231ba8b1 100644 --- a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs @@ -22,7 +22,7 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool public static new AIFunctionMcpServerTool Create( Delegate method, string? name, - string? description, + string? description, IServiceProvider? services) { Throw.IfNull(method); @@ -34,7 +34,7 @@ internal sealed class AIFunctionMcpServerTool : McpServerTool /// Creates an instance for a method, specified via a instance. /// public static new AIFunctionMcpServerTool Create( - MethodInfo method, + MethodInfo method, object? target, string? name, string? description, @@ -195,57 +195,59 @@ public override async Task InvokeAsync( }; } - switch (result) + return result switch { - case null: - return new() - { - Content = [] - }; - - case string text: - return new() - { - Content = [new() { Text = text, Type = "text" }] - }; - - case TextContent textContent: - return new() - { - Content = [new() { Text = textContent.Text, Type = "text" }] - }; - - case DataContent dataContent: - return new() - { - Content = [new() + null => new() + { + Content = [] + }, + string text => new() + { + Content = [new() { Text = text, Type = "text" }] + }, + TextContent textContent => new() + { + Content = [new() { Text = textContent.Text, Type = "text" }] + }, + DataContent dataContent => new() + { + Content = [new() { Data = dataContent.GetBase64Data(), MimeType = dataContent.MediaType, Type = dataContent.HasTopLevelMediaType("image") ? "image" : "resource", }] - }; + }, + string[] texts => new() + { + Content = [.. texts.Select(x => new Content() { Type = "text", Text = x ?? string.Empty })] + }, - case string[] texts: - return new() - { - Content = texts - .Select(x => new Content() { Type = "text", Text = x ?? string.Empty }) - .ToList() - }; + IEnumerable contentItems => new() + { + Content = [.. contentItems.Select(static item => item switch + { + TextContent textContent => new Content() { Type = "text", Text = textContent.Text }, + DataContent dataContent => new Content() + { + Data = dataContent.GetBase64Data(), + MimeType = dataContent.MediaType, + Type = dataContent.HasTopLevelMediaType("image") ? "image" : "resource", + }, + _ => new Content() { Type = "text", Text = item.ToString() ?? string.Empty } + })] + }, // TODO https://github.com/modelcontextprotocol/csharp-sdk/issues/69: // Add specialization for annotations. - - default: - return new() - { - Content = [new() + _ => new() + { + Content = [new() { Text = JsonSerializer.Serialize(result, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))), Type = "text" }] - }; - } + }, + }; } } \ No newline at end of file From 605e1babb0f1d5c4a3649839babd31774ae5295c Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 27 Mar 2025 14:27:34 +1100 Subject: [PATCH 2/4] Code review feedback --- .../AIContentExtensions.cs | 32 +++++++++++++++++++ .../Server/AIFunctionMcpServerTool.cs | 27 +++++----------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/src/ModelContextProtocol/AIContentExtensions.cs b/src/ModelContextProtocol/AIContentExtensions.cs index 6a3f17733..924a4e3e6 100644 --- a/src/ModelContextProtocol/AIContentExtensions.cs +++ b/src/ModelContextProtocol/AIContentExtensions.cs @@ -1,7 +1,9 @@ using Microsoft.Extensions.AI; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Utils; +using ModelContextProtocol.Utils.Json; using System.Runtime.InteropServices; +using System.Text.Json; namespace ModelContextProtocol; @@ -101,4 +103,34 @@ internal static string GetBase64Data(this DataContent dataContent) Convert.ToBase64String(dataContent.Data.ToArray()); #endif } + + /// + /// Converts different types of into a standardized object with specific properties based on the + /// content type. + /// + /// + /// A object that encapsulates the relevant properties derived from the input content. + public static Content ToContent(this AIContent content) => + content switch + { + TextContent textContent => new() + { + Text = textContent.Text, + Type = "text", + }, + DataContent dataContent => new() + { + Data = dataContent.GetBase64Data(), + MimeType = dataContent.MediaType, + Type = + dataContent.HasTopLevelMediaType("image") ? "image" : + dataContent.HasTopLevelMediaType("audio") ? "audio" : + "resource", + }, + _ => new() + { + Text = JsonSerializer.Serialize(content, McpJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))), + Type = "text", + } + }; } diff --git a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs index 3231ba8b1..5fb2a9b85 100644 --- a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs @@ -207,35 +207,23 @@ public override async Task InvokeAsync( }, TextContent textContent => new() { - Content = [new() { Text = textContent.Text, Type = "text" }] + Content = [textContent.ToContent()] }, DataContent dataContent => new() { - Content = [new() - { - Data = dataContent.GetBase64Data(), - MimeType = dataContent.MediaType, - Type = dataContent.HasTopLevelMediaType("image") ? "image" : "resource", - }] + Content = [dataContent.ToContent()] }, string[] texts => new() { Content = [.. texts.Select(x => new Content() { Type = "text", Text = x ?? string.Empty })] }, - IEnumerable contentItems => new() { - Content = [.. contentItems.Select(static item => item switch - { - TextContent textContent => new Content() { Type = "text", Text = textContent.Text }, - DataContent dataContent => new Content() - { - Data = dataContent.GetBase64Data(), - MimeType = dataContent.MediaType, - Type = dataContent.HasTopLevelMediaType("image") ? "image" : "resource", - }, - _ => new Content() { Type = "text", Text = item.ToString() ?? string.Empty } - })] + Content = [.. contentItems.Select(static item => item.ToContent())] + }, + IEnumerable contents => new() + { + Content = [.. contents] }, // TODO https://github.com/modelcontextprotocol/csharp-sdk/issues/69: @@ -250,4 +238,5 @@ public override async Task InvokeAsync( }, }; } + } \ No newline at end of file From 6f1dd693041625107a2d247489abe03e084380da Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 27 Mar 2025 14:50:40 +1100 Subject: [PATCH 3/4] Adding tests and some more cases --- .../Server/AIFunctionMcpServerTool.cs | 14 +- .../Server/McpServerToolReturnTests.cs | 167 ++++++++++++++++++ .../Server/McpServerToolTests.cs | 3 +- 3 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 tests/ModelContextProtocol.Tests/Server/McpServerToolReturnTests.cs diff --git a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs index 5fb2a9b85..9a570368d 100644 --- a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs @@ -197,6 +197,10 @@ public override async Task InvokeAsync( return result switch { + AIContent aiContent => new() + { + Content = [aiContent.ToContent()] + }, null => new() { Content = [] @@ -205,15 +209,11 @@ public override async Task InvokeAsync( { Content = [new() { Text = text, Type = "text" }] }, - TextContent textContent => new() - { - Content = [textContent.ToContent()] - }, - DataContent dataContent => new() + Content content => new() { - Content = [dataContent.ToContent()] + Content = [content] }, - string[] texts => new() + IEnumerable texts => new() { Content = [.. texts.Select(x => new Content() { Type = "text", Text = x ?? string.Empty })] }, diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolReturnTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolReturnTests.cs new file mode 100644 index 000000000..3da9165fc --- /dev/null +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolReturnTests.cs @@ -0,0 +1,167 @@ +using Microsoft.Extensions.AI; +using ModelContextProtocol.Protocol.Types; +using ModelContextProtocol.Server; +using Moq; + +namespace ModelContextProtocol.Tests.Server; +public class McpServerToolReturnTests +{ + [Fact] + public async Task CanReturnCollectionOfAIContent() + { + Mock mockServer = new(); + McpServerTool tool = McpServerTool.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return new List() { + new TextContent("text"), + new DataContent("data:image/png;base64,1234"), + new DataContent("data:audio/wav;base64,1234") + }; + }); + + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, null), + TestContext.Current.CancellationToken); + + Assert.Equal(3, result.Content.Count); + + Assert.Equal("text", result.Content[0].Text); + Assert.Equal("text", result.Content[0].Type); + + Assert.Equal("1234", result.Content[1].Data); + Assert.Equal("image/png", result.Content[1].MimeType); + Assert.Equal("image", result.Content[1].Type); + + Assert.Equal("1234", result.Content[2].Data); + Assert.Equal("audio/wav", result.Content[2].MimeType); + Assert.Equal("audio", result.Content[2].Type); + } + + [Theory] + [InlineData("text", "text")] + [InlineData("data:image/png;base64,1234", "image")] + [InlineData("data:audio/wav;base64,1234", "audio")] + public async Task CanReturnSingleAIContent(string data, string type) + { + Mock mockServer = new(); + McpServerTool tool = McpServerTool.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return type switch + { + "text" => (AIContent)new TextContent(data), + "image" => new DataContent(data), + "audio" => new DataContent(data), + _ => throw new ArgumentException("Invalid type") + }; + }); + + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, null), + TestContext.Current.CancellationToken); + + Assert.Single(result.Content); + Assert.Equal(type, result.Content[0].Type); + + if (type != "text") + { + Assert.NotNull(result.Content[0].MimeType); + Assert.Equal(data.Split(',').Last(), result.Content[0].Data); + } + else + { + Assert.Null(result.Content[0].MimeType); + Assert.Equal(data, result.Content[0].Text); + } + } + + [Fact] + public async Task CanReturnNullAIContent() + { + Mock mockServer = new(); + McpServerTool tool = McpServerTool.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return (string?)null; + }); + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, null), + TestContext.Current.CancellationToken); + Assert.Empty(result.Content); + } + + [Fact] + public async Task CanReturnString() + { + Mock mockServer = new(); + McpServerTool tool = McpServerTool.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return "42"; + }); + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, null), + TestContext.Current.CancellationToken); + Assert.Single(result.Content); + Assert.Equal("42", result.Content[0].Text); + Assert.Equal("text", result.Content[0].Type); + } + + [Fact] + public async Task CanReturnCollectionOfStrings() + { + Mock mockServer = new(); + McpServerTool tool = McpServerTool.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return new List() { "42", "43" }; + }); + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, null), + TestContext.Current.CancellationToken); + Assert.Equal(2, result.Content.Count); + Assert.Equal("42", result.Content[0].Text); + Assert.Equal("text", result.Content[0].Type); + Assert.Equal("43", result.Content[1].Text); + Assert.Equal("text", result.Content[1].Type); + } + + [Fact] + public async Task CanReturnMcpContent() + { + Mock mockServer = new(); + McpServerTool tool = McpServerTool.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return new Content { Text = "42", Type = "text" }; + }); + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, null), + TestContext.Current.CancellationToken); + Assert.Single(result.Content); + Assert.Equal("42", result.Content[0].Text); + Assert.Equal("text", result.Content[0].Type); + } + + [Fact] + public async Task CanReturnCollectionOfMcpContent() + { + Mock mockServer = new(); + McpServerTool tool = McpServerTool.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return new List() { new() { Text = "42", Type = "text" }, new() { Data = "1234", Type = "image", MimeType = "image/png" } }; + }); + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, null), + TestContext.Current.CancellationToken); + Assert.Equal(2, result.Content.Count); + Assert.Equal("42", result.Content[0].Text); + Assert.Equal("text", result.Content[0].Type); + Assert.Equal("1234", result.Content[1].Data); + Assert.Equal("image", result.Content[1].Type); + Assert.Equal("image/png", result.Content[1].MimeType); + Assert.Null(result.Content[1].Text); + } +} diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs index 3f066dd5c..1ce4b36cb 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolTests.cs @@ -1,4 +1,5 @@ -using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.DependencyInjection; using ModelContextProtocol.Protocol.Types; using ModelContextProtocol.Server; using Moq; From 51c9b13c2c4b77652c95a315b344af1d5a4d473a Mon Sep 17 00:00:00 2001 From: Aaron Powell Date: Thu, 27 Mar 2025 14:54:41 +1100 Subject: [PATCH 4/4] More cases in switch and tests --- .../AIContentExtensions.cs | 14 +++------- .../Server/AIFunctionMcpServerTool.cs | 1 + .../Server/McpServerToolReturnTests.cs | 27 +++++++++++++++++++ 3 files changed, 32 insertions(+), 10 deletions(-) diff --git a/src/ModelContextProtocol/AIContentExtensions.cs b/src/ModelContextProtocol/AIContentExtensions.cs index 924a4e3e6..87f40daaa 100644 --- a/src/ModelContextProtocol/AIContentExtensions.cs +++ b/src/ModelContextProtocol/AIContentExtensions.cs @@ -104,13 +104,7 @@ internal static string GetBase64Data(this DataContent dataContent) #endif } - /// - /// Converts different types of into a standardized object with specific properties based on the - /// content type. - /// - /// - /// A object that encapsulates the relevant properties derived from the input content. - public static Content ToContent(this AIContent content) => + internal static Content ToContent(this AIContent content) => content switch { TextContent textContent => new() @@ -123,9 +117,9 @@ public static Content ToContent(this AIContent content) => Data = dataContent.GetBase64Data(), MimeType = dataContent.MediaType, Type = - dataContent.HasTopLevelMediaType("image") ? "image" : - dataContent.HasTopLevelMediaType("audio") ? "audio" : - "resource", + dataContent.HasTopLevelMediaType("image") ? "image" : + dataContent.HasTopLevelMediaType("audio") ? "audio" : + "resource", }, _ => new() { diff --git a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs index 9a570368d..56ad40410 100644 --- a/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs +++ b/src/ModelContextProtocol/Server/AIFunctionMcpServerTool.cs @@ -225,6 +225,7 @@ public override async Task InvokeAsync( { Content = [.. contents] }, + CallToolResponse callToolResponse => callToolResponse, // TODO https://github.com/modelcontextprotocol/csharp-sdk/issues/69: // Add specialization for annotations. diff --git a/tests/ModelContextProtocol.Tests/Server/McpServerToolReturnTests.cs b/tests/ModelContextProtocol.Tests/Server/McpServerToolReturnTests.cs index 3da9165fc..a263ab9c3 100644 --- a/tests/ModelContextProtocol.Tests/Server/McpServerToolReturnTests.cs +++ b/tests/ModelContextProtocol.Tests/Server/McpServerToolReturnTests.cs @@ -164,4 +164,31 @@ public async Task CanReturnCollectionOfMcpContent() Assert.Equal("image/png", result.Content[1].MimeType); Assert.Null(result.Content[1].Text); } + + [Fact] + public async Task CanReturnCallToolResponse() + { + CallToolResponse response = new() + { + Content = [new() { Text = "text", Type = "text" }, new() { Data = "1234", Type = "image" }] + }; + + Mock mockServer = new(); + McpServerTool tool = McpServerTool.Create((IMcpServer server) => + { + Assert.Same(mockServer.Object, server); + return response; + }); + var result = await tool.InvokeAsync( + new RequestContext(mockServer.Object, null), + TestContext.Current.CancellationToken); + + Assert.Same(response, result); + + Assert.Equal(2, result.Content.Count); + Assert.Equal("text", result.Content[0].Text); + Assert.Equal("text", result.Content[0].Type); + Assert.Equal("1234", result.Content[1].Data); + Assert.Equal("image", result.Content[1].Type); + } }