diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml new file mode 100644 index 00000000000..d266b9d2d4e --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/CompatibilitySuppressions.xml @@ -0,0 +1,429 @@ + + + + + + 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 + + + CP0001 + T:Microsoft.Extensions.AI.UserInputRequestContent + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.UserInputResponseContent + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_AuthorizationToken + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_AuthorizationToken(System.String) + 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.McpServerToolCallContent.get_ToolName + 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 + + + CP0007 + T:Microsoft.Extensions.AI.FunctionApprovalRequestContent + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + lib/net462/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0007 + T:Microsoft.Extensions.AI.FunctionApprovalResponseContent + 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 + + + CP0001 + T:Microsoft.Extensions.AI.UserInputRequestContent + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.UserInputResponseContent + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_AuthorizationToken + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_AuthorizationToken(System.String) + 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.McpServerToolCallContent.get_ToolName + 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 + + + CP0007 + T:Microsoft.Extensions.AI.FunctionApprovalRequestContent + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + lib/netstandard2.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0007 + T:Microsoft.Extensions.AI.FunctionApprovalResponseContent + 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 + + + CP0001 + T:Microsoft.Extensions.AI.UserInputRequestContent + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.UserInputResponseContent + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_AuthorizationToken + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_AuthorizationToken(System.String) + 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.McpServerToolCallContent.get_ToolName + 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 + + + CP0007 + T:Microsoft.Extensions.AI.FunctionApprovalRequestContent + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net8.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0007 + T:Microsoft.Extensions.AI.FunctionApprovalResponseContent + 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 + + + CP0001 + T:Microsoft.Extensions.AI.UserInputRequestContent + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.UserInputResponseContent + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_AuthorizationToken + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_AuthorizationToken(System.String) + 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.McpServerToolCallContent.get_ToolName + 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 + + + CP0007 + T:Microsoft.Extensions.AI.FunctionApprovalRequestContent + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0007 + T:Microsoft.Extensions.AI.FunctionApprovalResponseContent + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net9.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + + CP0001 + T:Microsoft.Extensions.AI.McpServerToolApprovalRequestContent + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.McpServerToolApprovalResponseContent + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.UserInputRequestContent + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0001 + T:Microsoft.Extensions.AI.UserInputResponseContent + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.get_AuthorizationToken + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.HostedMcpServerTool.set_AuthorizationToken(System.String) + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolCallContent.get_Arguments + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolCallContent.get_ToolName + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolResultContent.get_Output + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0002 + M:Microsoft.Extensions.AI.McpServerToolResultContent.set_Output(System.Collections.Generic.IList{Microsoft.Extensions.AI.AIContent}) + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0007 + T:Microsoft.Extensions.AI.FunctionApprovalRequestContent + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + true + + + CP0007 + T:Microsoft.Extensions.AI.FunctionApprovalResponseContent + lib/net10.0/Microsoft.Extensions.AI.Abstractions.dll + lib/net10.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..55d01b2d310 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/AIContent.cs @@ -18,18 +18,16 @@ namespace Microsoft.Extensions.AI; [JsonDerivedType(typeof(TextReasoningContent), typeDiscriminator: "reasoning")] [JsonDerivedType(typeof(UriContent), typeDiscriminator: "uri")] [JsonDerivedType(typeof(UsageContent), typeDiscriminator: "usage")] +[JsonDerivedType(typeof(FunctionApprovalRequestContent), typeDiscriminator: "functionApprovalRequest")] +[JsonDerivedType(typeof(FunctionApprovalResponseContent), typeDiscriminator: "functionApprovalResponse")] +[JsonDerivedType(typeof(McpServerToolCallContent), typeDiscriminator: "mcpServerToolCall")] +[JsonDerivedType(typeof(McpServerToolResultContent), typeDiscriminator: "mcpServerToolResult")] // These should be added in once they're no longer [Experimental]. If they're included while still // experimental, any JsonSerializerContext that includes AIContent will incur errors about using // experimental types in its source generated files. When [Experimental] is removed from these types, // these lines should be uncommented and the corresponding lines in AIJsonUtilities.CreateDefaultOptions // as well as the [JsonSerializable] attributes for them on the JsonContext should be removed. -// [JsonDerivedType(typeof(FunctionApprovalRequestContent), typeDiscriminator: "functionApprovalRequest")] -// [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/FunctionApprovalRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs index f5a394cd63d..5e9cbcc3d04 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalRequestContent.cs @@ -2,42 +2,39 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; /// -/// Represents a request for user approval of a function call. +/// Represents a request for approval before invoking a function call. /// -[Experimental(DiagnosticIds.Experiments.AIFunctionApprovals, UrlFormat = DiagnosticIds.UrlFormat)] -public sealed class FunctionApprovalRequestContent : UserInputRequestContent +public sealed class FunctionApprovalRequestContent : InputRequestContent { /// /// Initializes a new instance of the class. /// - /// The ID that uniquely identifies the function approval request/response pair. - /// The function call that requires user approval. - /// is . - /// is empty or composed entirely of whitespace. + /// The unique identifier that correlates this request with its corresponding response. This may differ from the of the specified . + /// The function call that requires approval before execution. + /// is . + /// is empty or composed entirely of whitespace. /// is . - public FunctionApprovalRequestContent(string id, FunctionCallContent functionCall) - : base(id) + public FunctionApprovalRequestContent(string requestId, FunctionCallContent functionCall) + : base(requestId) { FunctionCall = Throw.IfNull(functionCall); } /// - /// Gets the function call that pre-invoke approval is required for. + /// Gets the function call that requires approval before execution. /// public FunctionCallContent FunctionCall { get; } /// - /// Creates a to indicate whether the function call is approved or rejected based on the value of . + /// Creates a indicating whether the function call is approved or rejected. /// /// if the function call is approved; otherwise, . /// An optional reason for the approval or rejection. - /// The representing the approval response. - public FunctionApprovalResponseContent CreateResponse(bool approved, string? reason = null) => new(Id, approved, FunctionCall) { Reason = reason }; + /// The correlated with this request. + public FunctionApprovalResponseContent CreateResponse(bool approved, string? reason = null) => new(RequestId, approved, FunctionCall) { Reason = reason }; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs index 5cc04c61442..04f12309b37 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionApprovalResponseContent.cs @@ -2,41 +2,38 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; /// -/// Represents a response to a function approval request. +/// Represents a response to a , indicating whether the function call was approved. /// -[Experimental(DiagnosticIds.Experiments.AIFunctionApprovals, UrlFormat = DiagnosticIds.UrlFormat)] -public sealed class FunctionApprovalResponseContent : UserInputResponseContent +public sealed class FunctionApprovalResponseContent : InputResponseContent { /// /// Initializes a new instance of the class. /// - /// The ID that uniquely identifies the function approval request/response pair. + /// The unique identifier of the associated with this response. /// if the function call is approved; otherwise, . - /// The function call that requires user approval. - /// is . - /// is empty or composed entirely of whitespace. + /// The function call that was subject to approval. + /// is . + /// is empty or composed entirely of whitespace. /// is . - public FunctionApprovalResponseContent(string id, bool approved, FunctionCallContent functionCall) - : base(id) + public FunctionApprovalResponseContent(string requestId, bool approved, FunctionCallContent functionCall) + : base(requestId) { Approved = approved; FunctionCall = Throw.IfNull(functionCall); } /// - /// Gets a value indicating whether the user approved the request. + /// Gets a value indicating whether the function call was approved for execution. /// public bool Approved { get; } /// - /// Gets the function call for which approval was requested. + /// Gets the function call that was subject to approval. /// public FunctionCallContent FunctionCall { get; } diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs index 6ec7febc486..042e862f986 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionCallContent.cs @@ -14,6 +14,7 @@ namespace Microsoft.Extensions.AI; /// Represents a function call request. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] +[JsonDerivedType(typeof(McpServerToolCallContent), "mcpServerToolCall")] public class FunctionCallContent : AIContent { /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs index d5eb4884709..9ffdb11472d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/FunctionResultContent.cs @@ -13,6 +13,7 @@ namespace Microsoft.Extensions.AI; /// Represents the result of a function call. /// [DebuggerDisplay("{DebuggerDisplay,nq}")] +[JsonDerivedType(typeof(McpServerToolResultContent), "mcpServerToolResult")] public class FunctionResultContent : AIContent { /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/InputRequestContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/InputRequestContent.cs new file mode 100644 index 00000000000..80fdf292d50 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/InputRequestContent.cs @@ -0,0 +1,31 @@ +// 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.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents a request for input from the user or application. +/// +[JsonDerivedType(typeof(FunctionApprovalRequestContent), "functionApprovalRequest")] +public class InputRequestContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier that correlates this request with its corresponding response. + /// is . + /// is empty or composed entirely of whitespace. + protected InputRequestContent(string requestId) + { + RequestId = Throw.IfNullOrWhitespace(requestId); + } + + /// + /// Gets the unique identifier that correlates this request with its corresponding . + /// + public string RequestId { get; } +} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/InputResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/InputResponseContent.cs new file mode 100644 index 00000000000..086427dbab5 --- /dev/null +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/InputResponseContent.cs @@ -0,0 +1,31 @@ +// 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.Text.Json.Serialization; +using Microsoft.Shared.Diagnostics; + +namespace Microsoft.Extensions.AI; + +/// +/// Represents the response to an . +/// +[JsonDerivedType(typeof(FunctionApprovalResponseContent), "functionApprovalResponse")] +public class InputResponseContent : AIContent +{ + /// + /// Initializes a new instance of the class. + /// + /// The unique identifier that correlates this response with its corresponding request. + /// is . + /// is empty or composed entirely of whitespace. + protected InputResponseContent(string requestId) + { + RequestId = Throw.IfNullOrWhitespace(requestId); + } + + /// + /// Gets the unique identifier that correlates this response with its corresponding . + /// + public string RequestId { get; } +} 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 8a611b1fc82..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalRequestContent.cs +++ /dev/null @@ -1,42 +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.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents a request for user approval of an MCP server tool call. -/// -[Experimental(DiagnosticIds.Experiments.AIMcpServers, UrlFormat = DiagnosticIds.UrlFormat)] -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 4eaab83e0db..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolApprovalResponseContent.cs +++ /dev/null @@ -1,33 +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.DiagnosticIds; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents a response to an MCP server tool approval request. -/// -[Experimental(DiagnosticIds.Experiments.AIMcpServers, UrlFormat = DiagnosticIds.UrlFormat)] -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 ef050f69ea2..62b7b25f065 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolCallContent.cs @@ -2,9 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -13,44 +10,35 @@ namespace Microsoft.Extensions.AI; /// Represents a tool call request to a MCP server. /// /// +/// /// This content type is used to represent an invocation of an MCP server tool by a hosted service. -/// It is informational only. +/// It is informational only and may appear as part of an approval request +/// to convey what is being approved, or as a record of which MCP server tool was invoked. +/// /// -[Experimental(DiagnosticIds.Experiments.AIMcpServers, UrlFormat = DiagnosticIds.UrlFormat)] -public sealed class McpServerToolCallContent : AIContent +public sealed class McpServerToolCallContent : FunctionCallContent { /// /// Initializes a new instance of the class. /// /// The tool call ID. - /// The tool name. + /// The tool name. /// The MCP server name that hosts the tool. - /// or is . - /// or is empty or composed entirely of whitespace. - public McpServerToolCallContent(string callId, string toolName, string? serverName) + /// or is . + /// or is empty or composed entirely of whitespace. + /// + /// This content is informational only and may appear as part of an approval request + /// to convey what is being approved, or as a record of which MCP server tool was invoked. + /// + public McpServerToolCallContent(string callId, string name, string? serverName) + : base(Throw.IfNullOrWhitespace(callId), Throw.IfNullOrWhitespace(name)) { - CallId = Throw.IfNullOrWhitespace(callId); - ToolName = Throw.IfNullOrWhitespace(toolName); ServerName = serverName; + InformationalOnly = true; } - /// - /// Gets the tool call ID. - /// - public string CallId { get; } - - /// - /// Gets the name of the tool called. - /// - public string ToolName { get; } - /// /// 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 a8798328019..694b86d4e43 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/McpServerToolResultContent.cs @@ -2,9 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -16,8 +13,7 @@ namespace Microsoft.Extensions.AI; /// This content type is used to represent the result of an invocation of an MCP server tool by a hosted service. /// It is informational only. /// -[Experimental(DiagnosticIds.Experiments.AIMcpServers, UrlFormat = DiagnosticIds.UrlFormat)] -public sealed class McpServerToolResultContent : AIContent +public sealed class McpServerToolResultContent : FunctionResultContent { /// /// Initializes a new instance of the class. @@ -26,17 +22,7 @@ public sealed class McpServerToolResultContent : AIContent /// is . /// is empty or composed entirely of whitespace. 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 deleted file mode 100644 index 9f40b33253c..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputRequestContent.cs +++ /dev/null @@ -1,36 +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 System.Text.Json.Serialization; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents a request for user input. -/// -[Experimental(DiagnosticIds.Experiments.AIFunctionApprovals, UrlFormat = DiagnosticIds.UrlFormat)] -[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] -[JsonDerivedType(typeof(FunctionApprovalRequestContent), "functionApprovalRequest")] -[JsonDerivedType(typeof(McpServerToolApprovalRequestContent), "mcpServerToolApprovalRequest")] -public class UserInputRequestContent : AIContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID that uniquely identifies the user input request/response pair. - /// is . - /// is empty or composed entirely of whitespace. - protected UserInputRequestContent(string id) - { - Id = Throw.IfNullOrWhitespace(id); - } - - /// - /// Gets the ID that uniquely identifies the user input request/response pair. - /// - public string Id { get; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs deleted file mode 100644 index eaddd46f920..00000000000 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Contents/UserInputResponseContent.cs +++ /dev/null @@ -1,36 +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 System.Text.Json.Serialization; -using Microsoft.Shared.DiagnosticIds; -using Microsoft.Shared.Diagnostics; - -namespace Microsoft.Extensions.AI; - -/// -/// Represents the response to a request for user input. -/// -[Experimental(DiagnosticIds.Experiments.AIFunctionApprovals, UrlFormat = DiagnosticIds.UrlFormat)] -[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] -[JsonDerivedType(typeof(FunctionApprovalResponseContent), "functionApprovalResponse")] -[JsonDerivedType(typeof(McpServerToolApprovalResponseContent), "mcpServerToolApprovalResponse")] -public class UserInputResponseContent : AIContent -{ - /// - /// Initializes a new instance of the class. - /// - /// The ID that uniquely identifies the user input request/response pair. - /// is . - /// is empty or composed entirely of whitespace. - protected UserInputResponseContent(string id) - { - Id = Throw.IfNullOrWhitespace(id); - } - - /// - /// Gets the ID that uniquely identifies the user input request/response pair. - /// - public string Id { get; } -} diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/ApprovalRequiredAIFunction.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/ApprovalRequiredAIFunction.cs index 77a93342784..1f51cd16c9d 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/ApprovalRequiredAIFunction.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Functions/ApprovalRequiredAIFunction.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.AI; @@ -15,7 +13,6 @@ namespace Microsoft.Extensions.AI; /// This class simply augments an with an indication that approval is required before invocation. /// It does not enforce the requirement for user approval; it is the responsibility of the invoker to obtain that approval before invoking the function. /// -[Experimental(DiagnosticIds.Experiments.AIFunctionApprovals, UrlFormat = DiagnosticIds.UrlFormat)] public sealed class ApprovalRequiredAIFunction : DelegatingAIFunction { /// diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolAlwaysRequireApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolAlwaysRequireApprovalMode.cs index 608839b116b..dfb48583360 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolAlwaysRequireApprovalMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolAlwaysRequireApprovalMode.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.AI; @@ -13,7 +11,6 @@ namespace Microsoft.Extensions.AI; /// /// Use to get an instance of . /// -[Experimental(DiagnosticIds.Experiments.AIMcpServers, UrlFormat = DiagnosticIds.UrlFormat)] [DebuggerDisplay(nameof(AlwaysRequire))] public sealed class HostedMcpServerToolAlwaysRequireApprovalMode : HostedMcpServerToolApprovalMode { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolApprovalMode.cs index 2d81f4c924f..9c4ca705238 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolApprovalMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolApprovalMode.cs @@ -2,9 +2,7 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Text.Json.Serialization; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.AI; @@ -15,8 +13,6 @@ namespace Microsoft.Extensions.AI; /// The predefined values , and are provided to specify handling for all tools. /// To specify approval behavior for individual tool names, use . /// -[Experimental(DiagnosticIds.Experiments.AIMcpServers, UrlFormat = DiagnosticIds.UrlFormat)] -[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] [JsonDerivedType(typeof(HostedMcpServerToolNeverRequireApprovalMode), typeDiscriminator: "never")] [JsonDerivedType(typeof(HostedMcpServerToolAlwaysRequireApprovalMode), typeDiscriminator: "always")] [JsonDerivedType(typeof(HostedMcpServerToolRequireSpecificApprovalMode), typeDiscriminator: "requireSpecific")] diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolNeverRequireApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolNeverRequireApprovalMode.cs index b21e3a61352..ede9f0f1309 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolNeverRequireApprovalMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolNeverRequireApprovalMode.cs @@ -2,8 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.AI; @@ -13,7 +11,6 @@ namespace Microsoft.Extensions.AI; /// /// Use to get an instance of . /// -[Experimental(DiagnosticIds.Experiments.AIMcpServers, UrlFormat = DiagnosticIds.UrlFormat)] [DebuggerDisplay(nameof(NeverRequire))] public sealed class HostedMcpServerToolNeverRequireApprovalMode : HostedMcpServerToolApprovalMode { diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolRequireSpecificApprovalMode.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolRequireSpecificApprovalMode.cs index 84d1233a357..526c49bc027 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolRequireSpecificApprovalMode.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/HostedMcpServerToolRequireSpecificApprovalMode.cs @@ -3,16 +3,13 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; using System.Linq; -using Microsoft.Shared.DiagnosticIds; namespace Microsoft.Extensions.AI; /// /// Represents a mode where approval behavior is specified for individual tool names. /// -[Experimental(DiagnosticIds.Experiments.AIMcpServers, UrlFormat = DiagnosticIds.UrlFormat)] public sealed class HostedMcpServerToolRequireSpecificApprovalMode : HostedMcpServerToolApprovalMode { /// 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 d7bfcc15bcf..95cfc015c86 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 @@ -729,11 +729,11 @@ }, { "Type": "sealed class Microsoft.Extensions.AI.ApprovalRequiredAIFunction : Microsoft.Extensions.AI.DelegatingAIFunction", - "Stage": "Experimental", + "Stage": "Stable", "Methods": [ { "Member": "Microsoft.Extensions.AI.ApprovalRequiredAIFunction.ApprovalRequiredAIFunction(Microsoft.Extensions.AI.AIFunction innerFunction);", - "Stage": "Experimental" + "Stage": "Stable" } ] }, @@ -1930,46 +1930,46 @@ ] }, { - "Type": "sealed class Microsoft.Extensions.AI.FunctionApprovalRequestContent : Microsoft.Extensions.AI.UserInputRequestContent", - "Stage": "Experimental", + "Type": "sealed class Microsoft.Extensions.AI.FunctionApprovalRequestContent : Microsoft.Extensions.AI.InputRequestContent", + "Stage": "Stable", "Methods": [ { - "Member": "Microsoft.Extensions.AI.FunctionApprovalRequestContent.FunctionApprovalRequestContent(string id, Microsoft.Extensions.AI.FunctionCallContent functionCall);", - "Stage": "Experimental" + "Member": "Microsoft.Extensions.AI.FunctionApprovalRequestContent.FunctionApprovalRequestContent(string requestId, Microsoft.Extensions.AI.FunctionCallContent functionCall);", + "Stage": "Stable" }, { "Member": "Microsoft.Extensions.AI.FunctionApprovalResponseContent Microsoft.Extensions.AI.FunctionApprovalRequestContent.CreateResponse(bool approved, string? reason = null);", - "Stage": "Experimental" + "Stage": "Stable" } ], "Properties": [ { "Member": "Microsoft.Extensions.AI.FunctionCallContent Microsoft.Extensions.AI.FunctionApprovalRequestContent.FunctionCall { get; }", - "Stage": "Experimental" + "Stage": "Stable" } ] }, { - "Type": "sealed class Microsoft.Extensions.AI.FunctionApprovalResponseContent : Microsoft.Extensions.AI.UserInputResponseContent", - "Stage": "Experimental", + "Type": "sealed class Microsoft.Extensions.AI.FunctionApprovalResponseContent : Microsoft.Extensions.AI.InputResponseContent", + "Stage": "Stable", "Methods": [ { - "Member": "Microsoft.Extensions.AI.FunctionApprovalResponseContent.FunctionApprovalResponseContent(string id, bool approved, Microsoft.Extensions.AI.FunctionCallContent functionCall);", - "Stage": "Experimental" + "Member": "Microsoft.Extensions.AI.FunctionApprovalResponseContent.FunctionApprovalResponseContent(string requestId, bool approved, Microsoft.Extensions.AI.FunctionCallContent functionCall);", + "Stage": "Stable" } ], "Properties": [ { "Member": "bool Microsoft.Extensions.AI.FunctionApprovalResponseContent.Approved { get; }", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "Microsoft.Extensions.AI.FunctionCallContent Microsoft.Extensions.AI.FunctionApprovalResponseContent.FunctionCall { get; }", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "string? Microsoft.Extensions.AI.FunctionApprovalResponseContent.Reason { get; set; }", - "Stage": "Experimental" + "Stage": "Stable" } ] }, @@ -2227,145 +2227,141 @@ }, { "Type": "class Microsoft.Extensions.AI.HostedMcpServerTool : Microsoft.Extensions.AI.AITool", - "Stage": "Experimental", + "Stage": "Stable", "Methods": [ { "Member": "Microsoft.Extensions.AI.HostedMcpServerTool.HostedMcpServerTool(string serverName, string serverAddress);", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "Microsoft.Extensions.AI.HostedMcpServerTool.HostedMcpServerTool(string serverName, string serverAddress, System.Collections.Generic.IReadOnlyDictionary? additionalProperties);", - "Stage": "Experimental" + "Stage": "Stable" }, { - "Member": "Microsoft.Extensions.AI.HostedMcpServerTool.HostedMcpServerTool(string serverName, System.Uri serverUrl);", - "Stage": "Experimental" + "Member": "Microsoft.Extensions.AI.HostedMcpServerTool.HostedMcpServerTool(string serverName, System.Uri serverAddress);", + "Stage": "Stable" }, { - "Member": "Microsoft.Extensions.AI.HostedMcpServerTool.HostedMcpServerTool(string serverName, System.Uri serverUrl, System.Collections.Generic.IReadOnlyDictionary? additionalProperties);", - "Stage": "Experimental" + "Member": "Microsoft.Extensions.AI.HostedMcpServerTool.HostedMcpServerTool(string serverName, System.Uri serverAddress, System.Collections.Generic.IReadOnlyDictionary? additionalProperties);", + "Stage": "Stable" } ], "Properties": [ { "Member": "override System.Collections.Generic.IReadOnlyDictionary Microsoft.Extensions.AI.HostedMcpServerTool.AdditionalProperties { get; }", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedMcpServerTool.AllowedTools { get; set; }", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "Microsoft.Extensions.AI.HostedMcpServerToolApprovalMode? Microsoft.Extensions.AI.HostedMcpServerTool.ApprovalMode { get; set; }", - "Stage": "Experimental" - }, - { - "Member": "string? Microsoft.Extensions.AI.HostedMcpServerTool.AuthorizationToken { get; set; }", - "Stage": "Experimental" + "Stage": "Stable" }, { - "Member": "System.Collections.Generic.IDictionary Microsoft.Extensions.AI.HostedMcpServerTool.Headers { get; }", - "Stage": "Experimental" + "Member": "System.Collections.Generic.IDictionary? Microsoft.Extensions.AI.HostedMcpServerTool.Headers { get; set; }", + "Stage": "Stable" }, { "Member": "override string Microsoft.Extensions.AI.HostedMcpServerTool.Name { get; }", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "string Microsoft.Extensions.AI.HostedMcpServerTool.ServerAddress { get; }", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "string? Microsoft.Extensions.AI.HostedMcpServerTool.ServerDescription { get; set; }", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "string Microsoft.Extensions.AI.HostedMcpServerTool.ServerName { get; }", - "Stage": "Experimental" + "Stage": "Stable" } ] }, { "Type": "sealed class Microsoft.Extensions.AI.HostedMcpServerToolAlwaysRequireApprovalMode : Microsoft.Extensions.AI.HostedMcpServerToolApprovalMode", - "Stage": "Experimental", + "Stage": "Stable", "Methods": [ { "Member": "Microsoft.Extensions.AI.HostedMcpServerToolAlwaysRequireApprovalMode.HostedMcpServerToolAlwaysRequireApprovalMode();", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "override bool Microsoft.Extensions.AI.HostedMcpServerToolAlwaysRequireApprovalMode.Equals(object? obj);", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "override int Microsoft.Extensions.AI.HostedMcpServerToolAlwaysRequireApprovalMode.GetHashCode();", - "Stage": "Experimental" + "Stage": "Stable" } ] }, { "Type": "class Microsoft.Extensions.AI.HostedMcpServerToolApprovalMode", - "Stage": "Experimental", + "Stage": "Stable", "Methods": [ { "Member": "static Microsoft.Extensions.AI.HostedMcpServerToolRequireSpecificApprovalMode Microsoft.Extensions.AI.HostedMcpServerToolApprovalMode.RequireSpecific(System.Collections.Generic.IList? alwaysRequireApprovalToolNames, System.Collections.Generic.IList? neverRequireApprovalToolNames);", - "Stage": "Experimental" + "Stage": "Stable" } ], "Properties": [ { "Member": "static Microsoft.Extensions.AI.HostedMcpServerToolAlwaysRequireApprovalMode Microsoft.Extensions.AI.HostedMcpServerToolApprovalMode.AlwaysRequire { get; }", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "static Microsoft.Extensions.AI.HostedMcpServerToolNeverRequireApprovalMode Microsoft.Extensions.AI.HostedMcpServerToolApprovalMode.NeverRequire { get; }", - "Stage": "Experimental" + "Stage": "Stable" } ] }, { "Type": "sealed class Microsoft.Extensions.AI.HostedMcpServerToolNeverRequireApprovalMode : Microsoft.Extensions.AI.HostedMcpServerToolApprovalMode", - "Stage": "Experimental", + "Stage": "Stable", "Methods": [ { "Member": "Microsoft.Extensions.AI.HostedMcpServerToolNeverRequireApprovalMode.HostedMcpServerToolNeverRequireApprovalMode();", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "override bool Microsoft.Extensions.AI.HostedMcpServerToolNeverRequireApprovalMode.Equals(object? obj);", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "override int Microsoft.Extensions.AI.HostedMcpServerToolNeverRequireApprovalMode.GetHashCode();", - "Stage": "Experimental" + "Stage": "Stable" } ] }, { "Type": "sealed class Microsoft.Extensions.AI.HostedMcpServerToolRequireSpecificApprovalMode : Microsoft.Extensions.AI.HostedMcpServerToolApprovalMode", - "Stage": "Experimental", + "Stage": "Stable", "Methods": [ { "Member": "Microsoft.Extensions.AI.HostedMcpServerToolRequireSpecificApprovalMode.HostedMcpServerToolRequireSpecificApprovalMode(System.Collections.Generic.IList? alwaysRequireApprovalToolNames, System.Collections.Generic.IList? neverRequireApprovalToolNames);", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "override bool Microsoft.Extensions.AI.HostedMcpServerToolRequireSpecificApprovalMode.Equals(object? obj);", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "override int Microsoft.Extensions.AI.HostedMcpServerToolRequireSpecificApprovalMode.GetHashCode();", - "Stage": "Experimental" + "Stage": "Stable" } ], "Properties": [ { "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedMcpServerToolRequireSpecificApprovalMode.AlwaysRequireApprovalToolNames { get; set; }", - "Stage": "Experimental" + "Stage": "Stable" }, { "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.HostedMcpServerToolRequireSpecificApprovalMode.NeverRequireApprovalToolNames { get; set; }", - "Stage": "Experimental" + "Stage": "Stable" } ] }, @@ -2429,11 +2425,11 @@ }, { "Type": "interface Microsoft.Extensions.AI.IChatReducer", - "Stage": "Stable", + "Stage": "Experimental", "Methods": [ { "Member": "System.Threading.Tasks.Task> Microsoft.Extensions.AI.IChatReducer.ReduceAsync(System.Collections.Generic.IEnumerable messages, System.Threading.CancellationToken cancellationToken);", - "Stage": "Stable" + "Stage": "Experimental" } ] }, @@ -2701,114 +2697,88 @@ ] }, { - "Type": "interface Microsoft.Extensions.AI.ISpeechToTextClient : System.IDisposable", - "Stage": "Experimental", + "Type": "class Microsoft.Extensions.AI.InputRequestContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", "Methods": [ { - "Member": "object? Microsoft.Extensions.AI.ISpeechToTextClient.GetService(System.Type serviceType, object? serviceKey = null);", - "Stage": "Experimental" - }, - { - "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.ISpeechToTextClient.GetStreamingTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", - "Stage": "Experimental" - }, + "Member": "Microsoft.Extensions.AI.InputRequestContent.InputRequestContent(string requestId);", + "Stage": "Stable" + } + ], + "Properties": [ { - "Member": "System.Threading.Tasks.Task Microsoft.Extensions.AI.ISpeechToTextClient.GetTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", - "Stage": "Experimental" + "Member": "string Microsoft.Extensions.AI.InputRequestContent.RequestId { get; }", + "Stage": "Stable" } ] }, { - "Type": "interface Microsoft.Extensions.AI.IToolReductionStrategy", - "Stage": "Experimental", + "Type": "class Microsoft.Extensions.AI.InputResponseContent : Microsoft.Extensions.AI.AIContent", + "Stage": "Stable", "Methods": [ { - "Member": "System.Threading.Tasks.Task> Microsoft.Extensions.AI.IToolReductionStrategy.SelectToolsForRequestAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", - "Stage": "Experimental" + "Member": "Microsoft.Extensions.AI.InputResponseContent.InputResponseContent(string requestId);", + "Stage": "Stable" + } + ], + "Properties": [ + { + "Member": "string Microsoft.Extensions.AI.InputResponseContent.RequestId { get; }", + "Stage": "Stable" } ] }, { - "Type": "sealed class Microsoft.Extensions.AI.McpServerToolApprovalRequestContent : Microsoft.Extensions.AI.UserInputRequestContent", + "Type": "interface Microsoft.Extensions.AI.ISpeechToTextClient : System.IDisposable", "Stage": "Experimental", "Methods": [ { - "Member": "Microsoft.Extensions.AI.McpServerToolApprovalRequestContent.McpServerToolApprovalRequestContent(string id, Microsoft.Extensions.AI.McpServerToolCallContent toolCall);", + "Member": "object? Microsoft.Extensions.AI.ISpeechToTextClient.GetService(System.Type serviceType, object? serviceKey = null);", "Stage": "Experimental" }, { - "Member": "Microsoft.Extensions.AI.McpServerToolApprovalResponseContent Microsoft.Extensions.AI.McpServerToolApprovalRequestContent.CreateResponse(bool approved);", + "Member": "System.Collections.Generic.IAsyncEnumerable Microsoft.Extensions.AI.ISpeechToTextClient.GetStreamingTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", "Stage": "Experimental" - } - ], - "Properties": [ + }, { - "Member": "Microsoft.Extensions.AI.McpServerToolCallContent Microsoft.Extensions.AI.McpServerToolApprovalRequestContent.ToolCall { get; }", + "Member": "System.Threading.Tasks.Task Microsoft.Extensions.AI.ISpeechToTextClient.GetTextAsync(System.IO.Stream audioSpeechStream, Microsoft.Extensions.AI.SpeechToTextOptions? options = null, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", "Stage": "Experimental" } ] }, { - "Type": "sealed class Microsoft.Extensions.AI.McpServerToolApprovalResponseContent : Microsoft.Extensions.AI.UserInputResponseContent", + "Type": "interface Microsoft.Extensions.AI.IToolReductionStrategy", "Stage": "Experimental", "Methods": [ { - "Member": "Microsoft.Extensions.AI.McpServerToolApprovalResponseContent.McpServerToolApprovalResponseContent(string id, bool approved);", - "Stage": "Experimental" - } - ], - "Properties": [ - { - "Member": "bool Microsoft.Extensions.AI.McpServerToolApprovalResponseContent.Approved { get; }", + "Member": "System.Threading.Tasks.Task> Microsoft.Extensions.AI.IToolReductionStrategy.SelectToolsForRequestAsync(System.Collections.Generic.IEnumerable messages, Microsoft.Extensions.AI.ChatOptions? options, System.Threading.CancellationToken cancellationToken = default(System.Threading.CancellationToken));", "Stage": "Experimental" } ] }, { - "Type": "sealed class Microsoft.Extensions.AI.McpServerToolCallContent : Microsoft.Extensions.AI.AIContent", - "Stage": "Experimental", + "Type": "sealed class Microsoft.Extensions.AI.McpServerToolCallContent : Microsoft.Extensions.AI.FunctionCallContent", + "Stage": "Stable", "Methods": [ { - "Member": "Microsoft.Extensions.AI.McpServerToolCallContent.McpServerToolCallContent(string callId, string toolName, string? serverName);", - "Stage": "Experimental" + "Member": "Microsoft.Extensions.AI.McpServerToolCallContent.McpServerToolCallContent(string callId, string name, string? serverName);", + "Stage": "Stable" } ], "Properties": [ - { - "Member": "System.Collections.Generic.IReadOnlyDictionary? Microsoft.Extensions.AI.McpServerToolCallContent.Arguments { get; set; }", - "Stage": "Experimental" - }, - { - "Member": "string Microsoft.Extensions.AI.McpServerToolCallContent.CallId { get; }", - "Stage": "Experimental" - }, { "Member": "string? Microsoft.Extensions.AI.McpServerToolCallContent.ServerName { get; }", - "Stage": "Experimental" - }, - { - "Member": "string Microsoft.Extensions.AI.McpServerToolCallContent.ToolName { get; }", - "Stage": "Experimental" + "Stage": "Stable" } ] }, { - "Type": "sealed class Microsoft.Extensions.AI.McpServerToolResultContent : Microsoft.Extensions.AI.AIContent", - "Stage": "Experimental", + "Type": "sealed class Microsoft.Extensions.AI.McpServerToolResultContent : Microsoft.Extensions.AI.FunctionResultContent", + "Stage": "Stable", "Methods": [ { "Member": "Microsoft.Extensions.AI.McpServerToolResultContent.McpServerToolResultContent(string callId);", - "Stage": "Experimental" - } - ], - "Properties": [ - { - "Member": "string Microsoft.Extensions.AI.McpServerToolResultContent.CallId { get; }", - "Stage": "Experimental" - }, - { - "Member": "System.Collections.Generic.IList? Microsoft.Extensions.AI.McpServerToolResultContent.Output { get; set; }", - "Stage": "Experimental" + "Stage": "Stable" } ] }, @@ -3435,38 +3405,6 @@ "Stage": "Stable" } ] - }, - { - "Type": "class Microsoft.Extensions.AI.UserInputRequestContent : Microsoft.Extensions.AI.AIContent", - "Stage": "Experimental", - "Methods": [ - { - "Member": "Microsoft.Extensions.AI.UserInputRequestContent.UserInputRequestContent(string id);", - "Stage": "Experimental" - } - ], - "Properties": [ - { - "Member": "string Microsoft.Extensions.AI.UserInputRequestContent.Id { get; }", - "Stage": "Experimental" - } - ] - }, - { - "Type": "class Microsoft.Extensions.AI.UserInputResponseContent : Microsoft.Extensions.AI.AIContent", - "Stage": "Experimental", - "Methods": [ - { - "Member": "Microsoft.Extensions.AI.UserInputResponseContent.UserInputResponseContent(string id);", - "Stage": "Experimental" - } - ], - "Properties": [ - { - "Member": "string Microsoft.Extensions.AI.UserInputResponseContent.Id { get; }", - "Stage": "Experimental" - } - ] } ] } \ No newline at end of file diff --git a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs index ef95d68031c..682933df7f7 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Tools/HostedMcpServerTool.cs @@ -3,8 +3,6 @@ using System; using System.Collections.Generic; -using System.Diagnostics.CodeAnalysis; -using Microsoft.Shared.DiagnosticIds; using Microsoft.Shared.Diagnostics; namespace Microsoft.Extensions.AI; @@ -12,18 +10,11 @@ namespace Microsoft.Extensions.AI; /// /// Represents a hosted MCP server tool that can be specified to an AI service. /// -[Experimental(DiagnosticIds.Experiments.AIMcpServers, UrlFormat = DiagnosticIds.UrlFormat)] public class HostedMcpServerTool : AITool { - /// The name of the Authorization header. - private const string AuthorizationHeaderName = "Authorization"; - /// Any additional properties associated with the tool. private IReadOnlyDictionary? _additionalProperties; - /// Lazily-initialized collection of headers to include when calling the remote MCP server. - private Dictionary? _headers; - /// /// Initializes a new instance of the class. /// @@ -55,12 +46,12 @@ public HostedMcpServerTool(string serverName, string serverAddress, IReadOnlyDic /// Initializes a new instance of the class. /// /// The name of the remote MCP server. - /// The URL of the remote MCP server. - /// or is . + /// The URL of the remote MCP server. + /// or is . /// is empty or composed entirely of whitespace. - /// is not an absolute URL. - public HostedMcpServerTool(string serverName, Uri serverUrl) - : this(serverName, ValidateUrl(serverUrl)) + /// is not an absolute URL. + public HostedMcpServerTool(string serverName, Uri serverAddress) + : this(serverName, ValidateUrl(serverAddress)) { } @@ -68,27 +59,27 @@ public HostedMcpServerTool(string serverName, Uri serverUrl) /// Initializes a new instance of the class. /// /// The name of the remote MCP server. - /// The URL of the remote MCP server. + /// The URL of the remote MCP server. /// Any additional properties associated with the tool. - /// or is . + /// or is . /// is empty or composed entirely of whitespace. - /// is not an absolute URL. - public HostedMcpServerTool(string serverName, Uri serverUrl, IReadOnlyDictionary? additionalProperties) - : this(serverName, ValidateUrl(serverUrl)) + /// is not an absolute URL. + public HostedMcpServerTool(string serverName, Uri serverAddress, IReadOnlyDictionary? additionalProperties) + : this(serverName, ValidateUrl(serverAddress)) { _additionalProperties = additionalProperties; } - private static string ValidateUrl(Uri serverUrl) + private static string ValidateUrl(Uri serverAddress) { - _ = Throw.IfNull(serverUrl); + _ = Throw.IfNull(serverAddress); - if (!serverUrl.IsAbsoluteUri) + if (!serverAddress.IsAbsoluteUri) { - Throw.ArgumentException(nameof(serverUrl), "The provided URL is not absolute."); + Throw.ArgumentException(nameof(serverAddress), "The provided URL is not absolute."); } - return serverUrl.AbsoluteUri; + return serverAddress.AbsoluteUri; } /// @@ -107,39 +98,6 @@ private static string ValidateUrl(Uri serverUrl) /// public string ServerAddress { get; } - /// - /// Gets or sets the OAuth authorization token that the AI service should use when calling the remote MCP server. - /// - /// - /// When set, this value is automatically added to the dictionary with the key "Authorization" - /// and the value "Bearer {token}". Setting this property will overwrite any existing "Authorization" header in . - /// Setting this property to will remove the "Authorization" header from . - /// - public string? AuthorizationToken - { - get - { - if (_headers?.TryGetValue(AuthorizationHeaderName, out string? value) is true && - value?.StartsWith("Bearer ", StringComparison.Ordinal) is true) - { - return value.Substring("Bearer ".Length); - } - - return null; - } - set - { - if (value is not null) - { - Headers[AuthorizationHeaderName] = $"Bearer {value}"; - } - else if (_headers is not null) - { - _ = _headers.Remove(AuthorizationHeaderName); - } - } - } - /// /// Gets or sets the description of the remote MCP server, used to provide more context to the AI service. /// @@ -171,12 +129,15 @@ public string? AuthorizationToken public HostedMcpServerToolApprovalMode? ApprovalMode { get; set; } /// - /// Gets a mutable dictionary of HTTP headers to include when calling the remote MCP server. + /// Gets or sets a mutable dictionary of HTTP headers to include when calling the remote MCP server. /// /// /// /// The underlying provider is not guaranteed to support or honor the headers. /// + /// + /// This property is useful for specifying the authentication header or other headers required by the MCP server. + /// /// - public IDictionary Headers => _headers ??= new Dictionary(); + public IDictionary? Headers { get; set; } } 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 0f2e4340358..8c9ffeaa54a 100644 --- a/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs +++ b/src/Libraries/Microsoft.Extensions.AI.Abstractions/Utilities/AIJsonUtilities.Defaults.cs @@ -51,12 +51,6 @@ private static JsonSerializerOptions CreateDefaultOptions() // Temporary workaround: these types are [Experimental] and can't be added as [JsonDerivedType] on AIContent yet, // or else consuming assemblies that used source generation with AIContent would implicitly reference them. // Once they're no longer [Experimental] and added as [JsonDerivedType] on AIContent, these lines should be removed. - AddAIContentType(options, typeof(FunctionApprovalRequestContent), typeDiscriminatorId: "functionApprovalRequest", checkBuiltIn: false); - 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); AddAIContentType(options, typeof(ImageGenerationToolCallContent), typeDiscriminatorId: "imageGenerationToolCall", checkBuiltIn: false); @@ -123,16 +117,15 @@ private static JsonSerializerOptions CreateDefaultOptions() [JsonSerializable(typeof(AIContent))] [JsonSerializable(typeof(IEnumerable))] + // InputRequestContent and InputResponseContent are polymorphic base types that may be + // serialized as root types (not just as AIContent). They have protected constructors so + // can't be instantiated directly, but we still need metadata when serializing derived + // types (e.g., FunctionApprovalRequestContent) as InputRequestContent. + [JsonSerializable(typeof(InputRequestContent))] + [JsonSerializable(typeof(InputResponseContent))] + // Temporary workaround: These should be implicitly added in once they're no longer [Experimental] // and are included via [JsonDerivedType] on AIContent. - [JsonSerializable(typeof(UserInputRequestContent))] - [JsonSerializable(typeof(UserInputResponseContent))] - [JsonSerializable(typeof(FunctionApprovalRequestContent))] - [JsonSerializable(typeof(FunctionApprovalResponseContent))] - [JsonSerializable(typeof(McpServerToolCallContent))] - [JsonSerializable(typeof(McpServerToolResultContent))] - [JsonSerializable(typeof(McpServerToolApprovalRequestContent))] - [JsonSerializable(typeof(McpServerToolApprovalResponseContent))] [JsonSerializable(typeof(CodeInterpreterToolCallContent))] [JsonSerializable(typeof(CodeInterpreterToolResultContent))] [JsonSerializable(typeof(ImageGenerationToolCallContent))] diff --git a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs index 8f42edb24d6..9a040864613 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIJsonContext.cs @@ -17,7 +17,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 af13a52328d..eedcf3b79df 100644 --- a/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI.OpenAI/OpenAIResponsesChatClient.cs @@ -167,6 +167,7 @@ internal static ChatResponse FromOpenAIResponse(ResponseResult responseResult, C internal static IEnumerable ToChatMessages(IEnumerable items, CreateResponseOptions? options = null) { ChatMessage? message = null; + Dictionary? mcpApprovalRequests = null; foreach (ResponseItem outputItem in items) { @@ -210,9 +211,13 @@ internal static IEnumerable ToChatMessages(IEnumerable ToChatMessages(IEnumerable FromOpenAIStreamingRe ChatRole? lastRole = null; bool anyFunctions = false; ResponseStatus? latestResponseStatus = null; + Dictionary? mcpApprovalRequests = null; UpdateConversationId(resumeResponseId); @@ -447,9 +467,13 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) => break; case McpToolCallApprovalRequestItem mtcari: - yield return CreateUpdate(new McpServerToolApprovalRequestContent(mtcari.Id, new(mtcari.Id, mtcari.ToolName, mtcari.ServerLabel) + // Store for correlation with responses. + (mcpApprovalRequests ??= new())[mtcari.Id] = mtcari; + + // We are reusing the mtcari.Id as the McpServerToolCallContent.CallId since we don't have one yet. + 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, }) { @@ -457,6 +481,24 @@ ChatResponseUpdate CreateUpdate(AIContent? content = null) => }); break; + case McpToolCallApprovalResponseItem mtcari + when mcpApprovalRequests?.TryGetValue(mtcari.ApprovalRequestId, out McpToolCallApprovalRequestItem? request) is true: + _ = mcpApprovalRequests.Remove(mtcari.ApprovalRequestId); + + // Correlate with the original request to get tool details. + // McpToolCallApprovalResponseItem without a correlated request falls through to default. + yield return CreateUpdate(new FunctionApprovalResponseContent( + mtcari.ApprovalRequestId, + mtcari.Approved, + new McpServerToolCallContent(mtcari.ApprovalRequestId, request.ToolName, request.ServerLabel) + { + Arguments = JsonSerializer.Deserialize(request.ToolArguments, OpenAIJsonContext.Default.IDictionaryStringObject), + }) + { + RawRepresentation = mtcari, + }); + break; + case CodeInterpreterCallResponseItem cicri: // The CodeInterpreterToolCallContent has already been yielded as part of delta updates. // Only yield the CodeInterpreterToolResultContent here for the outputs. @@ -635,16 +677,22 @@ void IDisposable.Dispose() if (isUrl) { - // For http: favor headers over authorization token. - if (mcpTool.Headers.Count > 0) + if (mcpTool.Headers is { Count: > 0 }) { responsesMcpTool.Headers = mcpTool.Headers; } } else { - // For connectors: Only set AuthorizationToken, do not include headers. - responsesMcpTool.AuthorizationToken = mcpTool.AuthorizationToken; + // For connectors: extract Bearer token from Headers and set as AuthorizationToken. + // Use case-insensitive comparison since auth scheme is case-insensitive per RFC 7235. + // Allow flexible whitespace in the header value. + if (mcpTool.Headers?.TryGetValue("Authorization", out string? authHeader) is true && + authHeader.AsSpan().Trim() is { Length: > 0 } trimmedAuthHeader && + trimmedAuthHeader.StartsWith("Bearer ", StringComparison.OrdinalIgnoreCase)) + { + responsesMcpTool.AuthorizationToken = trimmedAuthHeader.Slice("Bearer ".Length).TrimStart().ToString(); + } } if (mcpTool.AllowedTools is not null) @@ -886,7 +934,7 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable? idToContentMapping = null; + Dictionary? idToContentMapping = null; foreach (ChatMessage input in inputs) { @@ -919,7 +967,7 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable rawRep, - McpServerToolApprovalResponseContent mcpResp => ResponseItem.CreateMcpApprovalResponseItem(mcpResp.Id, mcpResp.Approved), + FunctionApprovalResponseContent { FunctionCall: McpServerToolCallContent } funcResp => ResponseItem.CreateMcpApprovalResponseItem(funcResp.RequestId, funcResp.Approved), _ => null }; @@ -1001,6 +1049,10 @@ internal static IEnumerable ToOpenAIResponseItems(IEnumerable contents) { @@ -1115,10 +1167,6 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera break; } break; - - case McpServerToolApprovalResponseContent mcpApprovalResponseContent: - yield return ResponseItem.CreateMcpApprovalResponseItem(mcpApprovalResponseContent.Id, mcpApprovalResponseContent.Approved); - break; } } @@ -1146,6 +1194,10 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera }; break; + case McpServerToolCallContent mstcc: + (idToContentMapping ??= [])[mstcc.CallId] = mstcc; + break; + case FunctionCallContent callContent: yield return ResponseItem.CreateFunctionCallItem( callContent.CallId, @@ -1155,34 +1207,33 @@ static FunctionCallOutputResponseItem SerializeAIContent(string callId, IEnumera AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); break; - case McpServerToolApprovalRequestContent mcpApprovalRequestContent: + case FunctionApprovalRequestContent funcReq when funcReq.FunctionCall is McpServerToolCallContent mcpToolCall: 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; + funcReq.RequestId, + mcpToolCall.ServerName, + mcpToolCall.Name, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes( + mcpToolCall.Arguments!, + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); 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) + associatedCall.Name, + BinaryData.FromBytes(JsonSerializer.SerializeToUtf8Bytes( + associatedCall.Arguments!, + AIJsonUtilities.DefaultOptions.GetTypeInfo(typeof(IDictionary))))); + if (mstrc.Result is ErrorContent errorContent) { mtci.Error = BinaryData.FromString(errorContent.Message); } - else + else if (mstrc.Result is TextContent textContent) { - mtci.ToolOutput = string.Concat(mstrc.Output?.OfType() ?? []); + mtci.ToolOutput = textContent.Text; } yield return mtci; @@ -1367,7 +1418,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 @@ -1377,9 +1428,9 @@ private static void AddMcpToolCallContent(McpToolCallItem mtci, IList contents.Add(new McpServerToolResultContent(mtci.Id) { RawRepresentation = mtci, - Output = [mtci.Error is not null ? + Result = mtci.Error is not null ? new ErrorContent(mtci.Error.ToString()) : - new TextContent(mtci.ToolOutput)], + new TextContent(mtci.ToolOutput), }); } diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs index 9fd95df0424..7fcf5b770de 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/FunctionInvokingChatClient.cs @@ -1464,13 +1464,13 @@ private static bool CurrentActivityIsInvokeAgent var content = message.Contents[j]; switch (content) { - case FunctionApprovalRequestContent farc: + case FunctionApprovalRequestContent farc when !farc.FunctionCall.InformationalOnly: // Validation: Capture each call id for each approval request to ensure later we have a matching response. _ = (approvalRequestCallIds ??= []).Add(farc.FunctionCall.CallId); - (allApprovalRequestsMessages ??= []).Add(farc.Id, message); + (allApprovalRequestsMessages ??= []).Add(farc.RequestId, message); break; - case FunctionApprovalResponseContent farc: + case FunctionApprovalResponseContent farc when !farc.FunctionCall.InformationalOnly: // Validation: Remove the call id for each approval response, to check it off the list of requests we need responses for. _ = approvalRequestCallIds?.Remove(farc.FunctionCall.CallId); (allApprovalResponses ??= []).Add(farc); @@ -1542,7 +1542,7 @@ private static bool CurrentActivityIsInvokeAgent ref List? targetList = ref approvalResponse.Approved ? ref approvedFunctionCalls : ref rejectedFunctionCalls; ChatMessage? requestMessage = null; - _ = allApprovalRequestsMessages?.TryGetValue(approvalResponse.Id, out requestMessage); + _ = allApprovalRequestsMessages?.TryGetValue(approvalResponse.RequestId, out requestMessage); (targetList ??= []).Add(new() { Response = approvalResponse, RequestMessage = requestMessage }); } @@ -1711,7 +1711,7 @@ private static bool TryReplaceFunctionCallsWithApprovalRequests(IList if (content[i] is FunctionCallContent fcc && !fcc.InformationalOnly) { updatedContent ??= [.. content]; // Clone the list if we haven't already - updatedContent[i] = new FunctionApprovalRequestContent(fcc.CallId, fcc); + updatedContent[i] = new FunctionApprovalRequestContent(ComposeApprovalRequestId(fcc.CallId), fcc); } } } @@ -1766,7 +1766,7 @@ private IList ReplaceFunctionCallsWithApprovalRequests( var functionCall = (FunctionCallContent)message.Contents[contentIndex]; LogFunctionRequiresApproval(functionCall.Name); - message.Contents[contentIndex] = new FunctionApprovalRequestContent(functionCall.CallId, functionCall); + message.Contents[contentIndex] = new FunctionApprovalRequestContent(ComposeApprovalRequestId(functionCall.CallId), functionCall); outputMessages[messageIndex] = message; lastMessageIndex = messageIndex; @@ -1783,6 +1783,9 @@ private static TimeSpan GetElapsedTime(long startingTimestamp) => new((long)((Stopwatch.GetTimestamp() - startingTimestamp) * ((double)TimeSpan.TicksPerSecond / Stopwatch.Frequency))); #endif + /// Composes an approval request ID from a function call ID. + private static string ComposeApprovalRequestId(string callId) => $"ficc_{callId}"; + /// /// Execute the provided and return the resulting /// wrapped in objects. diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs index bcb4fb2a241..ad3cf4feae0 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatCompletion/OpenTelemetryChatClient.cs @@ -258,6 +258,30 @@ internal static string SerializeChatMessages( m.Parts.Add(new OtelGenericPart { Type = "reasoning", Content = trc.Text }); break; + case McpServerToolCallContent mstcc: + m.Parts.Add(new OtelServerToolCallPart + { + Id = mstcc.CallId, + Name = mstcc.Name, + ServerToolCall = new OtelMcpToolCall + { + Arguments = mstcc.Arguments, + ServerName = mstcc.ServerName, + }, + }); + break; + + case McpServerToolResultContent mstrc: + m.Parts.Add(new OtelServerToolCallResponsePart + { + Id = mstrc.CallId, + ServerToolCallResponse = new OtelMcpToolCallResponse + { + Output = mstrc.Result, + }, + }); + break; + case FunctionCallContent fcc: m.Parts.Add(new OtelToolCallRequestPart { @@ -357,50 +381,26 @@ internal static string SerializeChatMessages( }); break; - case McpServerToolCallContent mstcc: - m.Parts.Add(new OtelServerToolCallPart - { - Id = mstcc.CallId, - Name = mstcc.ToolName, - ServerToolCall = new OtelMcpToolCall - { - Arguments = mstcc.Arguments, - ServerName = mstcc.ServerName, - }, - }); - break; - - case McpServerToolResultContent mstrc: - m.Parts.Add(new OtelServerToolCallResponsePart - { - Id = mstrc.CallId, - ServerToolCallResponse = new OtelMcpToolCallResponse - { - Output = mstrc.Output, - }, - }); - break; - - case McpServerToolApprovalRequestContent mstarc: + case FunctionApprovalRequestContent fareqc when fareqc.FunctionCall is McpServerToolCallContent mcpToolCall: m.Parts.Add(new OtelServerToolCallPart { - Id = mstarc.Id, - Name = mstarc.ToolCall.ToolName, + Id = fareqc.RequestId, + Name = fareqc.FunctionCall.Name, ServerToolCall = new OtelMcpApprovalRequest { - Arguments = mstarc.ToolCall.Arguments, - ServerName = mstarc.ToolCall.ServerName, + Arguments = mcpToolCall.Arguments, + ServerName = mcpToolCall.ServerName, }, }); break; - case McpServerToolApprovalResponseContent mstaresp: + case FunctionApprovalResponseContent farespc when farespc.FunctionCall is McpServerToolCallContent: m.Parts.Add(new OtelServerToolCallResponsePart { - Id = mstaresp.Id, + Id = farespc.RequestId, ServerToolCallResponse = new OtelMcpApprovalResponse { - Approved = mstaresp.Approved, + Approved = farespc.Approved, }, }); break; @@ -858,7 +858,7 @@ private sealed class OtelMcpToolCall { public string Type { get; set; } = "mcp"; public string? ServerName { get; set; } - public IReadOnlyDictionary? Arguments { get; set; } + public IDictionary? Arguments { get; set; } } private sealed class OtelMcpToolCallResponse @@ -871,7 +871,7 @@ private sealed class OtelMcpApprovalRequest { public string Type { get; set; } = "mcp_approval_request"; public string? ServerName { get; set; } - public IReadOnlyDictionary? Arguments { get; set; } + public IDictionary? Arguments { get; set; } } private sealed class OtelMcpApprovalResponse diff --git a/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs index a3b38ca14cd..7121f61cd8f 100644 --- a/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs +++ b/src/Libraries/Microsoft.Extensions.AI/ChatReduction/SummarizingChatReducer.cs @@ -208,8 +208,8 @@ public IEnumerable ToChatMessages() private static bool IsToolRelatedContent(AIContent content) => content is FunctionCallContent or FunctionResultContent - or UserInputRequestContent - or UserInputResponseContent; + or InputRequestContent + or InputResponseContent; /// Builds the list of messages to send to the chat client for summarization. private IEnumerable ToSummarizerChatMessages(int indexOfFirstMessageToKeep, string summarizationPrompt) diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs index 546b93a7f20..975ccb26230 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/AssertExtensions.cs @@ -46,6 +46,23 @@ public static void EqualMessageLists(List expectedMessages, 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")), new ImageGenerationToolCallContent { ImageId = "img123" }, new ImageGenerationToolResultContent { ImageId = "img456", Outputs = [new DataContent(new byte[] { 4, 5, 6 }, "image/png")] } ]); + // Verify each element roundtrips individually + foreach (AIContent content in message.Contents) + { + var serializedElement = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserializedElement = JsonSerializer.Deserialize(serializedElement, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserializedElement); + Assert.Equal(content.GetType(), deserializedElement.GetType()); + } + var serialized = JsonSerializer.Serialize(message, AIJsonUtilities.DefaultOptions); ChatMessage? deserialized = JsonSerializer.Deserialize(serialized, AIJsonUtilities.DefaultOptions); Assert.NotNull(deserialized); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs index cc5cc1dd8d9..8028167d8ad 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalRequestContentTests.cs @@ -15,9 +15,9 @@ public void Constructor_InvalidArguments_Throws() { FunctionCallContent functionCall = new("FCC1", "TestFunction"); - Assert.Throws("id", () => new FunctionApprovalRequestContent(null!, functionCall)); - Assert.Throws("id", () => new FunctionApprovalRequestContent("", functionCall)); - Assert.Throws("id", () => new FunctionApprovalRequestContent("\r\t\n ", functionCall)); + Assert.Throws("requestId", () => new FunctionApprovalRequestContent(null!, functionCall)); + Assert.Throws("requestId", () => new FunctionApprovalRequestContent("", functionCall)); + Assert.Throws("requestId", () => new FunctionApprovalRequestContent("\r\t\n ", functionCall)); Assert.Throws("functionCall", () => new FunctionApprovalRequestContent("id", null!)); } @@ -32,7 +32,7 @@ public void Constructor_Roundtrips(string id) FunctionApprovalRequestContent content = new(id, functionCall); - Assert.Same(id, content.Id); + Assert.Same(id, content.RequestId); Assert.Same(functionCall, content.FunctionCall); } @@ -49,7 +49,7 @@ public void CreateResponse_ReturnsExpectedResponse(bool approved) var response = content.CreateResponse(approved); Assert.NotNull(response); - Assert.Same(id, response.Id); + Assert.Same(id, response.RequestId); Assert.Equal(approved, response.Approved); Assert.Same(functionCall, response.FunctionCall); Assert.Null(response.Reason); @@ -70,7 +70,7 @@ public void CreateResponse_WithReason_ReturnsExpectedResponse(bool approved, str var response = content.CreateResponse(approved, reason); Assert.NotNull(response); - Assert.Same(id, response.Id); + Assert.Same(id, response.RequestId); Assert.Equal(approved, response.Approved); Assert.Same(functionCall, response.FunctionCall); Assert.Equal(reason, response.Reason); @@ -81,13 +81,22 @@ public void Serialization_Roundtrips() { var content = new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })); - var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); - var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); - - Assert.NotNull(deserializedContent); - Assert.Equal(content.Id, deserializedContent.Id); - Assert.NotNull(deserializedContent.FunctionCall); - Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId); - Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name); + AssertSerializationRoundtrips(content); + AssertSerializationRoundtrips(content); + AssertSerializationRoundtrips(content); + + static void AssertSerializationRoundtrips(FunctionApprovalRequestContent content) + where T : AIContent + { + T contentAsT = (T)(object)content; + string json = JsonSerializer.Serialize(contentAsT, AIJsonUtilities.DefaultOptions); + T? deserialized = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + var deserializedContent = Assert.IsType(deserialized); + Assert.Equal(content.RequestId, deserializedContent.RequestId); + Assert.NotNull(deserializedContent.FunctionCall); + Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId); + Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name); + } } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs index 405955463a1..036ef83b65a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionApprovalResponseContentTests.cs @@ -14,9 +14,9 @@ public void Constructor_InvalidArguments_Throws() { FunctionCallContent functionCall = new("FCC1", "TestFunction"); - Assert.Throws("id", () => new FunctionApprovalResponseContent(null!, true, functionCall)); - Assert.Throws("id", () => new FunctionApprovalResponseContent("", true, functionCall)); - Assert.Throws("id", () => new FunctionApprovalResponseContent("\r\t\n ", true, functionCall)); + Assert.Throws("requestId", () => new FunctionApprovalResponseContent(null!, true, functionCall)); + Assert.Throws("requestId", () => new FunctionApprovalResponseContent("", true, functionCall)); + Assert.Throws("requestId", () => new FunctionApprovalResponseContent("\r\t\n ", true, functionCall)); Assert.Throws("functionCall", () => new FunctionApprovalResponseContent("id", true, null!)); } @@ -30,15 +30,44 @@ public void Constructor_Roundtrips(string id, bool approved) FunctionCallContent functionCall = new("FCC1", "TestFunction"); FunctionApprovalResponseContent content = new(id, approved, functionCall); - Assert.Same(id, content.Id); + Assert.Same(id, content.RequestId); Assert.Equal(approved, content.Approved); Assert.Same(functionCall, content.FunctionCall); } + [Fact] + public void Serialization_Roundtrips() + { + var content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")) + { + Reason = "Approved for testing" + }; + + AssertSerializationRoundtrips(content); + AssertSerializationRoundtrips(content); + AssertSerializationRoundtrips(content); + + static void AssertSerializationRoundtrips(FunctionApprovalResponseContent content) + where T : AIContent + { + T contentAsT = (T)(object)content; + string json = JsonSerializer.Serialize(contentAsT, AIJsonUtilities.DefaultOptions); + T? deserialized = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + var deserializedContent = Assert.IsType(deserialized); + Assert.Equal(content.RequestId, deserializedContent.RequestId); + Assert.Equal(content.Approved, deserializedContent.Approved); + Assert.Equal(content.Reason, deserializedContent.Reason); + Assert.NotNull(deserializedContent.FunctionCall); + Assert.Equal(content.FunctionCall.CallId, deserializedContent.FunctionCall.CallId); + Assert.Equal(content.FunctionCall.Name, deserializedContent.FunctionCall.Name); + } + } + [Theory] [InlineData(null)] [InlineData("Custom rejection reason")] - public void Serialization_Roundtrips(string? reason) + public void Serialization_WithReason_Roundtrips(string? reason) { var content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")) { @@ -49,7 +78,7 @@ public void Serialization_Roundtrips(string? reason) var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); Assert.NotNull(deserializedContent); - Assert.Equal(content.Id, deserializedContent.Id); + Assert.Equal(content.RequestId, deserializedContent.RequestId); Assert.Equal(content.Approved, deserializedContent.Approved); Assert.Equal(content.Reason, deserializedContent.Reason); Assert.NotNull(deserializedContent.FunctionCall); diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs index 9fde659d65f..0ca4db7a123 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionCallContentTests.cs @@ -405,4 +405,60 @@ public static void CreateFromParsedArguments_NullInput_ThrowsArgumentNullExcepti Assert.Throws("name", () => FunctionCallContent.CreateFromParsedArguments("{}", "callId", null!, _ => null)); Assert.Throws("argumentParser", () => FunctionCallContent.CreateFromParsedArguments("{}", "callId", "functionName", null!)); } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new FunctionCallContent("call123", "myFunction") + { + Arguments = new Dictionary { { "arg1", "value1" } } + }; + + AssertSerializationRoundtrips(content); + AssertSerializationRoundtrips(content); + + static void AssertSerializationRoundtrips(FunctionCallContent content) + where T : AIContent + { + T contentAsT = (T)(object)content; + string json = JsonSerializer.Serialize(contentAsT, AIJsonUtilities.DefaultOptions); + T? deserialized = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + var deserializedContent = Assert.IsType(deserialized); + Assert.Equal(content.CallId, deserializedContent.CallId); + Assert.Equal(content.Name, deserializedContent.Name); + Assert.NotNull(deserializedContent.Arguments); + Assert.Equal("value1", deserializedContent.Arguments["arg1"]?.ToString()); + } + } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + FunctionCallContent[] contents = + [ + new FunctionCallContent("call1", "function1", new Dictionary { { "param1", 123 } }), + new McpServerToolCallContent("call2", "myTool", "myServer"), + ]; + + // Verify each element roundtrips individually + foreach (var content in contents) + { + var serialized = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(serialized, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + Assert.Equal(content.GetType(), deserialized.GetType()); + } + + // Verify the array roundtrips + var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.FunctionCallContentArray); + var deserializedContents = JsonSerializer.Deserialize(serializedContents, TestJsonSerializerContext.Default.FunctionCallContentArray); + Assert.NotNull(deserializedContents); + Assert.Equal(contents.Length, deserializedContents.Length); + for (int i = 0; i < deserializedContents.Length; i++) + { + Assert.NotNull(deserializedContents[i]); + Assert.Equal(contents[i].GetType(), deserializedContents[i].GetType()); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs index 1542a4b823a..7bf2cdf9d6f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/FunctionResultContentTests.cs @@ -91,4 +91,55 @@ public void ItShouldBeSerializableAndDeserializableWithException() Assert.Equal(sut.Result, deserializedSut.Result?.ToString()); Assert.Null(deserializedSut.Exception); } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new FunctionResultContent("call123", "result"); + + AssertSerializationRoundtrips(content); + AssertSerializationRoundtrips(content); + + static void AssertSerializationRoundtrips(FunctionResultContent content) + where T : AIContent + { + T contentAsT = (T)(object)content; + string json = JsonSerializer.Serialize(contentAsT, AIJsonUtilities.DefaultOptions); + T? deserialized = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + var deserializedContent = Assert.IsType(deserialized); + Assert.Equal(content.CallId, deserializedContent.CallId); + Assert.Equal("result", deserializedContent.Result?.ToString()); + } + } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + FunctionResultContent[] contents = + [ + new FunctionResultContent("call1", "result1"), + new McpServerToolResultContent("call2"), + ]; + + // Verify each element roundtrips individually + foreach (var content in contents) + { + var serialized = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(serialized, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + Assert.Equal(content.GetType(), deserialized.GetType()); + } + + // Verify the array roundtrips + var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.FunctionResultContentArray); + var deserializedContents = JsonSerializer.Deserialize(serializedContents, TestJsonSerializerContext.Default.FunctionResultContentArray); + Assert.NotNull(deserializedContents); + Assert.Equal(contents.Length, deserializedContents.Length); + for (int i = 0; i < deserializedContents.Length; i++) + { + Assert.NotNull(deserializedContents[i]); + Assert.Equal(contents[i].GetType(), deserializedContents[i].GetType()); + } + } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/InputRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/InputRequestContentTests.cs new file mode 100644 index 00000000000..76636a5e879 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/InputRequestContentTests.cs @@ -0,0 +1,69 @@ +// 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.Collections.Generic; +using System.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class InputRequestContentTests +{ + [Fact] + public void Constructor_InvalidArguments_Throws() + { + Assert.Throws("requestId", () => new TestInputRequestContent(null!)); + Assert.Throws("requestId", () => new TestInputRequestContent("")); + Assert.Throws("requestId", () => new TestInputRequestContent("\r\t\n ")); + } + + [Theory] + [InlineData("abc")] + [InlineData("123")] + [InlineData("!@#")] + public void Constructor_Roundtrips(string id) + { + TestInputRequestContent content = new(id); + + Assert.Equal(id, content.RequestId); + } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + InputRequestContent[] contents = + [ + new FunctionApprovalRequestContent("request123", new FunctionCallContent("call123", "functionName", new Dictionary { { "param1", 123 } })), + new FunctionApprovalRequestContent("request456", new McpServerToolCallContent("call456", "myTool", "myServer")), + ]; + + // Verify each element roundtrips individually + foreach (var content in contents) + { + var serialized = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(serialized, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + Assert.Equal(content.GetType(), deserialized.GetType()); + } + + // Verify the array roundtrips + var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.InputRequestContentArray); + var deserializedContents = JsonSerializer.Deserialize(serializedContents, TestJsonSerializerContext.Default.InputRequestContentArray); + Assert.NotNull(deserializedContents); + Assert.Equal(contents.Length, deserializedContents.Length); + for (int i = 0; i < deserializedContents.Length; i++) + { + Assert.NotNull(deserializedContents[i]); + Assert.Equal(contents[i].GetType(), deserializedContents[i].GetType()); + } + } + + private sealed class TestInputRequestContent : InputRequestContent + { + public TestInputRequestContent(string requestId) + : base(requestId) + { + } + } +} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/InputResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/InputResponseContentTests.cs new file mode 100644 index 00000000000..3a2b6411538 --- /dev/null +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/InputResponseContentTests.cs @@ -0,0 +1,68 @@ +// 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.Text.Json; +using Xunit; + +namespace Microsoft.Extensions.AI; + +public class InputResponseContentTests +{ + [Fact] + public void Constructor_InvalidArguments_Throws() + { + Assert.Throws("requestId", () => new TestInputResponseContent(null!)); + Assert.Throws("requestId", () => new TestInputResponseContent("")); + Assert.Throws("requestId", () => new TestInputResponseContent("\r\t\n ")); + } + + [Theory] + [InlineData("abc")] + [InlineData("123")] + [InlineData("!@#")] + public void Constructor_Roundtrips(string id) + { + TestInputResponseContent content = new(id); + + Assert.Equal(id, content.RequestId); + } + + [Fact] + public void Serialization_DerivedTypes_Roundtrips() + { + InputResponseContent[] contents = + [ + new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")), + new FunctionApprovalResponseContent("request456", true, new McpServerToolCallContent("call456", "myTool", "myServer")), + ]; + + // Verify each element roundtrips individually + foreach (var content in contents) + { + var serialized = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); + var deserialized = JsonSerializer.Deserialize(serialized, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + Assert.Equal(content.GetType(), deserialized.GetType()); + } + + // Verify the array roundtrips + var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.InputResponseContentArray); + var deserializedContents = JsonSerializer.Deserialize(serializedContents, TestJsonSerializerContext.Default.InputResponseContentArray); + Assert.NotNull(deserializedContents); + Assert.Equal(contents.Length, deserializedContents.Length); + for (int i = 0; i < deserializedContents.Length; i++) + { + Assert.NotNull(deserializedContents[i]); + Assert.Equal(contents[i].GetType(), deserializedContents[i].GetType()); + } + } + + private class TestInputResponseContent : InputResponseContent + { + public TestInputResponseContent(string requestId) + : base(requestId) + { + } + } +} 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..cc696618257 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolCallContentTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Text.Json; using Xunit; namespace Microsoft.Extensions.AI; @@ -18,9 +19,10 @@ public void Constructor_PropsDefault() Assert.Null(c.AdditionalProperties); Assert.Equal("callId1", c.CallId); - Assert.Equal("toolName", c.ToolName); + Assert.Equal("toolName", c.Name); Assert.Null(c.ServerName); Assert.Null(c.Arguments); + Assert.True(c.InformationalOnly); } [Fact] @@ -39,12 +41,16 @@ 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); + Assert.True(c.InformationalOnly); + c.InformationalOnly = false; + Assert.False(c.InformationalOnly); + Assert.Equal("callId1", c.CallId); - Assert.Equal("toolName", c.ToolName); + Assert.Equal("toolName", c.Name); Assert.Equal("serverName", c.ServerName); } @@ -52,9 +58,37 @@ public void Constructor_PropsRoundtrip() public void Constructor_Throws() { Assert.Throws("callId", () => new McpServerToolCallContent(string.Empty, "name", null)); - Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", string.Empty, null)); + Assert.Throws("name", () => new McpServerToolCallContent("callId1", string.Empty, null)); Assert.Throws("callId", () => new McpServerToolCallContent(null!, "name", null)); - Assert.Throws("toolName", () => new McpServerToolCallContent("callId1", null!, null)); + Assert.Throws("name", () => new McpServerToolCallContent("callId1", null!, null)); + } + + [Fact] + public void Serialization_Roundtrips() + { + var content = new McpServerToolCallContent("call123", "myTool", "myServer") + { + Arguments = new Dictionary { { "arg1", "value1" } } + }; + + AssertSerializationRoundtrips(content); + AssertSerializationRoundtrips(content); + AssertSerializationRoundtrips(content); + + static void AssertSerializationRoundtrips(McpServerToolCallContent content) + where T : AIContent + { + T contentAsT = (T)(object)content; + string json = JsonSerializer.Serialize(contentAsT, AIJsonUtilities.DefaultOptions); + T? deserialized = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + var deserializedContent = Assert.IsType(deserialized); + Assert.Equal(content.CallId, deserializedContent.CallId); + Assert.Equal(content.Name, deserializedContent.Name); + Assert.Equal(content.ServerName, deserializedContent.ServerName); + Assert.NotNull(deserializedContent.Arguments); + Assert.Equal("value1", deserializedContent.Arguments["arg1"]?.ToString()); + } } } 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..d71a1a6243f 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/McpServerToolResultContentTests.cs @@ -2,7 +2,6 @@ // The .NET Foundation licenses this file to you under the MIT license. using System; -using System.Collections.Generic; using System.Text.Json; using Xunit; @@ -17,7 +16,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 +36,10 @@ public void Constructor_PropsRoundtrip() Assert.Equal("callId", c.CallId); - Assert.Null(c.Output); - IList output = []; - c.Output = output; - Assert.Same(output, c.Output); + Assert.Null(c.Result); + object result = "test result"; + c.Result = result; + Assert.Same(result, c.Result); } [Fact] @@ -55,14 +54,23 @@ public void Serialization_Roundtrips() { var content = new McpServerToolResultContent("call123") { - Output = new List { new TextContent("result") } + Result = "result" }; - var json = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); - var deserializedContent = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + AssertSerializationRoundtrips(content); + AssertSerializationRoundtrips(content); + AssertSerializationRoundtrips(content); - Assert.NotNull(deserializedContent); - Assert.Equal(content.CallId, deserializedContent.CallId); - Assert.NotNull(deserializedContent.Output); + static void AssertSerializationRoundtrips(McpServerToolResultContent content) + where T : AIContent + { + T contentAsT = (T)(object)content; + string json = JsonSerializer.Serialize(contentAsT, AIJsonUtilities.DefaultOptions); + T? deserialized = JsonSerializer.Deserialize(json, AIJsonUtilities.DefaultOptions); + Assert.NotNull(deserialized); + var deserializedContent = Assert.IsType(deserialized); + Assert.Equal(content.CallId, deserializedContent.CallId); + Assert.Equal("result", ((JsonElement)deserializedContent.Result!).GetString()); + } } } diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs deleted file mode 100644 index fc4dac9cabb..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputRequestContentTests.cs +++ /dev/null @@ -1,67 +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.Collections.Generic; -using System.Linq; -using System.Text.Json; -using Xunit; - -namespace Microsoft.Extensions.AI.Contents; - -public class UserInputRequestContentTests -{ - [Fact] - public void Constructor_InvalidArguments_Throws() - { - Assert.Throws("id", () => new TestUserInputRequestContent(null!)); - Assert.Throws("id", () => new TestUserInputRequestContent("")); - Assert.Throws("id", () => new TestUserInputRequestContent("\r\t\n ")); - } - - [Theory] - [InlineData("abc")] - [InlineData("123")] - [InlineData("!@#")] - public void Constructor_Roundtrips(string id) - { - TestUserInputRequestContent content = new(id); - - Assert.Equal(id, content.Id); - } - - [Fact] - public void Serialization_DerivedTypes_Roundtrips() - { - UserInputRequestContent 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); - 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")), - ]; - - var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.UserInputRequestContentArray); - var deserializedContents = JsonSerializer.Deserialize(serializedContents, TestJsonSerializerContext.Default.UserInputRequestContentArray); - Assert.NotNull(deserializedContents); - - Assert.Equal(contents.Count(), deserializedContents.Length); - for (int i = 0; i < deserializedContents.Length; i++) - { - Assert.NotNull(contents.ElementAt(i)); - Assert.Equal(contents.ElementAt(i).GetType(), deserializedContents[i].GetType()); - } - } - - private sealed class TestUserInputRequestContent : UserInputRequestContent - { - public TestUserInputRequestContent(string id) - : base(id) - { - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs deleted file mode 100644 index 2442e57272d..00000000000 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Contents/UserInputResponseContentTests.cs +++ /dev/null @@ -1,65 +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.Text.Json; -using Xunit; - -namespace Microsoft.Extensions.AI.Contents; - -public class UserInputResponseContentTests -{ - [Fact] - public void Constructor_InvalidArguments_Throws() - { - Assert.Throws("id", () => new TestUserInputResponseContent(null!)); - Assert.Throws("id", () => new TestUserInputResponseContent("")); - Assert.Throws("id", () => new TestUserInputResponseContent("\r\t\n ")); - } - - [Theory] - [InlineData("abc")] - [InlineData("123")] - [InlineData("!@#")] - public void Constructor_Roundtrips(string id) - { - TestUserInputResponseContent content = new(id); - - Assert.Equal(id, content.Id); - } - - [Fact] - public void Serialization_DerivedTypes_Roundtrips() - { - UserInputResponseContent content = new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")); - var serializedContent = JsonSerializer.Serialize(content, AIJsonUtilities.DefaultOptions); - var deserializedContent = JsonSerializer.Deserialize(serializedContent, AIJsonUtilities.DefaultOptions); - Assert.NotNull(deserializedContent); - Assert.Equal(content.GetType(), deserializedContent.GetType()); - - UserInputResponseContent[] contents = - [ - new FunctionApprovalResponseContent("request123", true, new FunctionCallContent("call123", "functionName")), - new McpServerToolApprovalResponseContent("request123", true), - ]; - - var serializedContents = JsonSerializer.Serialize(contents, TestJsonSerializerContext.Default.UserInputResponseContentArray); - var deserializedContents = JsonSerializer.Deserialize(serializedContents, TestJsonSerializerContext.Default.UserInputResponseContentArray); - Assert.NotNull(deserializedContents); - - Assert.Equal(contents.Length, deserializedContents.Length); - for (int i = 0; i < deserializedContents.Length; i++) - { - Assert.NotNull(contents[i]); - Assert.Equal(contents[i].GetType(), deserializedContents[i].GetType()); - } - } - - private class TestUserInputResponseContent : UserInputResponseContent - { - public TestUserInputResponseContent(string id) - : base(id) - { - } - } -} diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs index 6c448d0efb1..0437c47eb1c 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/TestJsonSerializerContext.cs @@ -39,8 +39,10 @@ namespace Microsoft.Extensions.AI; [JsonSerializable(typeof(ChatResponseFormatTests.SomeType))] [JsonSerializable(typeof(ChatResponseFormatTests.TypeWithDisplayName))] [JsonSerializable(typeof(ResponseContinuationToken))] -[JsonSerializable(typeof(UserInputRequestContent[]))] -[JsonSerializable(typeof(UserInputResponseContent[]))] +[JsonSerializable(typeof(InputRequestContent[]))] +[JsonSerializable(typeof(InputResponseContent[]))] +[JsonSerializable(typeof(FunctionCallContent[]))] +[JsonSerializable(typeof(FunctionResultContent[]))] [JsonSerializable(typeof(ReasoningOptions))] [JsonSerializable(typeof(ReasoningEffort))] [JsonSerializable(typeof(ReasoningOutput))] diff --git a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs index 56c04ce1dfa..454fd74a731 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Abstractions.Tests/Tools/HostedMcpServerToolTests.cs @@ -20,12 +20,10 @@ public void Constructor_PropsDefault() Assert.Equal("https://localhost/", tool.ServerAddress); Assert.Empty(tool.Description); - Assert.Null(tool.AuthorizationToken); Assert.Null(tool.ServerDescription); Assert.Null(tool.AllowedTools); Assert.Null(tool.ApprovalMode); - Assert.NotNull(tool.Headers); - Assert.Empty(tool.Headers); + Assert.Null(tool.Headers); } [Fact] @@ -72,11 +70,6 @@ public void Constructor_Roundtrips() Assert.Equal("connector_id", tool.ServerAddress); Assert.Empty(tool.Description); - Assert.Null(tool.AuthorizationToken); - string authToken = "Bearer token123"; - tool.AuthorizationToken = authToken; - Assert.Equal(authToken, tool.AuthorizationToken); - Assert.Null(tool.ServerDescription); string serverDescription = "This is a test server"; tool.ServerDescription = serverDescription; @@ -98,92 +91,33 @@ public void Constructor_Roundtrips() tool.ApprovalMode = customApprovalMode; Assert.Same(customApprovalMode, tool.ApprovalMode); + Assert.Null(tool.Headers); + tool.Headers = new Dictionary { ["X-Custom-Header"] = "value1" }; Assert.NotNull(tool.Headers); Assert.Single(tool.Headers); - tool.Headers["X-Custom-Header"] = "value1"; - Assert.True(tool.Headers.Count == 2); Assert.Equal("value1", tool.Headers["X-Custom-Header"]); } [Fact] public void Constructor_WithHeaders_Uri_Roundtrips() { - var headers = new Dictionary + HostedMcpServerTool tool = new("serverName", new Uri("https://localhost/")) { - ["Authorization"] = "Bearer token456", - ["X-Custom"] = "value2" + Headers = new Dictionary + { + ["Authorization"] = "Bearer token456", + ["X-Custom"] = "value2" + } }; - HostedMcpServerTool tool = new("serverName", new Uri("https://localhost/")); - foreach (KeyValuePair keyValuePair in headers) - { - tool.Headers[keyValuePair.Key] = keyValuePair.Value; - } Assert.Equal("serverName", tool.ServerName); Assert.Equal("https://localhost/", tool.ServerAddress); + Assert.NotNull(tool.Headers); Assert.Equal(2, tool.Headers.Count); Assert.Equal("Bearer token456", tool.Headers["Authorization"]); - Assert.Equal("token456", tool.AuthorizationToken); Assert.Equal("value2", tool.Headers["X-Custom"]); } - [Fact] - public void Constructor_WithNullHeaders_CreatesEmptyDictionary() - { - HostedMcpServerTool tool1 = new("serverName", "connector_id"); - Assert.NotNull(tool1.Headers); - Assert.Empty(tool1.Headers); - - HostedMcpServerTool tool2 = new("serverName", new Uri("https://localhost/")); - Assert.NotNull(tool2.Headers); - Assert.Empty(tool2.Headers); - } - - [Fact] - public void AuthorizationToken_And_Headers_NoOrderingIssues() - { - // Verify that setting AuthorizationToken followed by adding to Headers works - var tool1 = new HostedMcpServerTool("server", "https://localhost/") - { - AuthorizationToken = "token123" - }; - tool1.Headers["X-Custom"] = "value1"; - - Assert.Equal(2, tool1.Headers.Count); - Assert.Equal("Bearer token123", tool1.Headers["Authorization"]); - Assert.Equal("token123", tool1.AuthorizationToken); - Assert.Equal("value1", tool1.Headers["X-Custom"]); - - // Verify that adding to Headers followed by setting AuthorizationToken works the same - var tool2 = new HostedMcpServerTool("server", "https://localhost/"); - tool2.Headers["X-Custom"] = "value1"; - tool2.AuthorizationToken = "token123"; - - Assert.Equal(2, tool2.Headers.Count); - Assert.Equal("Bearer token123", tool2.Headers["Authorization"]); - Assert.Equal("token123", tool2.AuthorizationToken); - Assert.Equal("value1", tool2.Headers["X-Custom"]); - - // Verify setting AuthorizationToken to null removes only Authorization header - tool2.AuthorizationToken = null; - Assert.Single(tool2.Headers); - Assert.False(tool2.Headers.ContainsKey("Authorization")); - Assert.Null(tool2.AuthorizationToken); - Assert.Equal("value1", tool2.Headers["X-Custom"]); - } - - [Fact] - public void Headers_WithNullAuthorization() - { - var tool = new HostedMcpServerTool("server", "https://localhost/"); - tool.Headers["Authorization"] = null!; - tool.Headers["X-Custom"] = "value1"; - Assert.Equal(2, tool.Headers.Count); - Assert.Null(tool.Headers["Authorization"]); - Assert.Null(tool.AuthorizationToken); - Assert.Equal("value1", tool.Headers["X-Custom"]); - } - [Fact] public void Constructor_Throws() { @@ -193,9 +127,9 @@ public void Constructor_Throws() Assert.Throws("serverName", () => new HostedMcpServerTool(null!, new Uri("https://localhost/"))); Assert.Throws("serverAddress", () => new HostedMcpServerTool("name", string.Empty)); - Assert.Throws("serverUrl", () => new HostedMcpServerTool("name", new Uri("/api/mcp", UriKind.Relative))); + Assert.Throws("serverAddress", () => new HostedMcpServerTool("name", new Uri("/api/mcp", UriKind.Relative))); Assert.Throws("serverAddress", () => new HostedMcpServerTool("name", (string)null!)); - Assert.Throws("serverUrl", () => new HostedMcpServerTool("name", (Uri)null!)); + Assert.Throws("serverAddress", () => new HostedMcpServerTool("name", (Uri)null!)); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs index d2e4dd39867..3daf4796bad 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIConversionTests.cs @@ -431,7 +431,7 @@ public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAuthToken_ProducesVa { var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") { - AuthorizationToken = "test-token" + Headers = new Dictionary { ["Authorization"] = "Bearer test-token" } }; var result = mcpTool.AsOpenAIResponseTool(); @@ -449,9 +449,12 @@ public void AsOpenAIResponseTool_WithHostedMcpServerToolWithAuthTokenAndCustomHe { var mcpTool = new HostedMcpServerTool("test-server", "http://localhost:8000") { - AuthorizationToken = "test-token" + Headers = new Dictionary + { + ["Authorization"] = "Bearer test-token", + ["X-Custom-Header"] = "custom-value" + } }; - mcpTool.Headers["X-Custom-Header"] = "custom-value"; var result = mcpTool.AsOpenAIResponseTool(); @@ -559,11 +562,11 @@ public void AsOpenAIResponseTool_WithHostedMcpServerToolWithRequireSpecificAppro } [Fact] - public void AsOpenAIResponseTool_WithHostedMcpServerToolConnector_OnlySetsAuthToken() + public void AsOpenAIResponseTool_WithHostedMcpServerToolConnector_ExtractsAuthToken() { var mcpTool = new HostedMcpServerTool("calendar", "connector_googlecalendar") { - AuthorizationToken = "connector-token" + Headers = new Dictionary { ["Authorization"] = "Bearer connector-token" } }; var result = mcpTool.AsOpenAIResponseTool(); @@ -572,7 +575,7 @@ public void AsOpenAIResponseTool_WithHostedMcpServerToolConnector_OnlySetsAuthTo var tool = Assert.IsType(result); Assert.Equal("connector-token", tool.AuthorizationToken); - // For connectors, headers should not be set even though AuthorizationToken adds to Headers internally + // For connectors, headers should not be set - only AuthorizationToken Assert.Empty(tool.Headers); } @@ -880,13 +883,126 @@ public void AsChatResponse_ConvertsOpenAIResponse() // as all constructors/factory methods currently are internal. Update this test when such functionality is available. } + /// + /// Derived type to allow creating StreamingResponseOutputItemDoneUpdate instances for testing. + /// The base class has internal constructors, but we can derive and set the Item property. + /// + private sealed class TestableStreamingResponseOutputItemDoneUpdate : StreamingResponseOutputItemDoneUpdate + { + } + [Fact] - public void AsChatResponseUpdatesAsync_ConvertsOpenAIStreamingResponseUpdates() + public async Task AsChatResponseUpdatesAsync_ConvertsOpenAIStreamingResponseUpdates() { Assert.Throws("responseUpdates", () => ((IAsyncEnumerable)null!).AsChatResponseUpdatesAsync()); - // The OpenAI library currently doesn't provide any way to create a StreamingResponseUpdate instance, - // as all constructors/factory methods currently are internal. Update this test when such functionality is available. + // Create streaming updates with various ResponseItem types + FunctionCallResponseItem functionCall = ResponseItem.CreateFunctionCallItem("call_abc", "MyFunction", BinaryData.FromString("""{"arg":"value"}""")); + McpToolCallItem mcpToolCall = ResponseItem.CreateMcpToolCallItem("deepwiki", "ask_question", BinaryData.FromString("""{"query":"hello"}""")); + mcpToolCall.Id = "mcp_call_123"; + mcpToolCall.ToolOutput = "The answer is 42"; + McpToolCallApprovalRequestItem mcpApprovalRequest = ResponseItem.CreateMcpApprovalRequestItem( + "mcpr_123", + "deepwiki", + "ask_question", + BinaryData.FromString("""{"repo":"dotnet/extensions"}""")); + McpToolCallApprovalResponseItem mcpApprovalResponse = ResponseItem.CreateMcpApprovalResponseItem("mcpr_123", approved: true); + + List updates = []; + await foreach (ChatResponseUpdate update in CreateStreamingUpdates().AsChatResponseUpdatesAsync()) + { + updates.Add(update); + } + + // Verify we got the expected updates + Assert.Equal(4, updates.Count); + + // First update should be FunctionCallContent + FunctionCallContent? fcc = updates[0].Contents.OfType().FirstOrDefault(); + Assert.NotNull(fcc); + Assert.Equal("call_abc", fcc.CallId); + Assert.Equal("MyFunction", fcc.Name); + + // Second update should be McpServerToolCallContent + McpServerToolResultContent + McpServerToolCallContent? mcpToolCallContent = updates[1].Contents.OfType().FirstOrDefault(); + Assert.NotNull(mcpToolCallContent); + Assert.Equal("mcp_call_123", mcpToolCallContent.CallId); + Assert.Equal("ask_question", mcpToolCallContent.Name); + Assert.Equal("deepwiki", mcpToolCallContent.ServerName); + Assert.Null(mcpToolCallContent.RawRepresentation); // Intentionally null to avoid duplication during roundtrip + + McpServerToolResultContent? mcpToolResultContent = updates[1].Contents.OfType().FirstOrDefault(); + Assert.NotNull(mcpToolResultContent); + Assert.Equal("mcp_call_123", mcpToolResultContent.CallId); + Assert.NotNull(mcpToolResultContent.RawRepresentation); + Assert.Same(mcpToolCall, mcpToolResultContent.RawRepresentation); + + // Third update should be FunctionApprovalRequestContent with McpServerToolCallContent + FunctionApprovalRequestContent? approvalRequest = updates[2].Contents.OfType().FirstOrDefault(); + Assert.NotNull(approvalRequest); + Assert.Equal("mcpr_123", approvalRequest.RequestId); + Assert.NotNull(approvalRequest.RawRepresentation); + Assert.Same(mcpApprovalRequest, approvalRequest.RawRepresentation); + + McpServerToolCallContent nestedMcpCall = Assert.IsType(approvalRequest.FunctionCall); + Assert.Equal("ask_question", nestedMcpCall.Name); + Assert.Equal("deepwiki", nestedMcpCall.ServerName); + + // Fourth update should be FunctionApprovalResponseContent correlated with request + FunctionApprovalResponseContent? approvalResponse = updates[3].Contents.OfType().FirstOrDefault(); + Assert.NotNull(approvalResponse); + Assert.Equal("mcpr_123", approvalResponse.RequestId); + Assert.True(approvalResponse.Approved); + Assert.NotNull(approvalResponse.RawRepresentation); + Assert.Same(mcpApprovalResponse, approvalResponse.RawRepresentation); + + // The correlated FunctionCall should be McpServerToolCallContent with tool details from the request + McpServerToolCallContent correlatedMcpCall = Assert.IsType(approvalResponse.FunctionCall); + Assert.Equal("mcpr_123", correlatedMcpCall.CallId); + Assert.Equal("ask_question", correlatedMcpCall.Name); + Assert.Equal("deepwiki", correlatedMcpCall.ServerName); + Assert.NotNull(correlatedMcpCall.Arguments); + Assert.Equal("dotnet/extensions", correlatedMcpCall.Arguments["repo"]?.ToString()); + + async IAsyncEnumerable CreateStreamingUpdates() + { + await Task.Yield(); + + yield return new TestableStreamingResponseOutputItemDoneUpdate { Item = functionCall }; + yield return new TestableStreamingResponseOutputItemDoneUpdate { Item = mcpToolCall }; + yield return new TestableStreamingResponseOutputItemDoneUpdate { Item = mcpApprovalRequest }; + yield return new TestableStreamingResponseOutputItemDoneUpdate { Item = mcpApprovalResponse }; + } + } + + [Fact] + public async Task AsChatResponseUpdatesAsync_McpToolCallApprovalResponseItem_WithoutCorrelatedRequest_FallsBackToAIContent() + { + // Create an approval response without a matching request in the stream. + McpToolCallApprovalResponseItem mcpApprovalResponse = ResponseItem.CreateMcpApprovalResponseItem("unknown_request_id", approved: true); + + List updates = []; + await foreach (ChatResponseUpdate update in CreateStreamingUpdates().AsChatResponseUpdatesAsync()) + { + updates.Add(update); + } + + Assert.Single(updates); + + // Should NOT have a FunctionApprovalResponseContent since there was no correlated request + Assert.Empty(updates[0].Contents.OfType()); + + // Should have a generic AIContent with RawRepresentation set to the response item + AIContent? genericContent = updates[0].Contents.FirstOrDefault(c => c.RawRepresentation == mcpApprovalResponse); + Assert.NotNull(genericContent); + Assert.IsNotType(genericContent); + Assert.Same(mcpApprovalResponse, genericContent.RawRepresentation); + + async IAsyncEnumerable CreateStreamingUpdates() + { + await Task.Yield(); + yield return new TestableStreamingResponseOutputItemDoneUpdate { Item = mcpApprovalResponse }; + } } [Fact] @@ -976,6 +1092,171 @@ public void AsChatMessages_FromResponseItems_WithFunctionCall_HandlesCorrectly() Assert.Equal("value", functionCall.Arguments!["param"]?.ToString()); } + [Fact] + public void AsChatMessages_FromResponseItems_AllContentTypes_SetsRawRepresentation() + { + // Create ResponseItems of various types that ToChatMessages handles. + // Each type should roundtrip with RawRepresentation set. + MessageResponseItem assistantItem = ResponseItem.CreateAssistantMessageItem("Hello from the assistant!"); + ReasoningResponseItem reasoningItem = ResponseItem.CreateReasoningItem("This is reasoning text"); + FunctionCallResponseItem functionCallItem = ResponseItem.CreateFunctionCallItem("call_abc", "MyFunction", BinaryData.FromString("""{"arg": "value"}""")); + FunctionCallOutputResponseItem functionOutputItem = ResponseItem.CreateFunctionCallOutputItem("call_abc", "function result output"); + McpToolCallItem mcpToolCallItem = ResponseItem.CreateMcpToolCallItem("deepwiki", "ask_question", BinaryData.FromString("""{"query":"hello"}""")); + mcpToolCallItem.Id = "mcp_call_123"; + mcpToolCallItem.ToolOutput = "The answer is 42"; + McpToolCallApprovalRequestItem mcpApprovalRequestItem = ResponseItem.CreateMcpApprovalRequestItem( + "mcpr_123", + "deepwiki", + "ask_question", + BinaryData.FromString("""{"repoName":"dotnet/extensions"}""")); + + // Use matching ID so response can correlate with the request + McpToolCallApprovalResponseItem mcpApprovalResponseItem = ResponseItem.CreateMcpApprovalResponseItem("mcpr_123", approved: true); + + ResponseItem[] items = [assistantItem, reasoningItem, functionCallItem, functionOutputItem, mcpToolCallItem, mcpApprovalRequestItem, mcpApprovalResponseItem]; + + // Convert to ChatMessages + ChatMessage[] messages = items.AsChatMessages().ToArray(); + + // All items should be grouped into a single assistant message + Assert.Single(messages); + ChatMessage message = messages[0]; + Assert.Equal(ChatRole.Assistant, message.Role); + + // The message itself should have RawRepresentation from MessageResponseItem + Assert.NotNull(message.RawRepresentation); + Assert.Same(assistantItem, message.RawRepresentation); + + // Verify each content type has RawRepresentation set + + // 1. MessageResponseItem -> TextContent with ResponseContentPart as RawRepresentation + TextContent? textContent = message.Contents.OfType().FirstOrDefault(); + Assert.NotNull(textContent); + Assert.Equal("Hello from the assistant!", textContent.Text); + Assert.NotNull(textContent.RawRepresentation); + Assert.IsAssignableFrom(textContent.RawRepresentation); + + // 2. ReasoningResponseItem -> TextReasoningContent + TextReasoningContent? reasoningContent = message.Contents.OfType().FirstOrDefault(); + Assert.NotNull(reasoningContent); + Assert.Equal("This is reasoning text", reasoningContent.Text); + Assert.NotNull(reasoningContent.RawRepresentation); + Assert.Same(reasoningItem, reasoningContent.RawRepresentation); + + // 3. FunctionCallResponseItem -> FunctionCallContent + FunctionCallContent? functionCallContent = message.Contents.OfType().FirstOrDefault(); + Assert.NotNull(functionCallContent); + Assert.Equal("call_abc", functionCallContent.CallId); + Assert.Equal("MyFunction", functionCallContent.Name); + Assert.NotNull(functionCallContent.RawRepresentation); + Assert.Same(functionCallItem, functionCallContent.RawRepresentation); + + // 4. FunctionCallOutputResponseItem -> FunctionResultContent + FunctionResultContent? functionResultContent = message.Contents.OfType().FirstOrDefault(); + Assert.NotNull(functionResultContent); + Assert.Equal("call_abc", functionResultContent.CallId); + Assert.Equal("function result output", functionResultContent.Result); + Assert.NotNull(functionResultContent.RawRepresentation); + Assert.Same(functionOutputItem, functionResultContent.RawRepresentation); + + // 5. McpToolCallItem -> McpServerToolCallContent + McpServerToolResultContent + // Note: AddMcpToolCallContent creates both contents; RawRepresentation is only on the result, not the call + McpServerToolCallContent? mcpToolCall = message.Contents.OfType().FirstOrDefault(c => c.CallId == "mcp_call_123"); + Assert.NotNull(mcpToolCall); + Assert.Equal("mcp_call_123", mcpToolCall.CallId); + Assert.Equal("ask_question", mcpToolCall.Name); + Assert.Equal("deepwiki", mcpToolCall.ServerName); + Assert.Null(mcpToolCall.RawRepresentation); // Intentionally null to avoid duplication during roundtrip + + McpServerToolResultContent? mcpToolResult = message.Contents.OfType().FirstOrDefault(c => c.CallId == "mcp_call_123"); + Assert.NotNull(mcpToolResult); + Assert.Equal("mcp_call_123", mcpToolResult.CallId); + Assert.NotNull(mcpToolResult.RawRepresentation); + Assert.Same(mcpToolCallItem, mcpToolResult.RawRepresentation); + + // 6. McpToolCallApprovalRequestItem -> FunctionApprovalRequestContent + FunctionApprovalRequestContent? approvalRequestContent = message.Contents.OfType().FirstOrDefault(); + Assert.NotNull(approvalRequestContent); + Assert.Equal("mcpr_123", approvalRequestContent.RequestId); + Assert.NotNull(approvalRequestContent.RawRepresentation); + Assert.Same(mcpApprovalRequestItem, approvalRequestContent.RawRepresentation); + + // The nested FunctionCall should be McpServerToolCallContent + McpServerToolCallContent nestedMcpCall = Assert.IsType(approvalRequestContent.FunctionCall); + Assert.Equal("ask_question", nestedMcpCall.Name); + Assert.Equal("deepwiki", nestedMcpCall.ServerName); + Assert.NotNull(nestedMcpCall.RawRepresentation); + Assert.Same(mcpApprovalRequestItem, nestedMcpCall.RawRepresentation); + + // 7. McpToolCallApprovalResponseItem -> FunctionApprovalResponseContent (correlated with request) + FunctionApprovalResponseContent? approvalResponseContent = message.Contents.OfType().FirstOrDefault(); + Assert.NotNull(approvalResponseContent); + Assert.Equal("mcpr_123", approvalResponseContent.RequestId); + Assert.True(approvalResponseContent.Approved); + Assert.NotNull(approvalResponseContent.RawRepresentation); + Assert.Same(mcpApprovalResponseItem, approvalResponseContent.RawRepresentation); + + // The correlated FunctionCall should be McpServerToolCallContent with tool details from the request + McpServerToolCallContent correlatedMcpCall = Assert.IsType(approvalResponseContent.FunctionCall); + Assert.Equal("mcpr_123", correlatedMcpCall.CallId); + Assert.Equal("ask_question", correlatedMcpCall.Name); + Assert.Equal("deepwiki", correlatedMcpCall.ServerName); + Assert.NotNull(correlatedMcpCall.Arguments); + Assert.Equal("dotnet/extensions", correlatedMcpCall.Arguments["repoName"]?.ToString()); + } + + [Fact] + public void AsChatMessages_McpToolCallApprovalResponseItem_WithoutCorrelatedRequest_FallsBackToAIContent() + { + // Create an approval response without a matching request in the batch. + // This simulates receiving a response item when we don't have the original request. + MessageResponseItem assistantItem = ResponseItem.CreateAssistantMessageItem("Hello"); + McpToolCallApprovalResponseItem mcpApprovalResponseItem = ResponseItem.CreateMcpApprovalResponseItem("unknown_request_id", approved: true); + + ResponseItem[] items = [assistantItem, mcpApprovalResponseItem]; + + // Convert to ChatMessages + ChatMessage[] messages = items.AsChatMessages().ToArray(); + + Assert.Single(messages); + ChatMessage message = messages[0]; + + // Should NOT have a FunctionApprovalResponseContent since there was no correlated request + Assert.Empty(message.Contents.OfType()); + + // Should have a generic AIContent with RawRepresentation set to the response item + AIContent? genericContent = message.Contents.FirstOrDefault(c => c.RawRepresentation == mcpApprovalResponseItem); + Assert.NotNull(genericContent); + Assert.IsNotType(genericContent); + Assert.Same(mcpApprovalResponseItem, genericContent.RawRepresentation); + } + + [Theory] + [InlineData(false)] + [InlineData(true)] + public void AsOpenAIResponseItems_McpServerToolContents_RoundtripsToolOutputAndError(bool isError) + { + var mcpToolCall = new McpServerToolCallContent("call_123", "get_weather", "weather_server") { Arguments = new Dictionary { ["city"] = "Seattle" } }; + var mcpToolResult = new McpServerToolResultContent("call_123") { Result = isError ? new ErrorContent("error") : new TextContent("sunny") }; + + var items = new ChatMessage[] { new(ChatRole.Assistant, [mcpToolCall, mcpToolResult]) }.AsOpenAIResponseItems().ToArray(); + + McpToolCallItem mtci = Assert.IsType(Assert.Single(items)); + Assert.Equal("get_weather", mtci.ToolName); + Assert.Equal("weather_server", mtci.ServerLabel); + + if (isError) + { + Assert.Contains("error", mtci.Error?.ToString()); + Assert.Null(mtci.ToolOutput); + } + else + { + Assert.Equal("sunny", mtci.ToolOutput); + Assert.Null(mtci.Error); + } + } + [Fact] public void AsOpenAIChatCompletion_WithNullArgument_ThrowsArgumentNullException() { diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs index 0bf3f7fcf0f..e1965468cde 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientIntegrationTests.cs @@ -149,7 +149,7 @@ 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); } @@ -203,8 +203,8 @@ await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() var approvalResponse = new ChatMessage(ChatRole.Tool, response.Messages .SelectMany(m => m.Contents) - .OfType() - .Select(c => new McpServerToolApprovalResponseContent(c.ToolCall.CallId, true)) + .OfType() + .Select(c => c.CreateResponse(true)) .ToArray()); if (approvalResponse.Contents.Count == 0) { @@ -377,7 +377,7 @@ public async Task RemoteMCP_Connector() { SkipIfNotEnabled(); - if (TestRunnerConfiguration.Instance["RemoteMCP:ConnectorAccessToken"] is not string accessToken) + if (TestRunnerConfiguration.Instance["RemoteMCP:ConnectorAccessToken"] is not string { Length: > 0 } accessToken) { throw new SkipTestException( "To run this test, set a value for RemoteMCP:ConnectorAccessToken. " + @@ -394,9 +394,9 @@ async Task RunAsync(bool streaming, bool approval) Tools = [new HostedMcpServerTool("calendar", "connector_googlecalendar") { ApprovalMode = approval ? - HostedMcpServerToolApprovalMode.AlwaysRequire : - HostedMcpServerToolApprovalMode.NeverRequire, - AuthorizationToken = accessToken + HostedMcpServerToolApprovalMode.AlwaysRequire : + HostedMcpServerToolApprovalMode.NeverRequire, + Headers = new Dictionary { ["Authorization"] = $"Bearer {accessToken}" }, } ], }; @@ -412,8 +412,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.Name); input.Add(new ChatMessage(ChatRole.Tool, [approvalRequest.CreateResponse(true)])); response = streaming ? @@ -423,10 +424,10 @@ await client.GetStreamingResponseAsync(input, chatOptions).ToChatResponseAsync() Assert.NotNull(response); var toolCall = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); - Assert.Equal("search_events", toolCall.ToolName); + Assert.Equal("search_events", toolCall.Name); var toolResult = Assert.Single(response.Messages.SelectMany(m => m.Contents).OfType()); - var content = Assert.IsType(Assert.Single(toolResult.Output!)); + var content = Assert.IsType(toolResult.Result); Assert.Equal(@"{""events"": [], ""next_page_token"": null}", content.Text); } } diff --git a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs index abd1414d85f..6193d58b291 100644 --- a/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.OpenAI.Tests/OpenAIResponseClientTests.cs @@ -1396,7 +1396,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)) @@ -1406,7 +1406,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; } @@ -1543,7 +1543,7 @@ public async Task McpToolCall_ApprovalRequired_NonStreaming(string role) var call = Assert.IsType(message.Contents[0]); Assert.Equal("mcp_06ee3b1962eeb8470068e6b21cbaa081a3b5aa2a6c989f4c6f", call.CallId); Assert.Equal("deepwiki", call.ServerName); - Assert.Equal("ask_question", call.ToolName); + Assert.Equal("ask_question", call.Name); Assert.NotNull(call.Arguments); Assert.Equal(2, call.Arguments.Count); Assert.Equal("dotnet/extensions", ((JsonElement)call.Arguments["repoName"]!).GetString()); @@ -1551,8 +1551,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).Text); Assert.NotNull(response.Usage); Assert.Equal(542, response.Usage.InputTokenCount); @@ -1798,28 +1797,26 @@ public async Task McpToolCall_ApprovalNotRequired_NonStreaming(bool rawTool) var firstCall = Assert.IsType(message.Contents[1]); Assert.Equal("mcp_68be4166acfc8191bc5e0a751eed358b0384f747588fc3f5", firstCall.CallId); Assert.Equal("deepwiki", firstCall.ServerName); - Assert.Equal("read_wiki_structure", firstCall.ToolName); + Assert.Equal("read_wiki_structure", firstCall.Name); Assert.NotNull(firstCall.Arguments); Assert.Single(firstCall.Arguments); Assert.Equal("dotnet/extensions", ((JsonElement)firstCall.Arguments["repoName"]!).GetString()); 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).Text); var secondCall = Assert.IsType(message.Contents[3]); Assert.Equal("mcp_68be416900f88191837ae0718339a4ce0384f747588fc3f5", secondCall.CallId); Assert.Equal("deepwiki", secondCall.ServerName); - Assert.Equal("ask_question", secondCall.ToolName); + Assert.Equal("ask_question", secondCall.Name); Assert.NotNull(secondCall.Arguments); Assert.Equal("dotnet/extensions", ((JsonElement)secondCall.Arguments["repoName"]!).GetString()); Assert.Equal("What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?", ((JsonElement)secondCall.Arguments["question"]!).GetString()); 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).Text); Assert.NotNull(response.Usage); Assert.Equal(1329, response.Usage.InputTokenCount); @@ -2211,28 +2208,26 @@ public async Task McpToolCall_ApprovalNotRequired_Streaming() var firstCall = Assert.IsType(message.Contents[1]); Assert.Equal("mcp_68be4503d45c819e89cb574361c8eba003a2537be0e84a54", firstCall.CallId); Assert.Equal("deepwiki", firstCall.ServerName); - Assert.Equal("read_wiki_structure", firstCall.ToolName); + Assert.Equal("read_wiki_structure", firstCall.Name); Assert.NotNull(firstCall.Arguments); Assert.Single(firstCall.Arguments); Assert.Equal("dotnet/extensions", ((JsonElement)firstCall.Arguments["repoName"]!).GetString()); 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).Text); var secondCall = Assert.IsType(message.Contents[3]); Assert.Equal("mcp_68be4505f134819e806c002f27cce0c303a2537be0e84a54", secondCall.CallId); Assert.Equal("deepwiki", secondCall.ServerName); - Assert.Equal("ask_question", secondCall.ToolName); + Assert.Equal("ask_question", secondCall.Name); Assert.NotNull(secondCall.Arguments); Assert.Equal("dotnet/extensions", ((JsonElement)secondCall.Arguments["repoName"]!).GetString()); Assert.Equal("What is the path to the README.md file for Microsoft.Extensions.AI.Abstractions?", ((JsonElement)secondCall.Arguments["question"]!).GetString()); 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).Text); Assert.NotNull(response.Usage); Assert.Equal(1420, response.Usage.InputTokenCount); @@ -2240,6 +2235,203 @@ public async Task McpToolCall_ApprovalNotRequired_Streaming() Assert.Equal(1569, 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.Name); + Assert.NotNull(toolCall.Arguments); + Assert.Empty(toolCall.Arguments); + + var toolResult = Assert.IsType(message.Contents[2]); + Assert.Equal("mcp_689023b0fa88819f99f48aff343d5ad50475557f6fefb5f0", toolResult.CallId); + var errorContent = Assert.IsType(toolResult.Result); + Assert.Contains("An error occurred invoking 'test_error'.", errorContent.Message); + + 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_WithAuthorizationTokenAndCustomHeaders_IncludesInRequest() { @@ -2309,11 +2501,13 @@ public async Task McpToolCall_WithAuthorizationTokenAndCustomHeaders_IncludesInR var mcpTool = new HostedMcpServerTool("deepwiki", new Uri("https://mcp.deepwiki.com/mcp")) { ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, - AuthorizationToken = "test-auth-token-12345" + Headers = new Dictionary + { + ["Authorization"] = "Bearer test-auth-token-12345", + ["X-Custom-Header"] = "custom-value" + } }; - mcpTool.Headers!["X-Custom-Header"] = "custom-value"; - var response = await client.GetResponseAsync("hello", new ChatOptions { Tools = [mcpTool] }); Assert.NotNull(response); @@ -2322,6 +2516,94 @@ public async Task McpToolCall_WithAuthorizationTokenAndCustomHeaders_IncludesInR Assert.Equal("Hi!", message.Text); } + [Theory] + [InlineData("bearer test-auth-token-12345")] + [InlineData("BEARER test-auth-token-12345")] + [InlineData("BeArEr test-auth-token-12345")] + [InlineData(" Bearer test-auth-token-12345")] + [InlineData("Bearer test-auth-token-12345 ")] + [InlineData(" Bearer test-auth-token-12345 ")] + [InlineData("Bearer test-auth-token-12345")] + public async Task McpToolCall_WithCaseInsensitiveBearerToken_ExtractsToken(string authHeaderValue) + { + // Use a connector ID (non-URL) to trigger the Bearer token extraction code path + string expectedToken = "test-auth-token-12345"; + string expectedInput = $$""" + { + "model": "gpt-4o-mini", + "tools": [ + { + "type": "mcp", + "server_label": "my-connector", + "connector_id": "connector-id-123", + "authorization": "{{expectedToken}}", + "require_approval": "never" + } + ], + "input": [ + { + "type": "message", + "role": "user", + "content": [ + { + "type": "input_text", + "text": "hello" + } + ] + } + ] + } + """; + + const string Output = """ + { + "id": "resp_bearer01", + "object": "response", + "created_at": 1757299043, + "status": "completed", + "model": "gpt-4o-mini-2024-07-18", + "output": [ + { + "id": "msg_bearer01", + "type": "message", + "status": "completed", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "Hi!" + } + ] + } + ], + "usage": { + "input_tokens": 10, + "output_tokens": 2, + "total_tokens": 12 + } + } + """; + + using VerbatimHttpHandler handler = new(expectedInput, Output); + using HttpClient httpClient = new(handler); + using IChatClient client = CreateResponseClient(httpClient, "gpt-4o-mini"); + + // Use string constructor with non-URL to trigger connector ID path + var mcpTool = new HostedMcpServerTool("my-connector", "connector-id-123") + { + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire, + Headers = new Dictionary + { + ["Authorization"] = authHeaderValue, + } + }; + + var response = await client.GetResponseAsync("hello", new ChatOptions { Tools = [mcpTool] }); + + Assert.NotNull(response); + Assert.Equal("resp_bearer01", response.ResponseId); + } + [Fact] public async Task GetResponseAsync_BackgroundResponses_FirstCall() { diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs index 0d2fd351c30..120646134c7 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientApprovalsTests.cs @@ -1,4 +1,4 @@ -// Licensed to the .NET Foundation under one or more agreements. +// Licensed to the .NET Foundation under one or more agreements. // The .NET Foundation licenses this file to you under the MIT license. using System; @@ -9,7 +9,7 @@ using System.Threading.Tasks; using Xunit; -namespace Microsoft.Extensions.AI.ChatCompletion; +namespace Microsoft.Extensions.AI; public class FunctionInvokingChatClientApprovalsTests { @@ -45,8 +45,8 @@ public async Task AllFunctionCallsReplacedWithApprovalsWhenAllRequireApprovalAsy [ new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]) ]; @@ -81,8 +81,8 @@ public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequireApprovalAsy [ new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]) ]; @@ -125,8 +125,8 @@ public async Task AllFunctionCallsReplacedWithApprovalsWhenAnyRequestOrAdditiona [ new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]) ]; @@ -152,13 +152,13 @@ public async Task ApprovedApprovalResponsesAreExecutedAsync() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]) { MessageId = "resp1" }, new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]), ]; @@ -204,13 +204,13 @@ public async Task ApprovedApprovalResponsesAreGroupedWhenMessageIdIsNullAsync() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]), // Note: No MessageId set - this is the bug trigger new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]), ]; @@ -256,19 +256,19 @@ public async Task ApprovedApprovalResponsesFromSeparateFCCMessagesAreExecutedAsy new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), ]) { MessageId = "resp1" }, new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]) { MessageId = "resp2" }, new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")), ]), new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]), ]; @@ -315,13 +315,13 @@ public async Task RejectedApprovalResponsesAreFailedAsync() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]) { MessageId = "resp1" }, new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("ficc_callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]), ]; @@ -374,13 +374,13 @@ public async Task MixedApprovedAndRejectedApprovalResponsesAreExecutedAndFailedA new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]) { MessageId = "resp1" }, new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]), ]; @@ -438,16 +438,16 @@ public async Task RejectedApprovalResponsesWithCustomReasonAsync() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]) { MessageId = "resp1" }, new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")) + new FunctionApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1")) { Reason = "User denied permission for this operation" }, - new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalResponseContent("ficc_callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) { Reason = "Function Func2 is not allowed at this time" } @@ -504,15 +504,15 @@ public async Task MixedApprovalResponsesWithCustomAndDefaultReasonsAsync() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })), - new FunctionApprovalRequestContent("callId3", new FunctionCallContent("callId3", "Func3", arguments: new Dictionary { { "s", "test" } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })), + new FunctionApprovalRequestContent("ficc_callId3", new FunctionCallContent("callId3", "Func3", arguments: new Dictionary { { "s", "test" } })) ]) { MessageId = "resp1" }, new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")) { Reason = "Custom rejection for Func1" }, - new FunctionApprovalResponseContent("callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })), - new FunctionApprovalResponseContent("callId3", true, new FunctionCallContent("callId3", "Func3", arguments: new Dictionary { { "s", "test" } })) + new FunctionApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1")) { Reason = "Custom rejection for Func1" }, + new FunctionApprovalResponseContent("ficc_callId2", false, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })), + new FunctionApprovalResponseContent("ficc_callId3", true, new FunctionCallContent("callId3", "Func3", arguments: new Dictionary { { "s", "test" } })) ]), ]; @@ -596,11 +596,11 @@ public async Task RejectedApprovalResponsesWithEmptyOrWhitespaceReasonUsesDefaul new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), ]) { MessageId = "resp1" }, new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")) + new FunctionApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1")) { Reason = reason }, @@ -654,13 +654,13 @@ public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]) { MessageId = "resp1" }, new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]), ]; @@ -682,7 +682,7 @@ public async Task ApprovedInputsAreExecutedAndFunctionResultsAreConvertedAsync() new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 3 } })) + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 3 } })) ]), ]; @@ -708,23 +708,23 @@ public async Task AlreadyExecutedApprovalsAreIgnoredAsync() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalRequestContent("callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId2", new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]) { MessageId = "resp1" }, new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("callId1", "Func1"), new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })]), new ChatMessage(ChatRole.Tool, [new FunctionResultContent("callId1", result: "Result 1"), new FunctionResultContent("callId2", result: "Result 2: 42")]), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId3", new FunctionCallContent("callId3", "Func1")), + new FunctionApprovalRequestContent("ficc_callId3", new FunctionCallContent("callId3", "Func1")), ]) { MessageId = "resp2" }, new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId3", true, new FunctionCallContent("callId3", "Func1")), + new FunctionApprovalResponseContent("ficc_callId3", true, new FunctionCallContent("callId3", "Func1")), ]), ]; @@ -834,7 +834,7 @@ public async Task ApprovalRequestWithoutApprovalResponseThrowsAsync() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")), ]) { MessageId = "resp1" }, ]; @@ -864,8 +864,8 @@ public async Task ApprovedApprovalResponsesWithoutApprovalRequestAreExecutedAsyn new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]), ]; @@ -910,8 +910,8 @@ public async Task FunctionCallContentIsNotPassedToDownstreamServiceWithServiceTh [ new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")), - new FunctionApprovalResponseContent("callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) + new FunctionApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")), + new FunctionApprovalResponseContent("ficc_callId2", true, new FunctionCallContent("callId2", "Func2", arguments: new Dictionary { { "i", 42 } })) ]), ]; @@ -1156,6 +1156,219 @@ public async Task FunctionCallsWithInformationalOnlyTrueAreNotReplacedWithApprov Assert.Equal(0, functionInvokedCount); } + [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("ficc_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("ficc_callId1", new FunctionCallContent("callId1", "Func")), + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("ficc_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("ficc_callId1", new FunctionCallContent("callId1", "Func")), + new FunctionApprovalRequestContent("callId2", new McpServerToolCallContent("callId2", "McpCall", "myServer")) + ]) { MessageId = "resp1" }, + new ChatMessage(ChatRole.User, + [ + new FunctionApprovalResponseContent("ficc_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); + } + [Fact] public async Task ApprovalResponsePreservesOriginalRequestMessageMetadata() { @@ -1261,7 +1474,7 @@ private static async Task> InvokeAndAssertMultiRoundAsync( IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); - var result = await service.GetResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token); + var result = await service.GetResponseAsync(new EnumeratedOnceEnumerable(CloneInput(input)), options, cts.Token); Assert.NotNull(result); var actualOutput = result.Messages as List ?? result.Messages.ToList(); @@ -1343,7 +1556,7 @@ private static async Task> InvokeAndAssertStreamingMultiRoundA IChatClient service = configurePipeline(innerClient.AsBuilder()).Build(); - var result = await service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(input), options, cts.Token).ToChatResponseAsync(); + var result = await service.GetStreamingResponseAsync(new EnumeratedOnceEnumerable(CloneInput(input)), options, cts.Token).ToChatResponseAsync(); Assert.NotNull(result); var actualOutput = result.Messages as List ?? result.Messages.ToList(); @@ -1362,4 +1575,20 @@ private static async IAsyncEnumerable YieldAsync(params T[] items) yield return item; } } + + private static List CloneInput(List input) => + input.Select(m => new ChatMessage(m.Role, m.Contents.Select(CloneFcc).ToList()) { MessageId = m.MessageId }).ToList(); + + private static AIContent CloneFcc(AIContent c) => c switch + { + McpServerToolCallContent mstcc => new McpServerToolCallContent(mstcc.CallId, mstcc.Name, mstcc.ServerName) + { + Arguments = mstcc.Arguments, + InformationalOnly = mstcc.InformationalOnly + }, + FunctionCallContent fcc => new FunctionCallContent(fcc.CallId, fcc.Name, fcc.Arguments) { InformationalOnly = fcc.InformationalOnly }, + FunctionApprovalRequestContent farc => new FunctionApprovalRequestContent(farc.RequestId, (FunctionCallContent)CloneFcc(farc.FunctionCall)), + FunctionApprovalResponseContent farc => new FunctionApprovalResponseContent(farc.RequestId, farc.Approved, (FunctionCallContent)CloneFcc(farc.FunctionCall)) { Reason = farc.Reason }, + _ => c + }; } diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs index 2ddf757d185..05ab5f1cfc9 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/FunctionInvokingChatClientTests.cs @@ -3291,7 +3291,7 @@ public async Task LogsFunctionRequiresApproval() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")) ]) ]; @@ -3327,11 +3327,11 @@ public async Task LogsProcessingApprovalResponse() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")) ]), new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", true, new FunctionCallContent("callId1", "Func1")) + new FunctionApprovalResponseContent("ficc_callId1", true, new FunctionCallContent("callId1", "Func1")) ]) }; @@ -3364,11 +3364,11 @@ public async Task LogsFunctionRejected() new ChatMessage(ChatRole.User, "hello"), new ChatMessage(ChatRole.Assistant, [ - new FunctionApprovalRequestContent("callId1", new FunctionCallContent("callId1", "Func1")) + new FunctionApprovalRequestContent("ficc_callId1", new FunctionCallContent("callId1", "Func1")) ]), new ChatMessage(ChatRole.User, [ - new FunctionApprovalResponseContent("callId1", false, new FunctionCallContent("callId1", "Func1")) { Reason = "User denied" } + new FunctionApprovalResponseContent("ficc_callId1", false, new FunctionCallContent("callId1", "Func1")) { Reason = "User denied" } ]) }; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs index 3f1c9f59bce..fbe78145f91 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatCompletion/OpenTelemetryChatClientTests.cs @@ -615,7 +615,7 @@ public async Task ServerToolCallContentTypes_SerializedCorrectly(bool streaming) new ImageGenerationToolCallContent { ImageId = "img-123" }, new ImageGenerationToolResultContent { ImageId = "img-123", Outputs = [new UriContent(new Uri("https://example.com/image.png"), "image/png")] }, new McpServerToolCallContent("mcp-call-1", "myTool", "myServer") { Arguments = new Dictionary { ["param1"] = "value1" } }, - new McpServerToolResultContent("mcp-call-1") { Output = [new TextContent("Tool result")] }, + new McpServerToolResultContent("mcp-call-1") { Result = new TextContent("Tool result") }, ])); }, GetStreamingResponseAsyncCallback = CallbackAsync, @@ -631,7 +631,7 @@ async static IAsyncEnumerable CallbackAsync( yield return new() { Contents = [new ImageGenerationToolCallContent { ImageId = "img-123" }] }; yield return new() { Contents = [new ImageGenerationToolResultContent { ImageId = "img-123", Outputs = [new UriContent(new Uri("https://example.com/image.png"), "image/png")] }] }; yield return new() { Contents = [new McpServerToolCallContent("mcp-call-1", "myTool", "myServer") { Arguments = new Dictionary { ["param1"] = "value1" } }] }; - yield return new() { Contents = [new McpServerToolResultContent("mcp-call-1") { Output = [new TextContent("Tool result")] }] }; + yield return new() { Contents = [new McpServerToolResultContent("mcp-call-1") { Result = new TextContent("Tool result") }] }; } using var chatClient = innerClient @@ -734,12 +734,10 @@ async static IAsyncEnumerable CallbackAsync( "id": "mcp-call-1", "server_tool_call_response": { "type": "mcp", - "output": [ - { - "$type": "text", - "text": "Tool result" - } - ] + "output": { + "$type": "text", + "text": "Tool result" + } } } ] @@ -785,11 +783,11 @@ public async Task McpServerToolApprovalContentTypes_SerializedCorrectly() [ new(ChatRole.Assistant, [ - new McpServerToolApprovalRequestContent("approval-1", toolCall), + new FunctionApprovalRequestContent("approval-1", toolCall), ]), new(ChatRole.User, [ - new McpServerToolApprovalResponseContent("approval-1", true), + new FunctionApprovalResponseContent("approval-1", true, toolCall), ]), ]; diff --git a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs index 8fa801c4811..5c92abf8a9a 100644 --- a/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs +++ b/test/Libraries/Microsoft.Extensions.AI.Tests/ChatReduction/SummarizingChatReducerTests.cs @@ -96,15 +96,15 @@ public async Task ReduceAsync_PreservesCompleteToolCallSequence() new ChatMessage(ChatRole.User, "What's the time?"), new ChatMessage(ChatRole.Assistant, "Let me check"), new ChatMessage(ChatRole.User, "What's the weather?"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather"), new TestUserInputRequestContent("uir1")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather"), new TestInputRequestContent("uir1")]), new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny")]), - new ChatMessage(ChatRole.User, [new TestUserInputResponseContent("uir1")]), + new ChatMessage(ChatRole.User, [new TestInputResponseContent("uir1")]), new ChatMessage(ChatRole.Assistant, "It's sunny"), ]; chatClient.GetResponseAsyncCallback = (msgs, _, _) => { - Assert.DoesNotContain(msgs, m => m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent or TestUserInputRequestContent or TestUserInputResponseContent)); + Assert.DoesNotContain(msgs, m => m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent or TestInputRequestContent or TestInputResponseContent)); return Task.FromResult(new ChatResponse(new ChatMessage(ChatRole.Assistant, "Asked about time"))); }; @@ -120,10 +120,10 @@ public async Task ReduceAsync_PreservesCompleteToolCallSequence() m => { Assert.Contains(m.Contents, c => c is FunctionCallContent); - Assert.Contains(m.Contents, c => c is TestUserInputRequestContent); + Assert.Contains(m.Contents, c => c is TestInputRequestContent); }, m => Assert.Contains(m.Contents, c => c is FunctionResultContent), - m => Assert.Contains(m.Contents, c => c is TestUserInputResponseContent), + m => Assert.Contains(m.Contents, c => c is TestInputResponseContent), m => Assert.Contains("sunny", m.Text)); } @@ -185,9 +185,9 @@ public async Task ReduceAsync_ExcludesToolCallsFromSummarizedPortion() List messages = [ new ChatMessage(ChatRole.User, "What's the weather in Seattle?"), - new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["location"] = "Seattle" }), new TestUserInputRequestContent("uir2")]), + new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", "get_weather", new Dictionary { ["location"] = "Seattle" }), new TestInputRequestContent("uir2")]), new ChatMessage(ChatRole.Tool, [new FunctionResultContent("call1", "Sunny, 72°F")]), - new ChatMessage(ChatRole.User, [new TestUserInputResponseContent("uir2")]), + new ChatMessage(ChatRole.User, [new TestInputResponseContent("uir2")]), new ChatMessage(ChatRole.Assistant, "It's sunny and 72°F in Seattle."), new ChatMessage(ChatRole.User, "What about New York?"), new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", "get_weather", new Dictionary { ["location"] = "New York" })]), @@ -200,7 +200,7 @@ public async Task ReduceAsync_ExcludesToolCallsFromSummarizedPortion() var msgList = msgs.ToList(); Assert.Equal(4, msgList.Count); // 3 non-function messages + system prompt - Assert.DoesNotContain(msgList, m => m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent or TestUserInputRequestContent or TestUserInputResponseContent)); + Assert.DoesNotContain(msgList, m => m.Contents.Any(c => c is FunctionCallContent or FunctionResultContent or TestInputRequestContent or TestInputResponseContent)); Assert.Contains(msgList, m => m.Text.Contains("What's the weather in Seattle?")); Assert.Contains(msgList, m => m.Text.Contains("sunny and 72°F in Seattle")); Assert.Contains(msgList, m => m.Text.Contains("What about New York?")); @@ -220,8 +220,8 @@ public async Task ReduceAsync_ExcludesToolCallsFromSummarizedPortion() Assert.Contains(resultList, m => m.Contents.Any(c => c is FunctionResultContent fr && fr.CallId == "call2")); Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is FunctionCallContent fc && fc.CallId == "call1")); Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is FunctionResultContent fr && fr.CallId == "call1")); - Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is TestUserInputRequestContent)); - Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is TestUserInputResponseContent)); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is TestInputRequestContent)); + Assert.DoesNotContain(resultList, m => m.Contents.Any(c => c is TestInputResponseContent)); Assert.DoesNotContain(resultList, m => m.Text.Contains("sunny and 72°F in Seattle")); } @@ -385,18 +385,18 @@ need frequent exercise. The user then asked about whether they're good around ki m => Assert.StartsWith("Do they make good lap dogs", m.Text, StringComparison.Ordinal)); } - private sealed class TestUserInputRequestContent : UserInputRequestContent + private sealed class TestInputRequestContent : InputRequestContent { - public TestUserInputRequestContent(string id) - : base(id) + public TestInputRequestContent(string requestId) + : base(requestId) { } } - private sealed class TestUserInputResponseContent : UserInputResponseContent + private sealed class TestInputResponseContent : InputResponseContent { - public TestUserInputResponseContent(string id) - : base(id) + public TestInputResponseContent(string requestId) + : base(requestId) { } }