mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
.NET: Delegate MCP ContentBlock to AIContent conversion to the MCP SDK (#5903)
* Add sample for invoking Foundry Toolbox tools from declarative workflows * Addressed initial PR comments. * Delegate MCP ContentBlock to AIContent conversion to the MCP SDK * Addressed additional properties metadata in the conversion fallback.
This commit is contained in:
committed by
GitHub
Unverified
parent
aad20c2b33
commit
3ebbdb01b4
@@ -12,6 +12,7 @@ using System.Threading;
|
||||
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;
|
||||
/// </remarks>
|
||||
public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
{
|
||||
private const string FilenameAdditionalPropertyName = "filename";
|
||||
|
||||
/// <summary>
|
||||
/// Reserved <c>toolName</c> value that maps an <see cref="IMcpToolHandler.InvokeToolAsync"/> request
|
||||
/// to the MCP protocol <c>tools/list</c> discovery operation.
|
||||
@@ -272,46 +275,46 @@ public sealed class DefaultMcpToolHandler : IMcpToolHandler, IAsyncDisposable
|
||||
|
||||
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<byte> 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<Tool> tools)
|
||||
|
||||
+191
-84
@@ -445,8 +445,9 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<TextContent>()
|
||||
.Which.Text.Should().Be("hello world");
|
||||
TextContent textContent = result.Should().BeOfType<TextContent>().Subject;
|
||||
textContent.Text.Should().Be("hello world");
|
||||
textContent.RawRepresentation.Should().BeSameAs(block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -462,13 +463,17 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().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<byte>(base64Bytes), MimeType = "image/png" };
|
||||
|
||||
// Act
|
||||
@@ -477,39 +482,9 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("image/png");
|
||||
dataContent.Uri.Should().Be("data:image/png;base64,iVBORw0KGgo=");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_ImageContentBlock_WithDataUri_ShouldReturnDataContentDirectly()
|
||||
{
|
||||
// Arrange
|
||||
const string DataUri = "data:image/jpeg;base64,/9j/4AAQ";
|
||||
byte[] dataUriBytes = Encoding.UTF8.GetBytes(DataUri);
|
||||
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(dataUriBytes), MimeType = "image/jpeg" };
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("image/jpeg");
|
||||
dataContent.Uri.Should().Be(DataUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_ImageContentBlock_WithNullMimeType_ShouldDefaultToImageWildcard()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("iVBORw0KGgo=");
|
||||
ImageContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = null! };
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("image/*");
|
||||
dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded);
|
||||
dataContent.Uri.Should().Be($"data:image/png;base64,{Base64Payload}");
|
||||
dataContent.RawRepresentation.Should().BeSameAs(block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -525,13 +500,17 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
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_AudioContentBlock_WithBase64Payload_ShouldReturnDataContent()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
|
||||
const string Base64Payload = "UklGRiQA";
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload);
|
||||
byte[] expectedDecoded = Convert.FromBase64String(Base64Payload);
|
||||
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = "audio/wav" };
|
||||
|
||||
// Act
|
||||
@@ -540,39 +519,9 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("audio/wav");
|
||||
dataContent.Uri.Should().Be("data:audio/wav;base64,UklGRiQA");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_AudioContentBlock_WithDataUri_ShouldReturnDataContentDirectly()
|
||||
{
|
||||
// Arrange
|
||||
const string DataUri = "data:audio/mp3;base64,//uQxAAA";
|
||||
byte[] dataUriBytes = Encoding.UTF8.GetBytes(DataUri);
|
||||
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(dataUriBytes), MimeType = "audio/mp3" };
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("audio/mp3");
|
||||
dataContent.Uri.Should().Be(DataUri);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_AudioContentBlock_WithNullMimeType_ShouldDefaultToAudioWildcard()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
|
||||
AudioContentBlock block = new() { Data = new ReadOnlyMemory<byte>(base64Bytes), MimeType = null! };
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("audio/*");
|
||||
dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded);
|
||||
dataContent.Uri.Should().Be($"data:audio/wav;base64,{Base64Payload}");
|
||||
dataContent.RawRepresentation.Should().BeSameAs(block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -593,15 +542,18 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
result.Should().BeOfType<TextContent>()
|
||||
.Which.Text.Should().Be("embedded text payload");
|
||||
TextContent textContent = result.Should().BeOfType<TextContent>().Subject;
|
||||
textContent.Text.Should().Be("embedded text payload");
|
||||
textContent.RawRepresentation.Should().BeSameAs(block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_ShouldReturnDataContent()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
|
||||
const string Base64Payload = "UklGRiQA";
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes(Base64Payload);
|
||||
byte[] expectedDecoded = Convert.FromBase64String(Base64Payload);
|
||||
EmbeddedResourceBlock block = new()
|
||||
{
|
||||
Resource = new BlobResourceContents
|
||||
@@ -618,21 +570,65 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("application/zip");
|
||||
dataContent.Uri.Should().Be("data:application/zip;base64,UklGRiQA");
|
||||
dataContent.Data.ToArray().Should().BeEquivalentTo(expectedDecoded);
|
||||
dataContent.Uri.Should().Be($"data:application/zip;base64,{Base64Payload}");
|
||||
dataContent.RawRepresentation.Should().BeSameAs(block);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_EmbeddedResourceBlock_WithBlobResource_NullMimeType_DefaultsToOctetStream()
|
||||
public void ConvertContentBlock_ResourceLinkBlock_WithUri_ShouldReturnUriContent()
|
||||
{
|
||||
// Arrange
|
||||
byte[] base64Bytes = Encoding.UTF8.GetBytes("UklGRiQA");
|
||||
EmbeddedResourceBlock block = new()
|
||||
ResourceLinkBlock block = new()
|
||||
{
|
||||
Resource = new BlobResourceContents
|
||||
Uri = "https://example.com/resource.bin",
|
||||
Name = "resource.bin",
|
||||
MimeType = "application/zip",
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
UriContent uriContent = result.Should().BeOfType<UriContent>().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_ResourceLinkBlock_WithNullMimeType_ShouldDefaultToOctetStream()
|
||||
{
|
||||
// Arrange
|
||||
ResourceLinkBlock block = new()
|
||||
{
|
||||
Uri = "https://example.com/resource",
|
||||
Name = "resource",
|
||||
MimeType = null,
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
UriContent uriContent = result.Should().BeOfType<UriContent>().Subject;
|
||||
uriContent.Uri.ToString().Should().Be("https://example.com/resource");
|
||||
uriContent.MediaType.Should().Be("application/octet-stream");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_ResourceLinkBlock_WithMeta_ShouldPropagateToAdditionalProperties()
|
||||
{
|
||||
// Arrange
|
||||
ResourceLinkBlock block = new()
|
||||
{
|
||||
Uri = "https://example.com/resource.bin",
|
||||
Name = string.Empty,
|
||||
MimeType = "application/zip",
|
||||
Meta = new System.Text.Json.Nodes.JsonObject
|
||||
{
|
||||
Blob = new ReadOnlyMemory<byte>(base64Bytes),
|
||||
Uri = "resource://example.bin",
|
||||
MimeType = null!,
|
||||
["traceId"] = "abc-123",
|
||||
["priority"] = 7,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -640,9 +636,120 @@ public sealed class DefaultMcpToolHandlerTests
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
DataContent dataContent = result.Should().BeOfType<DataContent>().Subject;
|
||||
dataContent.MediaType.Should().Be("application/octet-stream");
|
||||
dataContent.Uri.Should().Be("data:application/octet-stream;base64,UklGRiQA");
|
||||
UriContent uriContent = result.Should().BeOfType<UriContent>().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_ResourceLinkBlock_WithName_ShouldMapNameToFilenameAdditionalProperty()
|
||||
{
|
||||
// Arrange
|
||||
ResourceLinkBlock block = new()
|
||||
{
|
||||
Uri = "https://example.com/resource.bin",
|
||||
Name = "resource.bin",
|
||||
MimeType = "application/zip",
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
UriContent uriContent = result.Should().BeOfType<UriContent>().Subject;
|
||||
uriContent.AdditionalProperties.Should().NotBeNull();
|
||||
uriContent.AdditionalProperties!["filename"].Should().Be("resource.bin");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertContentBlock_ToolUseContentBlock_ShouldReturnFunctionCallContent()
|
||||
{
|
||||
// Arrange
|
||||
using JsonDocument input = JsonDocument.Parse("{\"city\":\"Seattle\",\"unit\":\"celsius\"}");
|
||||
ToolUseContentBlock block = new()
|
||||
{
|
||||
Id = "call-1",
|
||||
Name = "get_weather",
|
||||
Input = input.RootElement.Clone(),
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
FunctionCallContent call = result.Should().BeOfType<FunctionCallContent>().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<FunctionResultContent>().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<FunctionResultContent>().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
|
||||
{
|
||||
["traceId"] = "abc-123",
|
||||
["priority"] = 7,
|
||||
},
|
||||
};
|
||||
|
||||
// Act
|
||||
AIContent result = DefaultMcpToolHandler.ConvertContentBlock(block);
|
||||
|
||||
// Assert
|
||||
result.AdditionalProperties.Should().NotBeNull();
|
||||
result.AdditionalProperties!.Should().ContainKey("traceId");
|
||||
result.AdditionalProperties.Should().ContainKey("priority");
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
Reference in New Issue
Block a user