diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs index 66da428cf6..c133b38bbd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Mcp/DefaultMcpToolHandler.cs @@ -12,6 +12,7 @@ using System.Threading.Tasks; using Microsoft.Extensions.AI; using Microsoft.Shared.Diagnostics; +using ModelContextProtocol; using ModelContextProtocol.Client; using ModelContextProtocol.Protocol; @@ -27,6 +28,8 @@ namespace Microsoft.Agents.AI.Workflows.Declarative.Mcp; /// public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable { + private const string FilenameAdditionalPropertyName = "filename"; + /// /// Reserved toolName value that maps an request /// to the MCP protocol tools/list discovery operation. @@ -272,46 +275,46 @@ private static void PopulateResultContent(McpServerToolResultContent resultConte internal static AIContent ConvertContentBlock(ContentBlock block) { - return block switch + // Delegate to the MCP SDK's canonical converter. It maps every known + // ContentBlock subtype (Text/Image/Audio/EmbeddedResource/ToolUse/ToolResult) + // and sets RawRepresentation + AdditionalProperties from block.Meta. + // It intentionally returns null for ResourceLinkBlock — map that to + // UriContent here so callers always receive a usable AIContent. + return block.ToAIContent() ?? block switch { - TextContentBlock text => new TextContent(text.Text), - ImageContentBlock image => CreateDataContent(image.Data, image.MimeType ?? "image/*"), - AudioContentBlock audio => CreateDataContent(audio.Data, audio.MimeType ?? "audio/*"), - EmbeddedResourceBlock embedded => ConvertEmbeddedResource(embedded), - _ => new TextContent(block.ToString() ?? string.Empty), + ResourceLinkBlock link => new UriContent(link.Uri, link.MimeType ?? "application/octet-stream") + { + RawRepresentation = link, + AdditionalProperties = CreateAdditionalProperties(link), + }, + _ => new TextContent(block.ToString() ?? string.Empty) + { + RawRepresentation = block, + AdditionalProperties = CreateAdditionalProperties(block), + }, }; } - private static AIContent ConvertEmbeddedResource(EmbeddedResourceBlock block) + private static AdditionalPropertiesDictionary? CreateAdditionalProperties(ContentBlock block) { - return block.Resource switch - { - TextResourceContents text => new TextContent(text.Text), - BlobResourceContents blob => CreateDataContent(blob.Blob, blob.MimeType ?? "application/octet-stream"), - _ => new TextContent(block.ToString() ?? string.Empty), - }; - } + AdditionalPropertiesDictionary? properties = null; - private static DataContent CreateDataContent(ReadOnlyMemory base64Utf8Data, string mediaType) - { - if (base64Utf8Data.IsEmpty) + if (block.Meta is not null) { - return new DataContent($"data:{mediaType};base64,", mediaType); + foreach (var property in block.Meta) + { + properties ??= new AdditionalPropertiesDictionary(); + properties.Add(property.Key, property.Value); + } } -#if NET8_0_OR_GREATER - string base64 = Encoding.UTF8.GetString(base64Utf8Data.Span); -#else - string base64 = Encoding.UTF8.GetString(base64Utf8Data.ToArray()); -#endif - - // If it's already a data URI, use it directly - if (base64.StartsWith("data:", StringComparison.OrdinalIgnoreCase)) + if (block is ResourceLinkBlock { Name: { Length: > 0 } name }) { - return new DataContent(base64, mediaType); + properties ??= new AdditionalPropertiesDictionary(); + properties.TryAdd(FilenameAdditionalPropertyName, name); } - return new DataContent($"data:{mediaType};base64,{base64}", mediaType); + return properties; } private static string SerializeToolsList(IEnumerable tools) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs index f9cb5cdb56..1327c3df48 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.Mcp.UnitTests/DefaultMcpToolHandlerTests.cs @@ -445,8 +445,9 @@ public void ConvertContentBlock_TextContentBlock_ShouldReturnTextContent() AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert - result.Should().BeOfType() - .Which.Text.Should().Be("hello world"); + TextContent textContent = result.Should().BeOfType().Subject; + textContent.Text.Should().Be("hello world"); + textContent.RawRepresentation.Should().BeSameAs(block); } [Fact] @@ -462,13 +463,17 @@ public void ConvertContentBlock_ImageContentBlock_WithEmptyData_ShouldReturnData DataContent dataContent = result.Should().BeOfType().Subject; dataContent.MediaType.Should().Be("image/png"); dataContent.Uri.Should().Be("data:image/png;base64,"); + dataContent.Data.IsEmpty.Should().BeTrue(); + dataContent.RawRepresentation.Should().BeSameAs(block); } [Fact] public void ConvertContentBlock_ImageContentBlock_WithBase64Payload_ShouldReturnDataContent() { // Arrange - byte[] base64Bytes = Encoding.UTF8.GetBytes("iVBORw0KGgo="); + const string Base64Payload = "iVBORw0KGgo="; + byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload); + byte[] expectedDecoded = Convert.FromBase64String(Base64Payload); ImageContentBlock block = new() { Data = new ReadOnlyMemory(base64Bytes), MimeType = "image/png" }; // Act @@ -477,115 +482,153 @@ public void ConvertContentBlock_ImageContentBlock_WithBase64Payload_ShouldReturn // Assert DataContent dataContent = result.Should().BeOfType().Subject; dataContent.MediaType.Should().Be("image/png"); - dataContent.Uri.Should().Be("data:image/png;base64,iVBORw0KGgo="); + dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded); + dataContent.Uri.Should().Be($"data:image/png;base64,{Base64Payload}"); + dataContent.RawRepresentation.Should().BeSameAs(block); } [Fact] - public void ConvertContentBlock_ImageContentBlock_WithDataUri_ShouldReturnDataContentDirectly() + public void ConvertContentBlock_AudioContentBlock_WithEmptyData_ShouldReturnDataContentWithEmptyUri() { // Arrange - const string DataUri = "data:image/jpeg;base64,/9j/4AAQ"; - byte[] dataUriBytes = Encoding.UTF8.GetBytes(DataUri); - ImageContentBlock block = new() { Data = new ReadOnlyMemory(dataUriBytes), MimeType = "image/jpeg" }; + AudioContentBlock block = new() { Data = ReadOnlyMemory.Empty, MimeType = "audio/wav" }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert DataContent dataContent = result.Should().BeOfType().Subject; - dataContent.MediaType.Should().Be("image/jpeg"); - dataContent.Uri.Should().Be(DataUri); + dataContent.MediaType.Should().Be("audio/wav"); + dataContent.Uri.Should().Be("data:audio/wav;base64,"); + dataContent.Data.IsEmpty.Should().BeTrue(); + dataContent.RawRepresentation.Should().BeSameAs(block); } [Fact] - public void ConvertContentBlock_ImageContentBlock_WithNullMimeType_ShouldDefaultToImageWildcard() + public void ConvertContentBlock_AudioContentBlock_WithBase64Payload_ShouldReturnDataContent() { // Arrange - byte[] base64Bytes = Encoding.UTF8.GetBytes("iVBORw0KGgo="); - ImageContentBlock block = new() { Data = new ReadOnlyMemory(base64Bytes), MimeType = null! }; + const string Base64Payload = "UklGRiQA"; + byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload); + byte[] expectedDecoded = Convert.FromBase64String(Base64Payload); + AudioContentBlock block = new() { Data = new ReadOnlyMemory(base64Bytes), MimeType = "audio/wav" }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert DataContent dataContent = result.Should().BeOfType().Subject; - dataContent.MediaType.Should().Be("image/*"); + dataContent.MediaType.Should().Be("audio/wav"); + dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded); + dataContent.Uri.Should().Be($"data:audio/wav;base64,{Base64Payload}"); + dataContent.RawRepresentation.Should().BeSameAs(block); } [Fact] - public void ConvertContentBlock_AudioContentBlock_WithEmptyData_ShouldReturnDataContentWithEmptyUri() + public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent() { // Arrange - AudioContentBlock block = new() { Data = ReadOnlyMemory.Empty, MimeType = "audio/wav" }; + EmbeddedResourceBlock block = new() + { + Resource = new TextResourceContents + { + Text = "embedded text payload", + Uri = "resource://example", + MimeType = "text/plain", + }, + }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert - DataContent dataContent = result.Should().BeOfType().Subject; - dataContent.MediaType.Should().Be("audio/wav"); - dataContent.Uri.Should().Be("data:audio/wav;base64,"); + TextContent textContent = result.Should().BeOfType().Subject; + textContent.Text.Should().Be("embedded text payload"); + textContent.RawRepresentation.Should().BeSameAs(block); } [Fact] - public void ConvertContentBlock_AudioContentBlock_WithBase64Payload_ShouldReturnDataContent() + public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent() { // Arrange - byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA"); - AudioContentBlock block = new() { Data = new ReadOnlyMemory(base64Bytes), MimeType = "audio/wav" }; + const string Base64Payload = "UklGRiQA"; + byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload); + byte[] expectedDecoded = Convert.FromBase64String(Base64Payload); + EmbeddedResourceBlock block = new() + { + Resource = new BlobResourceContents + { + Blob = new ReadOnlyMemory(base64Bytes), + Uri = "resource://example.bin", + MimeType = "application/zip", + }, + }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert DataContent dataContent = result.Should().BeOfType().Subject; - dataContent.MediaType.Should().Be("audio/wav"); - dataContent.Uri.Should().Be("data:audio/wav;base64,UklGRiQA"); + dataContent.MediaType.Should().Be("application/zip"); + dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded); + dataContent.Uri.Should().Be($"data:application/zip;base64,{Base64Payload}"); + dataContent.RawRepresentation.Should().BeSameAs(block); } [Fact] - public void ConvertContentBlock_AudioContentBlock_WithDataUri_ShouldReturnDataContentDirectly() + public void ConvertContentBlock_ResourceLinkBlock_WithUri_ShouldReturnUriContent() { // Arrange - const string DataUri = "data:audio/mp3;base64,//uQxAAA"; - byte[] dataUriBytes = Encoding.UTF8.GetBytes(DataUri); - AudioContentBlock block = new() { Data = new ReadOnlyMemory(dataUriBytes), MimeType = "audio/mp3" }; + ResourceLinkBlock block = new() + { + Uri = "https://example.com/resource.bin", + Name = "resource.bin", + MimeType = "application/zip", + }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert - DataContent dataContent = result.Should().BeOfType().Subject; - dataContent.MediaType.Should().Be("audio/mp3"); - dataContent.Uri.Should().Be(DataUri); + UriContent uriContent = result.Should().BeOfType().Subject; + uriContent.Uri.ToString().Should().Be("https://example.com/resource.bin"); + uriContent.MediaType.Should().Be("application/zip"); + uriContent.RawRepresentation.Should().BeSameAs(block); } [Fact] - public void ConvertContentBlock_AudioContentBlock_WithNullMimeType_ShouldDefaultToAudioWildcard() + public void ConvertContentBlock_ResourceLinkBlock_WithNullMimeType_ShouldDefaultToOctetStream() { // Arrange - byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA"); - AudioContentBlock block = new() { Data = new ReadOnlyMemory(base64Bytes), MimeType = null! }; + ResourceLinkBlock block = new() + { + Uri = "https://example.com/resource", + Name = "resource", + MimeType = null, + }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert - DataContent dataContent = result.Should().BeOfType().Subject; - dataContent.MediaType.Should().Be("audio/*"); + UriContent uriContent = result.Should().BeOfType().Subject; + uriContent.Uri.ToString().Should().Be("https://example.com/resource"); + uriContent.MediaType.Should().Be("application/octet-stream"); } [Fact] - public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldReturnTextContent() + public void ConvertContentBlock_ResourceLinkBlock_WithMeta_ShouldPropagateToAdditionalProperties() { // Arrange - EmbeddedResourceBlock block = new() + ResourceLinkBlock block = new() { - Resource = new TextResourceContents + Uri = "https://example.com/resource.bin", + Name = string.Empty, + MimeType = "application/zip", + Meta = new System.Text.Json.Nodes.JsonObject { - Text = "embedded text payload", - Uri = "resource://example", - MimeType = "text/plain", + ["traceId"] = "abc-123", + ["priority"] = 7, }, }; @@ -593,46 +636,110 @@ public void ConvertContentBlock_EmbeddedResourceBlock_WithTextResource_ShouldRet AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert - result.Should().BeOfType() - .Which.Text.Should().Be("embedded text payload"); + UriContent uriContent = result.Should().BeOfType().Subject; + uriContent.AdditionalProperties.Should().NotBeNull(); + uriContent.AdditionalProperties!.Should().HaveCount(2); + uriContent.AdditionalProperties["traceId"].Should().BeSameAs(block.Meta!["traceId"]); + uriContent.AdditionalProperties["priority"].Should().BeSameAs(block.Meta["priority"]); } [Fact] - public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent() + public void ConvertContentBlock_ResourceLinkBlock_WithName_ShouldMapNameToFilenameAdditionalProperty() { // Arrange - byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA"); - EmbeddedResourceBlock block = new() + ResourceLinkBlock block = new() { - Resource = new BlobResourceContents - { - Blob = new ReadOnlyMemory(base64Bytes), - Uri = "resource://example.bin", - MimeType = "application/zip", - }, + Uri = "https://example.com/resource.bin", + Name = "resource.bin", + MimeType = "application/zip", }; // Act AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert - DataContent dataContent = result.Should().BeOfType().Subject; - dataContent.MediaType.Should().Be("application/zip"); - dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA"); + UriContent uriContent = result.Should().BeOfType().Subject; + uriContent.AdditionalProperties.Should().NotBeNull(); + uriContent.AdditionalProperties!["filename"].Should().Be("resource.bin"); } [Fact] - public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream() + public void ConvertContentBlock_ToolUseContentBlock_ShouldReturnFunctionCallContent() { // Arrange - byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA"); - EmbeddedResourceBlock block = new() + using JsonDocument input = JsonDocument.Parse("{\"city\":\"Seattle\",\"unit\":\"celsius\"}"); + ToolUseContentBlock block = new() { - Resource = new BlobResourceContents + Id = "call-1", + Name = "get_weather", + Input = input.RootElement.Clone(), + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + FunctionCallContent call = result.Should().BeOfType().Subject; + call.CallId.Should().Be("call-1"); + call.Name.Should().Be("get_weather"); + call.Arguments.Should().NotBeNull(); + call.Arguments!.Should().ContainKey("city"); + call.RawRepresentation.Should().BeSameAs(block); + } + + [Fact] + public void ConvertContentBlock_ToolResultContentBlock_NotError_ShouldReturnFunctionResultContent() + { + // Arrange + ToolResultContentBlock block = new() + { + ToolUseId = "call-1", + Content = [new TextContentBlock { Text = "ok" }], + IsError = false, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + FunctionResultContent functionResult = result.Should().BeOfType().Subject; + functionResult.CallId.Should().Be("call-1"); + functionResult.Exception.Should().BeNull(); + functionResult.RawRepresentation.Should().BeSameAs(block); + } + + [Fact] + public void ConvertContentBlock_ToolResultContentBlock_WithIsError_ShouldSetException() + { + // Arrange + ToolResultContentBlock block = new() + { + ToolUseId = "call-2", + Content = [new TextContentBlock { Text = "boom" }], + IsError = true, + }; + + // Act + AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); + + // Assert + FunctionResultContent functionResult = result.Should().BeOfType().Subject; + functionResult.CallId.Should().Be("call-2"); + functionResult.Exception.Should().NotBeNull(); + functionResult.RawRepresentation.Should().BeSameAs(block); + } + + [Fact] + public void ConvertContentBlock_BlockWithMeta_ShouldPropagateToAdditionalProperties() + { + // Arrange + TextContentBlock block = new() + { + Text = "hello", + Meta = new System.Text.Json.Nodes.JsonObject { - Blob = new ReadOnlyMemory(base64Bytes), - Uri = "resource://example.bin", - MimeType = null!, + ["traceId"] = "abc-123", + ["priority"] = 7, }, }; @@ -640,9 +747,9 @@ public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeT AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block); // Assert - DataContent dataContent = result.Should().BeOfType().Subject; - dataContent.MediaType.Should().Be("application/octet-stream"); - dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA"); + result.AdditionalProperties.Should().NotBeNull(); + result.AdditionalProperties!.Should().ContainKey("traceId"); + result.AdditionalProperties.Should().ContainKey("priority"); } #endregion