diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml index 40da1294c8d..93851f5fee4 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml @@ -169,4 +169,144 @@ lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll true + + CP0001 + T:Microsoft.Extensions.AI.McpServerToolApprovalRequestContent + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.McpServerToolApprovalResponseContent + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolCallContent.get_Arguments + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolResultContent.get_Output + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolResultContent.set_Output(System.Collections.Generic.IList{Microsoft.Extensions.AI.AIContent}) + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.McpServerToolApprovalRequestContent + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.McpServerToolApprovalResponseContent + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolCallContent.get_Arguments + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolResultContent.get_Output + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolResultContent.set_Output(System.Collections.Generic.IList{Microsoft.Extensions.AI.AIContent}) + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.McpServerToolApprovalRequestContent + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.McpServerToolApprovalResponseContent + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolCallContent.get_Arguments + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolResultContent.get_Output + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolResultContent.set_Output(System.Collections.Generic.IList{Microsoft.Extensions.AI.AIContent}) + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.McpServerToolApprovalRequestContent + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.McpServerToolApprovalResponseContent + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolCallContent.get_Arguments + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolResultContent.get_Output + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolResultContent.set_Output(System.Collections.Generic.IList{Microsoft.Extensions.AI.AIContent}) + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs index af8b19c8d84..ffdf33f7645 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -28,8 +28,6 @@ namespace Microsoft.Extensions.AI; // [JsonDerivedType(typeof(FunctionApprovalResponseContent), typeDiscriminator: "functionApprovalResponse")] // [JsonDerivedType(typeof(McpServerToolCallContent), typeDiscriminator: "mcpServerToolCall")] // [JsonDerivedType(typeof(McpServerToolResultContent), typeDiscriminator: "mcpServerToolResult")] -// [JsonDerivedType(typeof(McpServerToolApprovalRequestContent), typeDiscriminator: "mcpServerToolApprovalRequest")] -// [JsonDerivedType(typeof(McpServerToolApprovalResponseContent), typeDiscriminator: "mcpServerToolApprovalResponse")] // [JsonDerivedType(typeof(CodeInterpreterToolCallContent), typeDiscriminator: "codeInterpreterToolCall")] // [JsonDerivedType(typeof(CodeInterpreterToolResultContent), typeDiscriminator: "codeInterpreterToolResult")] diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index 836d5a4110b..7c506a7845b 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -14,7 +14,7 @@ namespace Microsoft.Extensions.AI; /// Represents a function call request. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] -public sealed class FunctionCallContent : AIContent +public class FunctionCallContent : AIContent { /// /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs index 46401347b40..d5eb4884709 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs @@ -13,7 +13,7 @@ namespace Microsoft.Extensions.AI; /// Represents the result of a function call. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] -public sealed class FunctionResultContent : AIContent +public class FunctionResultContent : AIContent { /// /// Initializes a new instance of the class. diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs deleted file mode 100644 index 8f302d901b4..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs +++ /dev/null @@ -1,41 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents a request for user approval of an MCP server tool call. -/// -[Experimental("MEAI001")] -public sealed class McpServerToolApprovalRequestContent : UserInputRequestContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID that uniquely identifies the MCP server tool approval request/response pair. - /// The tool call that requires user approval. - /// is . - /// is empty or composed entirely of whitespace. - /// is . - public McpServerToolApprovalRequestContent(string id, McpServerToolCallContent toolCall) - : base(id) - { - ToolCall = Throw.IfNull(toolCall); - } - - /// - /// Gets the tool call that pre-invoke approval is required for. - /// - public McpServerToolCallContent ToolCall { get; } - - /// - /// Creates a to indicate whether the function call is approved or rejected based on the value of . - /// - /// if the function call is approved; otherwise, . - /// The representing the approval response. - public McpServerToolApprovalResponseContent CreateResponse(bool approved) => new(Id, approved); -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs deleted file mode 100644 index 0e239a79d7f..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs +++ /dev/null @@ -1,32 +0,0 @@ -// Licensed to the .NET Foundation under one or more agreements. -// The .NET Foundation licenses this file to you under the MIT license. - -using System; -using System.Diagnostics.CodeAnalysis; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents a response to an MCP server tool approval request. -/// -[Experimental("MEAI001")] -public sealed class McpServerToolApprovalResponseContent : UserInputResponseContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID that uniquely identifies the MCP server tool approval request/response pair. - /// if the MCP server tool call is approved; otherwise, . - /// is . - /// is empty or composed entirely of whitespace. - public McpServerToolApprovalResponseContent(string id, bool approved) - : base(id) - { - Approved = approved; - } - - /// - /// Gets a value indicating whether the user approved the request. - /// - public bool Approved { get; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs index 3283c09a7ee..0f4053ef8b9 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs @@ -2,8 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -16,7 +16,7 @@ namespace Microsoft.Extensions.AI; /// It is informational only. /// [Experimental("MEAI001")] -public sealed class McpServerToolCallContent : AIContent +public sealed class McpServerToolCallContent : FunctionCallContent { /// /// Initializes a new instance of the class. @@ -26,30 +26,20 @@ public sealed class McpServerToolCallContent : AIContent /// The MCP server name that hosts the tool. /// or is . /// or is empty or composed entirely of whitespace. + [JsonConstructor] public McpServerToolCallContent(string callId, string toolName, string? serverName) + : base(Throw.IfNullOrWhitespace(callId), Throw.IfNullOrWhitespace(toolName)) { - CallId = Throw.IfNullOrWhitespace(callId); - ToolName = Throw.IfNullOrWhitespace(toolName); ServerName = serverName; } - /// - /// Gets the tool call ID. - /// - public string CallId { get; } - /// /// Gets the name of the tool called. /// - public string ToolName { get; } + public string ToolName => Name; /// /// Gets the name of the MCP server that hosts the tool. /// public string? ServerName { get; } - - /// - /// Gets or sets the arguments used for the tool call. - /// - public IReadOnlyDictionary? Arguments { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs index b8329c74d99..673a0217687 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs @@ -2,8 +2,8 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -16,7 +16,7 @@ namespace Microsoft.Extensions.AI; /// It is informational only. /// [Experimental("MEAI001")] -public sealed class McpServerToolResultContent : AIContent +public sealed class McpServerToolResultContent : FunctionResultContent { /// /// Initializes a new instance of the class. @@ -24,18 +24,9 @@ public sealed class McpServerToolResultContent : AIContent /// The tool call ID. /// is . /// is empty or composed entirely of whitespace. + [JsonConstructor] public McpServerToolResultContent(string callId) + : base(Throw.IfNullOrWhitespace(callId), result: null) { - CallId = Throw.IfNullOrWhitespace(callId); } - - /// - /// Gets the tool call ID. - /// - public string CallId { get; } - - /// - /// Gets or sets the output of the tool call. - /// - public IList? Output { get; set; } } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs index b2a2e0e6e95..3e77607a3fd 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs @@ -14,7 +14,6 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(FunctionApprovalRequestContent), "functionApprovalRequest")] -[JsonDerivedType(typeof(McpServerToolApprovalRequestContent), "mcpServerToolApprovalRequest")] public class UserInputRequestContent : AIContent { /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs index 6902f047282..f17ae2a964d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs @@ -14,7 +14,6 @@ namespace Microsoft.Extensions.AI; [Experimental("MEAI001")] [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(FunctionApprovalResponseContent), "functionApprovalResponse")] -[JsonDerivedType(typeof(McpServerToolApprovalResponseContent), "mcpServerToolApprovalResponse")] public class UserInputResponseContent : AIContent { /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json index e401502d82b..7c68076c583 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Microsoft.Extensions.AI.Abstractions.json @@ -1780,7 +1780,7 @@ ] }, { - "Type": "sealed class Microsoft.Extensions.AI.FunctionCallContent : Microsoft.Extensions.AI.AIContent", + "Type": "class Microsoft.Extensions.AI.FunctionCallContent : Microsoft.Extensions.AI.AIContent", "Stage": "Stable", "Methods": [ { @@ -1812,7 +1812,7 @@ ] }, { - "Type": "sealed class Microsoft.Extensions.AI.FunctionResultContent : Microsoft.Extensions.AI.AIContent", + "Type": "class Microsoft.Extensions.AI.FunctionResultContent : Microsoft.Extensions.AI.AIContent", "Stage": "Stable", "Methods": [ { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs index d01294836bc..51ed08441f1 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -55,8 +55,6 @@ private static JsonSerializerOptions CreateDefaultOptions() AddAIContentType(options, typeof(FunctionApprovalResponseContent), typeDiscriminatorId: "functionApprovalResponse", checkBuiltIn: false); AddAIContentType(options, typeof(McpServerToolCallContent), typeDiscriminatorId: "mcpServerToolCall", checkBuiltIn: false); AddAIContentType(options, typeof(McpServerToolResultContent), typeDiscriminatorId: "mcpServerToolResult", checkBuiltIn: false); - AddAIContentType(options, typeof(McpServerToolApprovalRequestContent), typeDiscriminatorId: "mcpServerToolApprovalRequest", checkBuiltIn: false); - AddAIContentType(options, typeof(McpServerToolApprovalResponseContent), typeDiscriminatorId: "mcpServerToolApprovalResponse", checkBuiltIn: false); AddAIContentType(options, typeof(CodeInterpreterToolCallContent), typeDiscriminatorId: "codeInterpreterToolCall", checkBuiltIn: false); AddAIContentType(options, typeof(CodeInterpreterToolResultContent), typeDiscriminatorId: "codeInterpreterToolResult", checkBuiltIn: false); @@ -129,8 +127,6 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(FunctionApprovalResponseContent))] [JsonSerializable(typeof(McpServerToolCallContent))] [JsonSerializable(typeof(McpServerToolResultContent))] - [JsonSerializable(typeof(McpServerToolApprovalRequestContent))] - [JsonSerializable(typeof(McpServerToolApprovalResponseContent))] [JsonSerializable(typeof(CodeInterpreterToolCallContent))] [JsonSerializable(typeof(CodeInterpreterToolResultContent))] [JsonSerializable(typeof(ResponseContinuationToken))] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs index 33d17e2963e..59d287edde2 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs @@ -14,7 +14,6 @@ namespace Microsoft.Extensions.AI; WriteIndented = true)] [JsonSerializable(typeof(OpenAIClientExtensions.ToolJson))] [JsonSerializable(typeof(IDictionary))] -[JsonSerializable(typeof(IReadOnlyDictionary))] [JsonSerializable(typeof(string[]))] [JsonSerializable(typeof(IEnumerable))] [JsonSerializable(typeof(JsonElement))] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs index 5fb6ac1935a..59f39c4b364 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -207,9 +207,9 @@ internal static IEnumerable ToChatMessages(IEnumerable ToChatMessages(IEnumerable break; case McpToolCallApprovalRequestItem mtcari: - yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) + yield return CreateUpdate(new FunctionApprovalRequestContent(mtcari.Id, new McpServerToolCallContent(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) { - Arguments = JsonSerializer.Deserialize(mtcari.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, + Arguments = JsonSerializer.Deserialize(mtcari.ToolArguments, OpenAIJsonContext.Default.IDictionaryStringObject), RawRepresentation = mtcari, }) { @@ -819,7 +815,7 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable? idToContentMapping = null; + Dictionary? idToContentMapping = null; foreach (ChatMessage input in inputs) { @@ -852,7 +848,7 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable rawRep, - McpServerToolApprovalResponseContent mcpResp => ResponseItem.CreateMcpApprovalResponseItem(mcpResp.Id, mcpResp.Approved), + FunctionApprovalResponseContent { FunctionCall: McpServerToolCallContent mcpCall } farc => ResponseItem.CreateMcpApprovalResponseItem(mcpCall.CallId, farc.Approved), _ => null }; @@ -1049,8 +1045,8 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera } break; - case McpServerToolApprovalResponseContent mcpApprovalResponseContent: - yield return ResponseItem.CreateMcpApprovalResponseItem(mcpApprovalResponseContent.Id, mcpApprovalResponseContent.Approved); + case FunctionApprovalResponseContent { FunctionCall: McpServerToolCallContent mcpCall } farc: + yield return ResponseItem.CreateMcpApprovalResponseItem(mcpCall.CallId, farc.Approved); break; } } @@ -1079,6 +1075,20 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera }; break; + case FunctionApprovalRequestContent { FunctionCall: McpServerToolCallContent mcpCall }: + yield return ResponseItem.CreateMcpApprovalRequestItem( + mcpCall.CallId, + mcpCall.ServerName, + mcpCall.ToolName, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes( + mcpCall.Arguments!, + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); + break; + + case McpServerToolCallContent mstcc: + (idToContentMapping ??= [])[mstcc.CallId] = mstcc; + break; + case FunctionCallContent callContent: yield return ResponseItem.CreateFunctionCallItem( callContent.CallId, @@ -1088,34 +1098,23 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); break; - case McpServerToolApprovalRequestContent mcpApprovalRequestContent: - yield return ResponseItem.CreateMcpApprovalRequestItem( - mcpApprovalRequestContent.Id, - mcpApprovalRequestContent.ToolCall.ServerName, - mcpApprovalRequestContent.ToolCall.ToolName, - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(mcpApprovalRequestContent.ToolCall.Arguments!, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject))); - break; - - case McpServerToolCallContent mstcc: - (idToContentMapping ??= [])[mstcc.CallId] = mstcc; - break; - case McpServerToolResultContent mstrc: - if (idToContentMapping?.TryGetValue(mstrc.CallId, out AIContent? callContentFromMapping) is true && - callContentFromMapping is McpServerToolCallContent associatedCall) + if (idToContentMapping?.TryGetValue(mstrc.CallId, out McpServerToolCallContent? associatedCall) is true) { _ = idToContentMapping.Remove(mstrc.CallId); McpToolCallItem mtci = ResponseItem.CreateMcpToolCallItem( associatedCall.ServerName, associatedCall.ToolName, - BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes(associatedCall.Arguments!, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject))); - if (mstrc.Output?.OfType().FirstOrDefault() is ErrorContent errorContent) + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes( + associatedCall.Arguments!, + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); + if (mstrc.Result is BinaryData errorData) { - mtci.Error = BinaryData.FromString(errorContent.Message); + mtci.Error = errorData; } - else + else if (mstrc.Result is string outputString) { - mtci.ToolOutput = string.Concat(mstrc.Output?.OfType() ?? []); + mtci.ToolOutput = outputString; } yield return mtci; @@ -1300,7 +1299,7 @@ private static void AddMcpToolCallContent(McpToolCallItem mtci, IList { contents.Add(new McpServerToolCallContent(mtci.Id, mtci.ToolName, mtci.ServerLabel) { - Arguments = JsonSerializer.Deserialize(mtci.ToolArguments.ToMemory().Span, OpenAIJsonContext.Default.IReadOnlyDictionaryStringObject)!, + Arguments = JsonSerializer.Deserialize(mtci.ToolArguments, OpenAIJsonContext.Default.IDictionaryStringObject), // We purposefully do not set the RawRepresentation on the McpServerToolCallContent, only on the McpServerToolResultContent, to avoid // the same McpToolCallItem being included on two different AIContent instances. When these are roundtripped, we want only one @@ -1310,9 +1309,7 @@ private static void AddMcpToolCallContent(McpToolCallItem mtci, IList contents.Add(new McpServerToolResultContent(mtci.Id) { RawRepresentation = mtci, - Output = [mtci.Error is not null ? - new ErrorContent(mtci.Error.ToString()) : - new TextContent(mtci.ToolOutput)], + Result = mtci.Error ?? (object)mtci.ToolOutput }); } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index cc43c192f89..1241ab1cc24 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -1245,7 +1245,7 @@ FunctionResultContent CreateFunctionResultContent(FunctionInvocationResult resul /// /// 1. Remove all and from the . /// 2. Recreate for any that haven't been executed yet. - /// 3. Genreate failed for any rejected . + /// 3. Generate failed for any rejected . /// 4. add all the new content items to and return them as the pre-invocation history. /// private static (List? preDownstreamCallHistory, List? approvals) ProcessFunctionApprovalResponses( @@ -1326,13 +1326,13 @@ private static (List? approvals, List { { "param1", 123 } })), new McpServerToolCallContent("call123", "myTool", "myServer"), new McpServerToolResultContent("call123"), - new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), - new McpServerToolApprovalResponseContent("request123", approved: true) + new FunctionApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), + new FunctionApprovalResponseContent("request123", approved: true, new McpServerToolCallContent("call456", "myTool2", "myServer2")) ]); var serialized = JsonSerializer.Serialize(message, AIJsonUtilities.DefaultOptions); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs index d5c5b43ed0a..9d0e0156f5d 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs @@ -39,7 +39,7 @@ public void Constructor_PropsRoundtrip() Assert.Same(props, c.AdditionalProperties); Assert.Null(c.Arguments); - IReadOnlyDictionary args = new Dictionary(); + IDictionary args = new Dictionary(); c.Arguments = args; Assert.Same(args, c.Arguments); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs index 8fa6cc8a381..94776705660 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs @@ -17,7 +17,7 @@ public void Constructor_PropsDefault() Assert.Equal("callId", c.CallId); Assert.Null(c.RawRepresentation); Assert.Null(c.AdditionalProperties); - Assert.Null(c.Output); + Assert.Null(c.Result); } [Fact] @@ -37,10 +37,10 @@ public void Constructor_PropsRoundtrip() Assert.Equal("callId", c.CallId); - Assert.Null(c.Output); + Assert.Null(c.Result); IList output = []; - c.Output = output; - Assert.Same(output, c.Output); + c.Result = output; + Assert.Same(output, c.Result); } [Fact] @@ -55,7 +55,7 @@ public void Serialization_Roundtrips() { var content = new McpServerToolResultContent("call123") { - Output = new List { new TextContent("result") } + Result = new List { new TextContent("result") } }; var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); @@ -63,6 +63,6 @@ public void Serialization_Roundtrips() Assert.NotNull(deserializedContent); Assert.Equal(content.CallId, deserializedContent.CallId); - Assert.NotNull(deserializedContent.Output); + Assert.NotNull(deserializedContent.Result); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs index fc4dac9cabb..9661972112f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs @@ -33,16 +33,19 @@ public void Constructor_Roundtrips(string id) [Fact] public void Serialization_DerivedTypes_Roundtrips() { - UserInputRequestContent content = new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })); + FunctionApprovalRequestContent content = new FunctionApprovalRequestContent( + "request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })); var serializedContent = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); - var deserializedContent = JsonSerializer.Deserialize(serializedContent, AIJsonUtilities.DefaultOptions); + var deserializedContent = JsonSerializer.Deserialize(serializedContent, AIJsonUtilities.DefaultOptions); Assert.NotNull(deserializedContent); Assert.Equal(content.GetType(), deserializedContent.GetType()); UserInputRequestContent[] contents = [ new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), - new McpServerToolApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), + + // Uncomment once McpServerToolCallContent is no longer experimental. + // new FunctionApprovalRequestContent("request123", new McpServerToolCallContent("call123", "myTool", "myServer")), ]; var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.UserInputRequestContentArray); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs index 2442e57272d..39d6307fff5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs @@ -40,7 +40,9 @@ public void Serialization_DerivedTypes_Roundtrips() UserInputResponseContent[] contents = [ new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")), - new McpServerToolApprovalResponseContent("request123", true), + + // Uncomment once McpServerToolCallContent is no longer experimental. + // new FunctionApprovalResponseContent("request123", true, new McpServerToolCallContent("call123", "myTool", "myServer")), ]; var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.UserInputResponseContentArray); diff --git a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs index 1293d14aec3..337ede2d5e5 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Integration.Tests/ChatClientIntegrationTests.cs @@ -1742,6 +1742,14 @@ public Task> SelectToolsForRequestAsync( } } + protected static void SkipIfRateLimitedResponse(ChatResponse response) + { + if (response.Messages.Any(m => m.Contents.OfType().Any(e => e.ErrorCode == "rate_limit_exceeded"))) + { + throw new SkipTestException("Rate limit exceeded. Test cannot continue."); + } + } + [MemberNotNull(nameof(ChatClient))] protected void SkipIfNotEnabled() { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index 1421e780dca..78233b9111a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -144,79 +144,86 @@ await client.GetStreamingResponseAsync(Prompt, chatOptions).ToChatResponseAsync( Assert.NotNull(response); Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType()); Assert.NotEmpty(response.Messages.SelectMany(m => m.Contents).OfType()); - Assert.Empty(response.Messages.SelectMany(m => m.Contents).OfType()); + Assert.Empty(response.Messages.SelectMany(m => m.Contents).OfType()); Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text); } } - [ConditionalFact] - public async Task RemoteMCP_CallTool_ApprovalRequired() + [ConditionalTheory] + [InlineData(false, false, false)] + [InlineData(false, false, true)] + [InlineData(true, true, false)] + [InlineData(true, true, true)] + public async Task RemoteMCP_CallTool_ApprovalRequired(bool streaming, bool requireSpecific, bool useConversationId) { SkipIfNotEnabled(); - await RunAsync(false, false, false); - await RunAsync(true, true, false); - await RunAsync(false, false, true); - await RunAsync(true, true, true); - - async Task RunAsync(bool streaming, bool requireSpecific, bool useConversationId) + ChatOptions chatOptions = new() { - ChatOptions chatOptions = new() - { - Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) - { - ApprovalMode = requireSpecific ? - HostedMcpServerToolApprovalMode.RequireSpecific(["read_wiki_structure", "ask_question"], null) : - HostedMcpServerToolApprovalMode.AlwaysRequire, - } - ], - }; + Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) + { + ApprovalMode = requireSpecific ? + HostedMcpServerToolApprovalMode.RequireSpecific(["read_wiki_structure", "ask_question"], null) : + HostedMcpServerToolApprovalMode.AlwaysRequire, + } + ], + }; - using var client = CreateChatClient()!; + using var client = CreateChatClient()!; - // Initial request - List input = [new ChatMessage(ChatRole.User, "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository")]; - ChatResponse response = streaming ? - await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : - await client.GetResponseAsync(input, chatOptions); + // Initial request + List input = [new ChatMessage(ChatRole.User, "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository")]; + ChatResponse response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + SkipIfRateLimitedResponse(response); - // Handle approvals of up to two rounds of tool calls - int approvalsCount = 0; - for (int i = 0; i < 2; i++) + // Handle approvals of up to two rounds of tool calls + int approvalsCount = 0; + for (int i = 0; i < 2; i++) + { + if (useConversationId) { - if (useConversationId) - { - chatOptions.ConversationId = response.ConversationId; - input.Clear(); - } - else - { - input.AddRange(response.Messages); - } - - var approvalResponse = new ChatMessage(ChatRole.Tool, - response.Messages - .SelectMany(m => m.Contents) - .OfType() - .Select(c => new McpServerToolApprovalResponseContent(c.ToolCall.CallId, true)) - .ToArray()); - if (approvalResponse.Contents.Count == 0) - { - break; - } + chatOptions.ConversationId = response.ConversationId; + input.Clear(); + } + else + { + input.AddRange(response.Messages); + } - approvalsCount += approvalResponse.Contents.Count; - input.Add(approvalResponse); - response = streaming ? - await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : - await client.GetResponseAsync(input, chatOptions); + var approvalResponse = new ChatMessage(ChatRole.Tool, + response.Messages + .SelectMany(m => m.Contents) + .OfType() + .Select(c => + { + var mcpCallContent = Assert.IsType(c.FunctionCall); + return new FunctionApprovalResponseContent(mcpCallContent.CallId, true, mcpCallContent); + }) + .ToArray()); + if (approvalResponse.Contents.Count == 0) + { + break; } - // Validate final response - Assert.Equal(2, approvalsCount); - Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text); + approvalsCount += approvalResponse.Contents.Count; + input.Add(approvalResponse); + response = streaming ? + await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() : + await client.GetResponseAsync(input, chatOptions); + SkipIfRateLimitedResponse(response); } + + if (approvalsCount == 0) + { + throw new InvalidOperationException("No approvals were requested; the test setup may be incorrect."); + } + + // Validate final response + Assert.Equal(2, approvalsCount); + Assert.Contains("src/Libraries/Microsoft.Extensions.AI.Abstractions/README.md", response.Text); } [ConditionalFact] @@ -407,8 +414,9 @@ await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() if (approval) { input.AddRange(response.Messages); - var approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); - Assert.Equal("search_events", approvalRequest.ToolCall.ToolName); + var approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + var mcpCallContent = Assert.IsType(approvalRequest.FunctionCall); + Assert.Equal("search_events", mcpCallContent.ToolName); input.Add(new ChatMessage(ChatRole.Tool, [approvalRequest.CreateResponse(true)])); response = streaming ? @@ -421,8 +429,7 @@ await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() Assert.Equal("search_events", toolCall.ToolName); var toolResult = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); - var content = Assert.IsType(Assert.Single(toolResult.Output!)); - Assert.Equal(@"{""events"": [], ""next_page_token"": null}", content.Text); + Assert.Equal(@"{""events"": [], ""next_page_token"": null}", toolResult.Result); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index d1a6749450b..58f4338d7b1 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -1286,7 +1286,7 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) { Tools = [new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp"))] }; - McpServerToolApprovalRequestContent approvalRequest; + FunctionApprovalRequestContent approvalRequest; using (VerbatimHttpHandler handler = new(input, output)) using (HttpClient httpClient = new(handler)) @@ -1296,7 +1296,7 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) "Tell me the path to the README.md file for Microsoft.Extensions.AI.Abstractions in the dotnet/extensions repository", chatOptions); - approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); + approvalRequest = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); chatOptions.ConversationId = response.ConversationId; } @@ -1441,8 +1441,7 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) var result = Assert.IsType(message.Contents[1]); Assert.Equal("mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", result.CallId); - Assert.NotNull(result.Output); - Assert.StartsWith("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at", Assert.IsType(Assert.Single(result.Output)).Text); + Assert.StartsWith("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at", Assert.IsType(result.Result)); Assert.NotNull(response.Usage); Assert.Equal(542, response.Usage.InputTokenCount); @@ -1695,8 +1694,7 @@ public async Task McpToolCall_ApprovalNotRequired_NonStreaming(bool rawTool) var firstResult = Assert.IsType(message.Contents[2]); Assert.Equal("mcp_68be4166acfc8191bc5e0a751eed358b0384f747588fc3f5", firstResult.CallId); - Assert.NotNull(firstResult.Output); - Assert.StartsWith("Available pages for dotnet/extensions", Assert.IsType(Assert.Single(firstResult.Output)).Text); + Assert.StartsWith("Available pages for dotnet/extensions", Assert.IsType(firstResult.Result)); var secondCall = Assert.IsType(message.Contents[3]); Assert.Equal("mcp_68be416900f88191837ae0718339a4ce0384f747588fc3f5", secondCall.CallId); @@ -1708,8 +1706,7 @@ public async Task McpToolCall_ApprovalNotRequired_NonStreaming(bool rawTool) var secondResult = Assert.IsType(message.Contents[4]); Assert.Equal("mcp_68be416900f88191837ae0718339a4ce0384f747588fc3f5", secondResult.CallId); - Assert.NotNull(secondResult.Output); - Assert.StartsWith("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at", Assert.IsType(Assert.Single(secondResult.Output)).Text); + Assert.StartsWith("The `README.md` file for `Microsoft.Extensions.AI.Abstractions` is located at", Assert.IsType(secondResult.Result)); Assert.NotNull(response.Usage); Assert.Equal(1329, response.Usage.InputTokenCount); @@ -1717,6 +1714,208 @@ public async Task McpToolCall_ApprovalNotRequired_NonStreaming(bool rawTool) Assert.Equal(1452, response.Usage.TotalTokenCount); } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task McpToolCall_ErrorResponse_NonStreaming(bool rawTool) + { + const string Input = """ + { + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "mymcp", + "server_url": "https://mcp.example.com/mcp", + "require_approval": "never" + } + ], + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "Test error handling" + } + ] + } + ] + } + """; + + const string Output = """ + { + "id": "resp_689023b0fa88819f99f48aff343d5ad50475557f6fefb5f0", + "object": "response", + "created_at": 1757299100, + "status": "completed", + "background": false, + "error": null, + "incomplete_details": null, + "instructions": null, + "max_output_tokens": null, + "max_tool_calls": null, + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "mcpl_689023b0fa88819f99f48aff343d5ad50475557f6fefb5f0", + "type": "mcp_list_tools", + "server_label": "mymcp", + "tools": [ + { + "annotations": { + "read_only": false + }, + "description": "A tool that always errors", + "input_schema": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "$schema": "http://json-schema.org/draft-07/schema#" + }, + "name": "test_error" + } + ] + }, + { + "id": "mcp_689023b0fa88819f99f48aff343d5ad50475557f6fefb5f0", + "type": "mcp_call", + "approval_request_id": null, + "arguments": "{}", + "error": { + "type": "mcp_tool_execution_error", + "content": [ + { + "type": "text", + "text": "An error occurred invoking 'test_error'.", + "annotations": null, + "meta": null + } + ] + }, + "name": "test_error", + "output": null, + "server_label": "mymcp" + }, + { + "id": "msg_689023b0fa88819f99f48aff343d5ad50475557f6fefb5f0", + "type": "message", + "status": "completed", + "content": [ + { + "type": "output_text", + "annotations": [], + "logprobs": [], + "text": "The tool encountered an error during execution." + } + ], + "role": "assistant" + } + ], + "parallel_tool_calls": true, + "previous_response_id": null, + "prompt_cache_key": null, + "reasoning": { + "effort": null, + "summary": null + }, + "safety_identifier": null, + "service_tier": "default", + "store": true, + "temperature": 1, + "text": { + "format": { + "type": "text" + }, + "verbosity": "medium" + }, + "tool_choice": "auto", + "tools": [ + { + "type": "mcp", + "allowed_tools": null, + "headers": null, + "require_approval": "never", + "server_description": null, + "server_label": "mymcp", + "server_url": "https://mcp.example.com/" + } + ], + "top_logprobs": 0, + "top_p": 1, + "truncation": "disabled", + "usage": { + "input_tokens": 500, + "input_tokens_details": { + "cached_tokens": 0 + }, + "output_tokens": 50, + "output_tokens_details": { + "reasoning_tokens": 0 + }, + "total_tokens": 550 + }, + "user": null, + "metadata": {} + } + """; + + using VerbatimHttpHandler handler = new(Input, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + AITool mcpTool = rawTool ? + ResponseTool.CreateMcpTool("mymcp", serverUri: new("https://mcp.example.com/mcp"), toolCallApprovalPolicy: new McpToolCallApprovalPolicy(GlobalMcpToolCallApprovalPolicy.NeverRequireApproval)).AsAITool() : + new HostedMcpServerTool("mymcp", new Uri("https://mcp.example.com/mcp")) + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, + }; + + ChatOptions chatOptions = new() + { + Tools = [mcpTool], + }; + + var response = await client.GetResponseAsync("Test error handling", chatOptions); + Assert.NotNull(response); + + Assert.Equal("resp_689023b0fa88819f99f48aff343d5ad50475557f6fefb5f0", response.ResponseId); + Assert.Equal("resp_689023b0fa88819f99f48aff343d5ad50475557f6fefb5f0", response.ConversationId); + Assert.Equal("gpt-4o-mini-2024-07-18", response.ModelId); + Assert.Equal(DateTimeOffset.FromUnixTimeSeconds(1_757_299_100), response.CreatedAt); + Assert.Null(response.FinishReason); + + var message = Assert.Single(response.Messages); + Assert.Equal(ChatRole.Assistant, response.Messages[0].Role); + Assert.Equal("The tool encountered an error during execution.", response.Messages[0].Text); + + Assert.Equal(4, message.Contents.Count); + + var toolCall = Assert.IsType(message.Contents[1]); + Assert.Equal("mcp_689023b0fa88819f99f48aff343d5ad50475557f6fefb5f0", toolCall.CallId); + Assert.Equal("mymcp", toolCall.ServerName); + Assert.Equal("test_error", toolCall.ToolName); + Assert.NotNull(toolCall.Arguments); + Assert.Empty(toolCall.Arguments); + + var toolResult = Assert.IsType(message.Contents[2]); + Assert.Equal("mcp_689023b0fa88819f99f48aff343d5ad50475557f6fefb5f0", toolResult.CallId); + var errorData = Assert.IsType(toolResult.Result); + var errorJson = JsonDocument.Parse(errorData); + Assert.Equal("mcp_tool_execution_error", errorJson.RootElement.GetProperty("type").GetString()); + var contentArray = errorJson.RootElement.GetProperty("content"); + Assert.Equal(1, contentArray.GetArrayLength()); + Assert.Equal("text", contentArray[0].GetProperty("type").GetString()); + Assert.Equal("An error occurred invoking 'test_error'.", contentArray[0].GetProperty("text").GetString()); + + Assert.NotNull(response.Usage); + Assert.Equal(500, response.Usage.InputTokenCount); + Assert.Equal(50, response.Usage.OutputTokenCount); + Assert.Equal(550, response.Usage.TotalTokenCount); + } + [Fact] public async Task McpToolCall_ApprovalNotRequired_Streaming() { @@ -2108,8 +2307,7 @@ public async Task McpToolCall_ApprovalNotRequired_Streaming() var firstResult = Assert.IsType(message.Contents[2]); Assert.Equal("mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54", firstResult.CallId); - Assert.NotNull(firstResult.Output); - Assert.StartsWith("Available pages for dotnet/extensions", Assert.IsType(Assert.Single(firstResult.Output)).Text); + Assert.StartsWith("Available pages for dotnet/extensions", Assert.IsType(firstResult.Result)); var secondCall = Assert.IsType(message.Contents[3]); Assert.Equal("mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54", secondCall.CallId); @@ -2121,8 +2319,7 @@ public async Task McpToolCall_ApprovalNotRequired_Streaming() var secondResult = Assert.IsType(message.Contents[4]); Assert.Equal("mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54", secondResult.CallId); - Assert.NotNull(secondResult.Output); - Assert.StartsWith("The path to the `README.md` file", Assert.IsType(Assert.Single(secondResult.Output)).Text); + Assert.StartsWith("The path to the `README.md` file", Assert.IsType(secondResult.Result)); Assert.NotNull(response.Usage); Assert.Equal(1420, response.Usage.InputTokenCount); diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index 01f2e111447..abd2a5aac43 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -9,7 +9,7 @@ using System.Threading.Tasks; using Xunit; -namespace Microsoft.Extensions.AI.ChatCompletion; +namespace Microsoft.Extensions.AI; public class FunctionInvokingChatClientApprovalsTests { @@ -1113,6 +1113,219 @@ async IAsyncEnumerable YieldInnerClientUpdates( } } + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task FunctionCallReplacedWithApproval_MixedWithMcpApprovalAsync(bool useAdditionalTools) + { + AITool[] tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func")), + new HostedMcpServerTool("myServer", "https://localhost/mcp") + ]; + + var options = new ChatOptions + { + Tools = useAdditionalTools ? null : tools + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func"), + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]) + ]; + + List expectedOutput = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func")), + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null); + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, expectedOutput, additionalTools: useAdditionalTools ? tools : null); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public async Task ApprovedApprovalResponseIsExecuted_MixedWithMcpApprovalAsync(bool useAdditionalTools) + { + AITool[] tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func")), + new HostedMcpServerTool("myServer", "https://localhost/mcp") + ]; + + var options = new ChatOptions + { + Tools = useAdditionalTools ? null : tools + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func")), + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func")), + new FunctionApprovalResponseContent("callId2", true, new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + ]; + + List expectedDownstreamClientInput = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId2", true, new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func") + ]), + new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("callId1", result: "Result 1") + ]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [ + new McpServerToolResultContent("callId2") { Result = new List { new TextContent("Result 2") } }, + new TextContent("world") + ]) + ]; + + List output = + [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func") + ]), + new ChatMessage(ChatRole.Tool, + [ + new FunctionResultContent("callId1", result: "Result 1") + ]), + new ChatMessage(ChatRole.Assistant, [ + new McpServerToolResultContent("callId2") { Result = new List { new TextContent("Result 2") } }, + new TextContent("world") + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null); + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null); + } + + [Theory] + [InlineData(false, true, false)] + [InlineData(false, false, true)] + [InlineData(true, true, false)] + [InlineData(true, false, true)] + public async Task RejectedApprovalResponses_MixedWithMcpApprovalAsync(bool useAdditionalTools, bool approveFuncCall, bool approveMcpCall) + { + Assert.NotEqual(approveFuncCall, approveMcpCall); + + AITool[] tools = + [ + new ApprovalRequiredAIFunction(AIFunctionFactory.Create(() => "Result 1", "Func")), + new HostedMcpServerTool("myServer", "https://localhost/mcp") + ]; + + var options = new ChatOptions + { + Tools = useAdditionalTools ? null : tools + }; + + List input = + [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func")), + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId1", approveFuncCall, new FunctionCallContent("callId1", "Func")), + new FunctionApprovalResponseContent("callId2", approveMcpCall, new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + ]; + + List expectedDownstreamClientInput = [ + new ChatMessage(ChatRole.User, "hello"), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("callId2", approveMcpCall, new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]), + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func") + ]), + new ChatMessage(ChatRole.Tool, + [ + approveFuncCall ? + new FunctionResultContent("callId1", result: "Result 1") : + new FunctionResultContent("callId1", result: "Tool call invocation rejected.") + ]), + ]; + + List downstreamClientOutput = + [ + new ChatMessage(ChatRole.Assistant, [ + new TextContent("world"), + .. approveMcpCall ? + [new McpServerToolResultContent("callId2") { Result = new List { new TextContent("Result 2") } }] : + Array.Empty() + ]) + ]; + + List output = [ + new ChatMessage(ChatRole.Assistant, + [ + new FunctionCallContent("callId1", "Func"), + ]), + new ChatMessage(ChatRole.Tool, + [ + approveFuncCall ? + new FunctionResultContent("callId1", result: "Result 1") : + new FunctionResultContent("callId1", result: "Tool call invocation rejected.") + ]), + new ChatMessage(ChatRole.Assistant, [ + new TextContent("world"), + .. approveMcpCall ? + [new McpServerToolResultContent("callId2") { Result = new List { new TextContent("Result 2") } }] : + Array.Empty() + ]) + ]; + + await InvokeAndAssertAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null); + await InvokeAndAssertStreamingAsync(options, input, downstreamClientOutput, output, expectedDownstreamClientInput, additionalTools: useAdditionalTools ? tools : null); + } + private static Task> InvokeAndAssertAsync( ChatOptions? options, List input,