diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md index a51c7995b0a..c5025afd3fb 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox-AuthPaths/README.md @@ -25,6 +25,13 @@ This sample deploys to Foundry **directly from source (code / ZIP upload)**: the | `.env.example` | Template for local configuration. | | `../../scripts/Add-LocalFrameworkFeed.ps1`, `../../scripts/add-local-framework-feed.sh` | Contributor-only helpers, see [Deploy your local framework changes](#deploy-your-local-framework-changes-contributors). | +## Responses tool contract + +After consent and any required approval, the hosted container executes the Toolbox tool through MCP. +Responses callers receive one completed `mcp_call` item with inline output rather than a +`function_call` plus `function_call_output` pair. Approval-required Toolbox tools use +`mcp_approval_request`, and delegated OAuth continues to use `oauth_consent_request`. + ## Configuration Copy the template and fill in your project endpoint: diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md index f4c29e88027..3679d368a57 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-Toolbox/README.md @@ -25,6 +25,14 @@ This sample deploys to Foundry **directly from source (code / ZIP upload)**: the | `.env.example` | Template for local configuration. | | `../../scripts/Add-LocalFrameworkFeed.ps1`, `../../scripts/add-local-framework-feed.sh` | Contributor-only helpers, see [Deploy your local framework changes](#deploy-your-local-framework-changes-contributors). | +## Responses tool contract + +The hosted container executes Toolbox tools through MCP. Responses callers see each execution as one +`mcp_call` item with the Toolbox name in `server_label`, the remote tool name, its arguments, and the +result in the item's `output`. The caller does not execute the tool and does not send a +`function_call_output`. A Toolbox tool that requires approval is surfaced as an +`mcp_approval_request` before execution. + ## Configuration Copy the template and fill in your project endpoint: diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md index 68d21d06742..2e72e9c66e8 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md @@ -174,6 +174,11 @@ conversation id, so the approval request is sent with `conversation`, not `previous_response_id`. Sending only `previous_response_id` selects the separate implicit-chain path; sending both fields is rejected by the Responses API. +After approval, `load_skill` is returned as `function_call` plus `function_call_output`, followed by +the assistant message. These are Agent Framework's built-in skill functions. The Toolbox MCP +connection supplies the skill source; it is not the tool call exposed to the model, so this path does +not produce an `mcp_call`. + ### Step 4: clean up ``` diff --git a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Program.cs b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Program.cs index 3ec2f0f711e..e99be5c6251 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Program.cs +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Using-Samples/Hosted-Toolbox-AuthPaths-Client/Program.cs @@ -156,7 +156,12 @@ Type a message or 'quit' to exit. List decisions = approvals.ConvertAll(approval => { - string name = (approval.ToolCall as FunctionCallContent)?.Name ?? "tool"; + string name = approval.ToolCall switch + { + FunctionCallContent functionCall => functionCall.Name, + McpServerToolCallContent mcpCall => mcpCall.Name, + _ => "tool", + }; Console.ForegroundColor = ConsoleColor.Yellow; Console.Write($"Approve tool call '{name}'? [Y/N] "); Console.ResetColor(); diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 04ce0df7d2d..d0766f878cd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -254,8 +254,7 @@ public override async IAsyncEnumerable CreateAsync( agentOptions?.ChatOptions?.RawRepresentationFactory, this._options); chatOptions.Instructions = request.Instructions; - - // Inject pre-registered Foundry Toolbox tools when the toolbox service is available. + // Inject pre-registered Foundry Toolbox tools and collect local MCP execution metadata. if (this._toolboxService is not null) { // Re-apply the call id: the EmitCreated/EmitInProgress yields above reverted the ambient @@ -296,6 +295,8 @@ public override async IAsyncEnumerable CreateAsync( ConsentBlockingFunctionInvoker.EnsureInstalled(functionInvokingClient); } + IReadOnlyDictionary localMcpFunctions = + CreateLocalMcpFunctionContracts(agentOptions?.ChatOptions?.Tools, chatOptions.Tools); var options = new ChatClientAgentRunOptions(chatOptions); // We only use a volatile provider for the conversation history if the agent is a @@ -361,6 +362,7 @@ public override async IAsyncEnumerable CreateAsync( agent.RunStreamingAsync(messages, session, options: options, cancellationToken: consentCts.Token), stream, session?.StateBag, + localMcpFunctions, cancellationToken: cancellationToken).GetAsyncEnumerator(cancellationToken); var pendingEvents = new Queue(); try @@ -752,6 +754,58 @@ await toolboxService Consents: []); } + private static Dictionary CreateLocalMcpFunctionContracts( + params IEnumerable?[] toolSets) + { + var contracts = new Dictionary(StringComparer.Ordinal); + var functions = new Dictionary( + StringComparer.Ordinal); + var observedTools = new HashSet(ReferenceEqualityComparer.Instance); + foreach (var toolSet in toolSets) + { + if (toolSet is null) + { + continue; + } + + foreach (var tool in toolSet) + { + if (!observedTools.Add(tool) || tool is not AIFunction function) + { + continue; + } + + LocalMcpExecutionContract? contract = function.GetService(); + if (contract is not null) + { + contract = contract with + { + RequiresApproval = function.GetService() is not null, + }; + } + + if (functions.TryGetValue(function.Name, out var existing) + && (existing.Contract is not null || contract is not null)) + { + throw new InvalidOperationException( + $"Local MCP function name '{function.Name}' collides with another agent function."); + } + + functions[function.Name] = (function, contract); + } + } + + foreach (var (functionName, entry) in functions) + { + if (entry.Contract is not null) + { + contracts.Add(functionName, entry.Contract); + } + } + + return contracts; + } + /// /// Emits the terminal events for a turn that stops on OAuth consent: one oauth_consent_request /// item per consent, then response.incomplete. diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs index 8b7ffc3e643..f88ef14da08 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Diagnostics.CodeAnalysis; using System.Runtime.CompilerServices; using System.Text.Json; using System.Threading; @@ -11,6 +12,52 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; +/// +/// Describes how one locally executed function-shaped MCP tool is projected onto the caller-facing MCP contract. +/// +internal sealed record LocalMcpExecutionContract( + string ServerLabel, + string ToolName, + bool RequiresApproval, + bool DeferProjectionUntilResult = false); + +/// +/// Adds caller-facing MCP execution metadata to an arbitrary locally executed function. +/// +internal sealed class LocalMcpAIFunction : DelegatingAIFunction +{ + private readonly LocalMcpExecutionContract _contract; + + internal LocalMcpAIFunction(AIFunction innerFunction, string serverLabel, string toolName) + : base(innerFunction) + { + this._contract = new(serverLabel, toolName, innerFunction.GetService() is not null); + } + + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType == typeof(LocalMcpExecutionContract) + ? this._contract + : base.GetService(serviceType, serviceKey); + + protected override async ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + LocalMcpInvocationTracker.Record(this._contract); + try + { + return await base.InvokeCoreAsync(arguments, cancellationToken).ConfigureAwait(false); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancellation produces no FunctionResultContent, so OutputConverter cannot consume + // the temporary invocation marker. Ordinary failures still need the marker for projection. + LocalMcpInvocationTracker.RemoveCurrent(); + throw; + } + } +} + /// /// An wrapper around that intercepts /// JSON-RPC error -32006 (OAuth consent required) from the Foundry Toolsets proxy and @@ -31,12 +78,17 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; internal sealed class ConsentAwareMcpClientAIFunction : AIFunction { private readonly McpClientTool _inner; - private readonly string _toolboxName; + private readonly LocalMcpExecutionContract _contract; internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName) { this._inner = inner; - this._toolboxName = toolboxName; + this.ToolboxName = toolboxName; + this._contract = new( + toolboxName, + inner.Name, + RequiresApproval: false, + DeferProjectionUntilResult: true); } public override string Name => this._inner.Name; @@ -49,16 +101,27 @@ internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName public override JsonSerializerOptions JsonSerializerOptions => this._inner.JsonSerializerOptions; + internal string ToolboxName { get; } + + internal string ToolName => this._inner.Name; + + public override object? GetService(Type serviceType, object? serviceKey = null) => + serviceKey is null && serviceType == typeof(LocalMcpExecutionContract) + ? this._contract + : base.GetService(serviceType, serviceKey); + protected override async ValueTask InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken) { + LocalMcpInvocationTracker.Record(this._contract); try { return await this._inner.InvokeAsync(arguments, cancellationToken).ConfigureAwait(false); } catch (McpProtocolException ex) when ((int)ex.ErrorCode == -32006) { + LocalMcpInvocationTracker.RemoveCurrent(); var state = McpConsentContext.Current.Value; if (state is not null) { @@ -74,7 +137,7 @@ internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName // handler rejects this consent response instead of falling back to the message. _ => string.Empty, }; - state.AddPendingConsent(new McpConsentInfo(this._toolboxName, this._inner.Name, consentUrl)); + state.AddPendingConsent(new McpConsentInfo(this.ToolboxName, this._inner.Name, consentUrl)); if (FunctionInvokingChatClient.CurrentContext is { } invocation) { if (state.ReconcileDownstreamHistory) @@ -98,6 +161,49 @@ internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName cancellationToken.ThrowIfCancellationRequested(); throw; // fallback if the CT wasn't cancelled for some reason } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + // Cancellation produces no FunctionResultContent, so OutputConverter cannot consume + // the temporary invocation marker. Ordinary failures still need the marker for projection. + LocalMcpInvocationTracker.RemoveCurrent(); + throw; + } + } +} + +internal static class LocalMcpInvocationTracker +{ + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call arguments dictionary.")] + public static void Record(LocalMcpExecutionContract contract) + { + FunctionCallContent? call = FunctionInvokingChatClient.CurrentContext?.CallContent; + AgentSessionStateBag? stateBag = AIAgent.CurrentRunContext?.Session?.StateBag; + if (call?.CallId is not { Length: > 0 } callId || stateBag is null) + { + return; + } + + string arguments = call.Arguments is not null + ? JsonSerializer.Serialize(call.Arguments) + : "{}"; + LocalMcpInvocationMap.Record( + stateBag, + callId, + call.Name, + contract.ToolName, + contract.ServerLabel, + arguments); + } + + public static void RemoveCurrent() + { + FunctionCallContent? call = FunctionInvokingChatClient.CurrentContext?.CallContent; + AgentSessionStateBag? stateBag = AIAgent.CurrentRunContext?.Session?.StateBag; + if (call?.CallId is { Length: > 0 } callId) + { + _ = LocalMcpInvocationMap.Consume(stateBag, callId); + } } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs index 7f33c1a4f38..9d6ce055559 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs @@ -38,11 +38,37 @@ public static List ConvertInputToMessages(CreateResponse request, A { messages.Add(message); } + } return messages; } + private static void AppendRejectedMcpApprovalResult( + List messages, + string approvalRequestId, + AgentSessionStateBag? stateBag) + { + const string RejectionResult = + "Tool call invocation was rejected by the approver and must not be retried."; + if (ToolApprovalIdMap.ResolveEntry(stateBag, approvalRequestId) is not + { + Approved: false, + McpServerLabel: not null + } entry) + { + return; + } + + AIContent result = entry.IsMcpToolCall + ? new McpServerToolResultContent(entry.CallId) + { + Outputs = [new ErrorContent(RejectionResult)] + } + : new FunctionResultContent(entry.CallId, RejectionResult); + messages.Add(new ChatMessage(ChatRole.Tool, [result])); + } + /// /// Converts resolved SDK input items into instances. /// @@ -60,6 +86,7 @@ public static List ConvertItemsToMessages(IReadOnlyList items { messages.Add(message); } + } return messages; @@ -77,11 +104,22 @@ public static List ConvertOutputItemsToMessages(IReadOnlyList ConvertItemMessage(msg), FunctionCallOutputItemParam funcOutput => ConvertFunctionCallOutput(funcOutput), ItemFunctionToolCall funcCall => ConvertItemFunctionToolCall(funcCall), - ItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments, stateBag), - MCPApprovalResponse approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag), + ItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest( + approvalRequest.Id, + approvalRequest.ServerLabel, + approvalRequest.Name, + approvalRequest.Arguments, + stateBag), + MCPApprovalResponse approvalResponse => ConvertMcpApprovalResponse( + approvalResponse.ApprovalRequestId, + approvalResponse.Approve, + stateBag, + recordDecision: true), ItemComputerToolCall computerCall => ComputerToolItemConverter.ToChatMessage(computerCall), ComputerCallOutputItemParam computerCallOutput => ComputerToolItemConverter.ToChatMessage(computerCallOutput), ItemReferenceParam => null, @@ -281,24 +328,30 @@ private static ChatMessage ConvertItemFunctionToolCall(ItemFunctionToolCall func /// /// Converts an inbound mcp_approval_request wire item (from history replay /// or fresh-input) to a wrapping a - /// . + /// the original function or MCP tool call. /// - private static ChatMessage ConvertMcpApprovalRequest(string id, string name, string? arguments, AgentSessionStateBag? stateBag) + private static ChatMessage ConvertMcpApprovalRequest( + string id, + string serverLabel, + string name, + string? arguments, + AgentSessionStateBag? stateBag) { var entry = ToolApprovalIdMap.ResolveEntry(stateBag, id); - if (entry is not { AfRequestId: string requestId } || entry.Name != name || entry.Arguments != arguments) + string? publicToolName = entry?.McpToolName ?? entry?.Name; + if (entry is not { AfRequestId: string requestId } + || !string.Equals(publicToolName, name, StringComparison.Ordinal) + || entry.Arguments != arguments + || (entry.McpServerLabel is not null + && !string.Equals(entry.McpServerLabel, serverLabel, StringComparison.Ordinal))) { throw new InvalidOperationException( $"Approval mapping for wire id '{id}' does not match the replayed approval request."); } - var functionCall = new FunctionCallContent( - entry.CallId, - entry.Name, - ParseFunctionArgumentsObject(arguments)); return new ChatMessage( ChatRole.Assistant, - [new ToolApprovalRequestContent(requestId, functionCall)]); + [new ToolApprovalRequestContent(requestId, CreateApprovalToolCall(entry, arguments))]); } /// @@ -311,20 +364,168 @@ private static ChatMessage ConvertMcpApprovalRequest(string id, string name, str /// Thrown when no mapping is recorded for . /// Without the mapping the original call cannot be reconstructed, so we fail the request. /// - private static ChatMessage ConvertMcpApprovalResponse(string approvalRequestId, bool approve, AgentSessionStateBag? stateBag) + private static ChatMessage ConvertMcpApprovalResponse( + string approvalRequestId, + bool approve, + AgentSessionStateBag? stateBag, + bool recordDecision) { - var entry = ToolApprovalIdMap.ResolveEntry(stateBag, approvalRequestId) - ?? throw new InvalidOperationException( - $"No approval mapping recorded for wire id '{approvalRequestId}'."); - - var functionCall = new FunctionCallContent( - entry.CallId, - entry.Name, - ParseFunctionArgumentsObject(entry.Arguments)); + var entry = recordDecision + ? ToolApprovalIdMap.RecordDecision(stateBag, approvalRequestId, approve) + : ToolApprovalIdMap.ResolveEntry(stateBag, approvalRequestId) + ?? throw new InvalidOperationException( + $"No approval mapping recorded for wire id '{approvalRequestId}'."); return new ChatMessage( ChatRole.User, - [new ToolApprovalResponseContent(entry.AfRequestId, approve, functionCall)]); + [new ToolApprovalResponseContent(entry.AfRequestId, approve, CreateApprovalToolCall(entry, entry.Arguments))]); + } + + private static ToolCallContent CreateApprovalToolCall( + ToolApprovalIdMap.ApprovalEntry entry, + string? arguments) + { + var parsedArguments = ParseFunctionArgumentsObject(arguments); + if (entry.IsMcpToolCall) + { + return new McpServerToolCallContent( + entry.CallId, + entry.McpToolName ?? entry.Name, + entry.McpServerLabel) + { + Arguments = parsedArguments, + }; + } + + return new FunctionCallContent(entry.CallId, entry.Name, parsedArguments); + } + + private static void AppendMcpCallMessages( + List messages, + OutputItemMcpToolCall mcpCall, + AgentSessionStateBag? stateBag) + { + var arguments = ParseFunctionArgumentsObject(mcpCall.Arguments); + bool hasTerminalResult = mcpCall.Status is MCPToolCallStatus.Completed + or MCPToolCallStatus.Failed + or MCPToolCallStatus.Incomplete; + string? failureMessage = GetMcpFailureMessage(mcpCall); + + if (LocalMcpCallIdMap.ResolveEntry(stateBag, mcpCall.Id) is { } localMcpEntry) + { + if (!string.Equals(localMcpEntry.ServerLabel, mcpCall.ServerLabel, StringComparison.Ordinal) + || !string.Equals(localMcpEntry.ToolName, mcpCall.Name, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Local MCP mapping for wire id '{mcpCall.Id}' does not match the replayed MCP call."); + } + + messages.Add(new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent(localMcpEntry.CallId, localMcpEntry.FunctionName, arguments)])); + if (hasTerminalResult) + { + var result = new FunctionResultContent(localMcpEntry.CallId, mcpCall.Output); + if (failureMessage is not null) + { + result.Exception = new InvalidOperationException(failureMessage); + } + + messages.Add(new ChatMessage( + ChatRole.Tool, + [result])); + } + + return; + } + + if (NativeMcpCallIdMap.ResolveEntry(stateBag, mcpCall.Id) is { } nativeMcpEntry) + { + if (!string.Equals(nativeMcpEntry.ServerLabel, mcpCall.ServerLabel, StringComparison.Ordinal) + || !string.Equals(nativeMcpEntry.ToolName, mcpCall.Name, StringComparison.Ordinal)) + { + throw new InvalidOperationException( + $"Native MCP mapping for wire id '{mcpCall.Id}' does not match the replayed MCP call."); + } + + AppendNativeMcpMessages( + messages, + nativeMcpEntry.CallId, + nativeMcpEntry.ToolName, + nativeMcpEntry.ServerLabel, + arguments, + mcpCall.Output, + hasTerminalResult, + failureMessage); + return; + } + + AppendNativeMcpMessages( + messages, + mcpCall.Id, + mcpCall.Name, + mcpCall.ServerLabel, + arguments, + mcpCall.Output, + hasTerminalResult, + failureMessage); + } + + private static void AppendNativeMcpMessages( + List messages, + string callId, + string toolName, + string serverLabel, + IDictionary? arguments, + string output, + bool hasTerminalResult, + string? failureMessage) + { + messages.Add(new ChatMessage( + ChatRole.Assistant, + [new McpServerToolCallContent(callId, toolName, serverLabel) + { + Arguments = arguments, + }])); + if (hasTerminalResult) + { + AIContent result = failureMessage is null + ? new MeaiTextContent(output) + : new ErrorContent(failureMessage); + messages.Add(new ChatMessage( + ChatRole.Tool, + [new McpServerToolResultContent(callId) + { + Outputs = [result], + }])); + } + } + + private static string? GetMcpFailureMessage(OutputItemMcpToolCall mcpCall) + { + if (mcpCall.Status is not (MCPToolCallStatus.Failed or MCPToolCallStatus.Incomplete)) + { + return null; + } + + if (mcpCall.Error.TryGetValue("message", out BinaryData? errorData)) + { + try + { + using JsonDocument document = JsonDocument.Parse(errorData); + if (document.RootElement.ValueKind == JsonValueKind.String + && document.RootElement.GetString() is { Length: > 0 } message) + { + return message; + } + } + catch (JsonException) + { + // Fall through to the status-based message for malformed provider errors. + } + } + + return $"MCP tool call ended with status '{mcpCall.Status}'."; } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing tool-call arguments from SDK input.")] @@ -353,8 +554,17 @@ private static ChatMessage ConvertMcpApprovalResponse(string approvalRequestId, OutputItemMessage msg => ConvertOutputItemMessageToChat(msg), OutputItemFunctionToolCall funcCall => ConvertOutputItemFunctionCall(funcCall), OutputItemFunctionToolCallOutput funcOutput => ConvertFunctionToolCallOutput(funcOutput), - OutputItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest(approvalRequest.Id, approvalRequest.Name, approvalRequest.Arguments, stateBag), - OutputItemMcpApprovalResponseResource approvalResponse => ConvertMcpApprovalResponse(approvalResponse.ApprovalRequestId, approvalResponse.Approve, stateBag), + OutputItemMcpApprovalRequest approvalRequest => ConvertMcpApprovalRequest( + approvalRequest.Id, + approvalRequest.ServerLabel, + approvalRequest.Name, + approvalRequest.Arguments, + stateBag), + OutputItemMcpApprovalResponseResource approvalResponse => ConvertMcpApprovalResponse( + approvalResponse.ApprovalRequestId, + approvalResponse.Approve, + stateBag, + recordDecision: false), OutputItemComputerToolCall computerCall => ComputerToolItemConverter.ToChatMessage(computerCall), OutputItemComputerToolCallOutput computerCallOutput => ComputerToolItemConverter.ToChatMessage(computerCallOutput), OutputItemReasoningItem => null, diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/LocalMcpAIFunctionExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/LocalMcpAIFunctionExtensions.cs new file mode 100644 index 00000000000..ce2f0ca136f --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/LocalMcpAIFunctionExtensions.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Extensions.AI; +using Microsoft.Shared.DiagnosticIds; + +namespace Microsoft.Agents.AI.Foundry.Hosting; + +/// +/// Extension methods for locally executed MCP functions. +/// +[Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] +public static class LocalMcpAIFunctionExtensions +{ + /// + /// Marks a locally executed MCP function so Foundry hosting exposes its execution as a caller-facing + /// mcp_call item instead of a function-call pair. + /// + /// The locally executed MCP function. + /// The MCP server label exposed to Responses callers. + /// + /// The remote MCP tool name. When omitted, is used. + /// + /// A function retaining the original invocation behavior with local MCP execution metadata. + public static AIFunction AsLocalMcpTool( + this AIFunction function, + string serverLabel, + string? toolName = null) + { + ArgumentNullException.ThrowIfNull(function); + ArgumentException.ThrowIfNullOrWhiteSpace(serverLabel); + + toolName ??= function.Name; + ArgumentException.ThrowIfNullOrWhiteSpace(toolName); + + return new LocalMcpAIFunction(function, serverLabel, toolName); + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs index 3d373cad3b3..da763d8634f 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -51,6 +51,20 @@ internal static class OutputConverter // Keep non-ASCII characters as-is while continuing to escape HTML-sensitive characters. private static readonly JsonSerializerOptions s_jsonOptions = new() { Encoder = JavaScriptEncoder.Create(UnicodeRanges.All) }; + private sealed class PendingMcpCall( + OutputItemMcpCallBuilder builder, + LocalMcpExecutionContract contract, + string arguments) + { + public OutputItemMcpCallBuilder Builder { get; } = builder; + + public LocalMcpExecutionContract Contract { get; } = contract; + + public string Arguments { get; } = arguments; + } + + private sealed record DeferredLocalMcpCall(string FunctionName, string Arguments); + /// /// Converts a stream of into a stream of /// using the SDK builder pattern. @@ -58,6 +72,7 @@ internal static class OutputConverter /// The agent response updates to convert. /// The SDK event stream builder. /// Optional session state bag used to persist tool-approval id mappings across turns. + /// Locally executed function names and their MCP wire identities. /// Cancellation token. /// /// An async enumerable of SDK response stream events. Workflow checkpoint boundaries are omitted. @@ -66,12 +81,14 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents IAsyncEnumerable updates, ResponseEventStream stream, AgentSessionStateBag? stateBag = null, + IReadOnlyDictionary? localMcpFunctions = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { await foreach (OutputConverterItem item in ConvertUpdatesToItemsAsync( updates, stream, stateBag, + localMcpFunctions, cancellationToken).ConfigureAwait(false)) { if (item is OutputConverterItem.ResponseEvent responseEvent) @@ -88,6 +105,7 @@ public static async IAsyncEnumerable ConvertUpdatesToEvents /// The agent response updates to convert. /// The SDK event stream builder. /// Optional session state bag used to persist tool-approval id mappings across turns. + /// Locally executed function names and their MCP wire identities. /// Cancellation token. /// Converted response events and workflow checkpoint boundaries in source order. [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call arguments dictionary.")] @@ -96,6 +114,7 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA IAsyncEnumerable updates, ResponseEventStream stream, AgentSessionStateBag? stateBag = null, + IReadOnlyDictionary? localMcpFunctions = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ResponseUsage? accumulatedUsage = null; @@ -106,6 +125,8 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA string? previousMessageId = null; bool hasTerminalEvent = false; var executorItemIds = new Dictionary(); + var pendingMcpCalls = new Dictionary(StringComparer.Ordinal); + var deferredLocalMcpCalls = new Dictionary(StringComparer.Ordinal); await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) { @@ -217,11 +238,99 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA ? JsonSerializer.Serialize(functionCall.Arguments, s_jsonOptions) : "{}"; - var fcBuilder = stream.AddOutputItemFunctionCall(functionCall.Name, functionCall.CallId); - yield return fcBuilder.EmitAdded(); - yield return fcBuilder.EmitArgumentsDelta(arguments); - yield return fcBuilder.EmitArgumentsDone(arguments); - yield return fcBuilder.EmitDone(); + if (localMcpFunctions?.TryGetValue(functionCall.Name, out var localMcpFunction) is true) + { + if (localMcpFunction.DeferProjectionUntilResult) + { + deferredLocalMcpCalls[functionCall.CallId] = + new DeferredLocalMcpCall(functionCall.Name, arguments); + break; + } + + // Approval-required local MCP calls surface only as mcp_approval_request until the + // caller approves them. Emitting an mcp_call here would falsely claim execution. + if (localMcpFunction.RequiresApproval) + { + break; + } + + var mcpBuilder = stream.AddOutputItemMcpCall( + localMcpFunction.ServerLabel, + localMcpFunction.ToolName); + LocalMcpCallIdMap.Record( + stateBag, + mcpBuilder.ItemId, + functionCall.CallId, + functionCall.Name, + localMcpFunction.ToolName, + localMcpFunction.ServerLabel); + pendingMcpCalls[functionCall.CallId] = + new PendingMcpCall(mcpBuilder, localMcpFunction, arguments); + + yield return mcpBuilder.EmitAdded(); + yield return mcpBuilder.EmitInProgress(); + yield return mcpBuilder.EmitArgumentsDelta(arguments); + yield return mcpBuilder.EmitArgumentsDone(arguments); + break; + } + + foreach (var evt in EmitFunctionCall( + stream, + functionCall.Name, + functionCall.CallId, + arguments)) + { + yield return evt; + } + + break; + } + + case McpServerToolCallContent mcpCall: + { + if (mcpCall.CallId is not { Length: > 0 }) + { + break; + } + + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + accumulatedAnnotations = null; + previousMessageId = null; + + string serverLabel = mcpCall.ServerName ?? "mcp"; + string arguments = mcpCall.Arguments is not null + ? JsonSerializer.Serialize(mcpCall.Arguments, s_jsonOptions) + : "{}"; + var contract = new LocalMcpExecutionContract( + serverLabel, + mcpCall.Name, + RequiresApproval: false); + var mcpBuilder = stream.AddOutputItemMcpCall(serverLabel, mcpCall.Name); + NativeMcpCallIdMap.Record( + stateBag, + mcpBuilder.ItemId, + mcpCall.CallId, + mcpCall.Name, + serverLabel); + if (!pendingMcpCalls.TryAdd( + mcpCall.CallId, + new PendingMcpCall(mcpBuilder, contract, arguments))) + { + throw new InvalidOperationException( + $"MCP call id '{mcpCall.CallId}' is already active."); + } + + yield return mcpBuilder.EmitAdded(); + yield return mcpBuilder.EmitInProgress(); + yield return mcpBuilder.EmitArgumentsDelta(arguments); + yield return mcpBuilder.EmitArgumentsDone(arguments); break; } @@ -335,6 +444,8 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA var approvalArguments = approvalFunctionCall.Arguments is not null ? JsonSerializer.Serialize(approvalFunctionCall.Arguments, s_jsonOptions) : "{}"; + LocalMcpExecutionContract? localMcpFunction = null; + _ = localMcpFunctions?.TryGetValue(approvalFunctionCall.Name, out localMcpFunction); ToolApprovalIdMap.Record( stateBag, @@ -342,12 +453,14 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA approvalRequest.RequestId, approvalFunctionCall.CallId, approvalFunctionCall.Name, - approvalArguments); + approvalArguments, + localMcpFunction?.ServerLabel, + localMcpFunction?.ToolName); var approvalItem = new OutputItemMcpApprovalRequest( wireId, - "agent_framework", - approvalFunctionCall.Name, + localMcpFunction?.ServerLabel ?? "agent_framework", + localMcpFunction?.ToolName ?? approvalFunctionCall.Name, approvalArguments); var approvalBuilder = stream.AddOutputItem(wireId); @@ -356,8 +469,48 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA break; } + case ToolApprovalRequestContent approvalRequest when approvalRequest.ToolCall is McpServerToolCallContent approvalMcpCall: + { + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + accumulatedAnnotations = null; + previousMessageId = null; + + string wireId = ToolApprovalIdMap.ComputeWireId(approvalRequest.RequestId); + string serverLabel = approvalMcpCall.ServerName ?? "mcp"; + string arguments = approvalMcpCall.Arguments is not null + ? JsonSerializer.Serialize(approvalMcpCall.Arguments, s_jsonOptions) + : "{}"; + ToolApprovalIdMap.Record( + stateBag, + wireId, + approvalRequest.RequestId, + approvalMcpCall.CallId, + approvalMcpCall.Name, + arguments, + serverLabel, + approvalMcpCall.Name, + isMcpToolCall: true); + + var approvalItem = new OutputItemMcpApprovalRequest( + wireId, + serverLabel, + approvalMcpCall.Name, + arguments); + var approvalBuilder = stream.AddOutputItem(wireId); + yield return approvalBuilder.EmitAdded(approvalItem); + yield return approvalBuilder.EmitDone(approvalItem); + break; + } + case ToolApprovalRequestContent: - // Approval requests must wrap a FunctionCallContent (handled above). + // Approval requests must wrap a supported function or MCP tool call. // Any other shape has no representation in the Responses wire format. break; @@ -386,6 +539,24 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA previousMessageId = null; hasTerminalEvent = true; + foreach (var pending in pendingMcpCalls.Values) + { + yield return pending.Builder.EmitFailed(); + var item = new OutputItemMcpToolCall( + pending.Builder.ItemId, + pending.Contract.ServerLabel, + pending.Contract.ToolName, + pending.Arguments) + { + Output = string.Empty, + Status = MCPToolCallStatus.Failed, + }; + item.Error["message"] = BinaryData.FromObjectAsJson( + errorContent.Message ?? "An error occurred during agent execution."); + yield return pending.Builder.EmitDone(item); + } + + pendingMcpCalls.Clear(); yield return stream.EmitFailed( ResponseErrorCode.ServerError, errorContent.Message ?? "An error occurred during agent execution.", @@ -418,6 +589,138 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA accumulatedAnnotations = null; previousMessageId = null; + _ = deferredLocalMcpCalls.Remove( + functionResult.CallId, + out DeferredLocalMcpCall? deferredFunctionCall); + bool consentInterrupted = IsConsentInterruptedResult(functionResult); + + if (!pendingMcpCalls.Remove( + functionResult.CallId, + out PendingMcpCall? pendingMcpCall)) + { + var approvalEntry = ToolApprovalIdMap.ConsumeMcpEntryByCallId( + stateBag, + functionResult.CallId, + isMcpToolCall: false); + if (approvalEntry?.Approved is false) + { + // A rejected approval produces a synthetic function result for the inner + // model transcript. It is not an MCP execution and must not become mcp_call. + break; + } + + if (approvalEntry is + { + Approved: true, + McpServerLabel: { Length: > 0 } serverLabel, + Name: { Length: > 0 } name + }) + { + var arguments = approvalEntry.Arguments ?? "{}"; + if (consentInterrupted) + { + deferredFunctionCall ??= new DeferredLocalMcpCall(name, arguments); + } + else + { + string toolName = approvalEntry.McpToolName ?? name; + var contract = new LocalMcpExecutionContract( + serverLabel, + toolName, + RequiresApproval: true); + var mcpBuilder = stream.AddOutputItemMcpCall(serverLabel, toolName); + LocalMcpCallIdMap.Record( + stateBag, + mcpBuilder.ItemId, + functionResult.CallId, + name, + toolName, + serverLabel); + pendingMcpCall = new PendingMcpCall(mcpBuilder, contract, arguments); + + yield return mcpBuilder.EmitAdded(); + yield return mcpBuilder.EmitInProgress(); + yield return mcpBuilder.EmitArgumentsDelta(arguments); + yield return mcpBuilder.EmitArgumentsDone(arguments); + } + } + } + if (pendingMcpCall is null + && LocalMcpInvocationMap.Consume(stateBag, functionResult.CallId) is { } invocation) + { + if (consentInterrupted) + { + deferredFunctionCall ??= new DeferredLocalMcpCall( + invocation.FunctionName, + invocation.Arguments); + } + else + { + var contract = new LocalMcpExecutionContract( + invocation.ServerLabel, + invocation.ToolName, + RequiresApproval: true); + var mcpBuilder = stream.AddOutputItemMcpCall( + invocation.ServerLabel, + invocation.ToolName); + LocalMcpCallIdMap.Record( + stateBag, + mcpBuilder.ItemId, + functionResult.CallId, + invocation.FunctionName, + invocation.ToolName, + invocation.ServerLabel); + pendingMcpCall = new PendingMcpCall( + mcpBuilder, + contract, + invocation.Arguments); + + yield return mcpBuilder.EmitAdded(); + yield return mcpBuilder.EmitInProgress(); + yield return mcpBuilder.EmitArgumentsDelta(invocation.Arguments); + yield return mcpBuilder.EmitArgumentsDone(invocation.Arguments); + } + } + + if (pendingMcpCall is not null) + { + _ = LocalMcpInvocationMap.Consume(stateBag, functionResult.CallId); + string output = ConvertFunctionResultToText(functionResult.Result); + bool failed = functionResult.Exception is not null; + yield return failed + ? pendingMcpCall.Builder.EmitFailed() + : pendingMcpCall.Builder.EmitCompleted(); + + var item = new OutputItemMcpToolCall( + pendingMcpCall.Builder.ItemId, + pendingMcpCall.Contract.ServerLabel, + pendingMcpCall.Contract.ToolName, + pendingMcpCall.Arguments) + { + Output = output, + Status = failed ? MCPToolCallStatus.Failed : MCPToolCallStatus.Completed, + }; + if (functionResult.Exception is not null) + { + item.Error["message"] = BinaryData.FromObjectAsJson(functionResult.Exception.Message); + } + + yield return pendingMcpCall.Builder.EmitDone(item); + break; + } + + if (deferredFunctionCall is not null) + { + foreach (var evt in EmitFunctionCall( + stream, + deferredFunctionCall.FunctionName, + functionResult.CallId, + deferredFunctionCall.Arguments)) + { + yield return evt; + } + } + var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result); // Use the SDK's convenience method so the OutputItemFunctionToolCallOutput @@ -436,6 +739,85 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA break; } + case McpServerToolResultContent mcpResult: + { + if (mcpResult.CallId is not { Length: > 0 }) + { + break; + } + + foreach (var evt in CloseCurrentMessage(currentMessageBuilder, currentTextBuilder, accumulatedText, accumulatedAnnotations)) + { + yield return evt; + } + + currentTextBuilder = null; + currentMessageBuilder = null; + accumulatedText = null; + accumulatedAnnotations = null; + previousMessageId = null; + + if (!pendingMcpCalls.Remove(mcpResult.CallId, out PendingMcpCall? pendingMcpCall)) + { + var approvalEntry = ToolApprovalIdMap.ConsumeMcpEntryByCallId( + stateBag, + mcpResult.CallId, + isMcpToolCall: true); + if (approvalEntry?.Approved is false) + { + break; + } + + if (approvalEntry is + { + Approved: true, + McpServerLabel: { Length: > 0 } serverLabel, + Name: { Length: > 0 } name + }) + { + string toolName = approvalEntry.McpToolName ?? name; + string arguments = approvalEntry.Arguments ?? "{}"; + var contract = new LocalMcpExecutionContract( + serverLabel, + toolName, + RequiresApproval: true); + var mcpBuilder = stream.AddOutputItemMcpCall(serverLabel, toolName); + NativeMcpCallIdMap.Record( + stateBag, + mcpBuilder.ItemId, + mcpResult.CallId, + toolName, + serverLabel); + pendingMcpCall = new PendingMcpCall(mcpBuilder, contract, arguments); + + yield return mcpBuilder.EmitAdded(); + yield return mcpBuilder.EmitInProgress(); + yield return mcpBuilder.EmitArgumentsDelta(arguments); + yield return mcpBuilder.EmitArgumentsDone(arguments); + } + } + + if (pendingMcpCall is null) + { + throw new InvalidOperationException( + $"No MCP call was recorded for result '{mcpResult.CallId}'."); + } + + string output = ConvertMcpResultToText(mcpResult.Outputs); + yield return pendingMcpCall.Builder.EmitCompleted(); + var item = new OutputItemMcpToolCall( + pendingMcpCall.Builder.ItemId, + pendingMcpCall.Contract.ServerLabel, + pendingMcpCall.Contract.ToolName, + pendingMcpCall.Arguments) + { + Output = output, + Status = MCPToolCallStatus.Completed, + }; + yield return pendingMcpCall.Builder.EmitDone(item); + break; + } + default: break; } @@ -474,6 +856,22 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA yield return evt; } + foreach (var pending in pendingMcpCalls.Values) + { + yield return pending.Builder.EmitFailed(); + var item = new OutputItemMcpToolCall( + pending.Builder.ItemId, + pending.Contract.ServerLabel, + pending.Contract.ToolName, + pending.Arguments) + { + Output = string.Empty, + Status = MCPToolCallStatus.Failed, + }; + item.Error["message"] = BinaryData.FromObjectAsJson("Tool execution did not produce a result."); + yield return pending.Builder.EmitDone(item); + } + if (!hasTerminalEvent) { yield return stream.EmitCompleted(accumulatedUsage); @@ -727,6 +1125,34 @@ private static IEnumerable EmitWorkflowEvent( } } + private static IEnumerable EmitFunctionCall( + ResponseEventStream stream, + string functionName, + string callId, + string arguments) + { + var builder = stream.AddOutputItemFunctionCall(functionName, callId); + yield return builder.EmitAdded(); + yield return builder.EmitArgumentsDelta(arguments); + yield return builder.EmitArgumentsDone(arguments); + yield return builder.EmitDone(); + } + + private static bool IsConsentInterruptedResult(FunctionResultContent result) + { + if (result.Result is not string text + || McpConsentContext.Current.Value is not + { + HasPendingConsents: true, + } consentState) + { + return false; + } + + return string.Equals(text, McpConsentResults.ConsentRequired, StringComparison.Ordinal) + || string.Equals(text, consentState.InterruptedToolResult, StringComparison.Ordinal); + } + /// /// Generates a valid item ID matching the SDK's {prefix}_{50chars} format. /// @@ -760,9 +1186,14 @@ private static string GenerateItemId(string prefix) /// [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call result payload.")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call result payload.")] - internal static string EncodeFunctionResultAsJsonStringPayload(object? result) + internal static string EncodeFunctionResultAsJsonStringPayload(object? result) => + JsonSerializer.Serialize(ConvertFunctionResultToText(result)); + + [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Serializing function call result payload.")] + [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Serializing function call result payload.")] + private static string ConvertFunctionResultToText(object? result) { - string innerText = result switch + return result switch { null => string.Empty, string s => s, @@ -774,7 +1205,23 @@ internal static string EncodeFunctionResultAsJsonStringPayload(object? result) : JsonSerializer.Serialize(jd.RootElement, s_jsonOptions), _ => JsonSerializer.Serialize(result, s_jsonOptions), }; + } - return JsonSerializer.Serialize(innerText, s_jsonOptions); + private static string ConvertMcpResultToText(IList? outputs) + { + if (outputs is not { Count: > 0 }) + { + return string.Empty; + } + + return string.Join( + "\n", + outputs.Select(static output => output switch + { + MeaiTextContent text => text.Text, + DataContent data => data.Uri, + UriContent uri => uri.Uri.ToString(), + _ => output.ToString(), + })); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs index 0aa63d3bd0d..5b0e5e70145 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs @@ -2,6 +2,8 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Runtime.CompilerServices; using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.AI; @@ -29,6 +31,9 @@ internal static class ToolApprovalIdMap /// public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap"; + private const string DecisionIndexStateBagKey = + "Microsoft.Agents.AI.Foundry.Hosting.ToolApprovalIdMap.DecisionIndex"; + /// /// Captures the data needed to reconstruct the original /// on the inbound (response) side. @@ -44,6 +49,11 @@ internal sealed class ApprovalEntry public string CallId { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string? Arguments { get; set; } + public string? McpServerLabel { get; set; } + public string? McpToolName { get; set; } + public bool IsMcpToolCall { get; set; } + public bool? Approved { get; set; } + public bool Consumed { get; set; } } /// @@ -73,7 +83,16 @@ public static string ComputeWireId(string afRequestId) /// without those fields the entry cannot be used to faithfully reconstruct /// the original on the inbound side. /// - public static void Record(AgentSessionStateBag? stateBag, string wireId, string afRequestId, string? callId, string? name, string? argumentsJson) + public static void Record( + AgentSessionStateBag? stateBag, + string wireId, + string afRequestId, + string? callId, + string? name, + string? argumentsJson, + string? mcpServerLabel = null, + string? mcpToolName = null, + bool isMcpToolCall = false) { if (stateBag is null) { @@ -92,10 +111,54 @@ public static void Record(AgentSessionStateBag? stateBag, string wireId, string CallId = callId!, Name = name!, Arguments = argumentsJson, + McpServerLabel = mcpServerLabel, + McpToolName = mcpToolName, + IsMcpToolCall = isMcpToolCall, }; stateBag.SetValue(StateBagKey, map); } + /// + /// Records the caller's decision for an approval request so a later function result can + /// distinguish an executed MCP call from a synthetic rejection result. A decision is a + /// one-time capability: duplicate or conflicting responses are rejected. + /// + /// The decided approval entry. + /// + /// The state bag or approval mapping is unavailable, or the request was already decided. + /// + public static ApprovalEntry RecordDecision(AgentSessionStateBag? stateBag, string wireId, bool approved) + { + if (stateBag is null) + { + throw new InvalidOperationException( + $"No approval mapping recorded for wire id '{wireId}' because no agent session state bag is available."); + } + + var map = LoadMap(stateBag); + if (!map.TryGetValue(wireId, out var entry)) + { + throw new InvalidOperationException($"No approval mapping recorded for wire id '{wireId}'."); + } + + if (entry.Approved is not null) + { + throw new InvalidOperationException($"Approval request '{wireId}' has already been decided."); + } + + entry.Approved = approved; + stateBag.SetValue(StateBagKey, map); + if (entry.McpServerLabel is not null) + { + var decisionIndex = stateBag.GetValue>(DecisionIndexStateBagKey) + ?? new Dictionary(StringComparer.Ordinal); + decisionIndex[DecisionIndexKey(entry.CallId, entry.IsMcpToolCall)] = wireId; + stateBag.SetValue(DecisionIndexStateBagKey, decisionIndex); + } + + return entry; + } + /// /// Looks up the AF request id for a given wire id. Returns the wire id verbatim /// when no mapping is present. @@ -126,10 +189,106 @@ public static string Resolve(AgentSessionStateBag? stateBag, string wireId) return null; } + /// + /// Consumes the decided MCP approval entry for an original tool call id. + /// + /// + /// The consumed entry, or when the call has no decided MCP approval. + /// + /// The approval result was already consumed. + public static ApprovalEntry? ConsumeMcpEntryByCallId( + AgentSessionStateBag? stateBag, + string callId, + bool isMcpToolCall) + { + if (!TryLoadMap(stateBag, out var map)) + { + return null; + } + + string indexKey = DecisionIndexKey(callId, isMcpToolCall); + if (stateBag.GetValue>(DecisionIndexStateBagKey) + ?.TryGetValue(indexKey, out string? wireId) is true + && map.TryGetValue(wireId, out ApprovalEntry? indexedEntry) + && IsMatchingMcpEntry(indexedEntry, callId, isMcpToolCall)) + { + return ConsumeEntry(stateBag, map, indexedEntry, callId); + } + + ApprovalEntry? current = null; + bool hasConsumed = false; + foreach (var entry in map.Values) + { + if (!IsMatchingMcpEntry(entry, callId, isMcpToolCall)) + { + continue; + } + + if (entry.Consumed) + { + hasConsumed = true; + } + else if (entry.Approved is not null) + { + // Preserve compatibility with sessions written before the decision index existed. + // Dictionary serialization retains insertion order, so the last decided entry is current. + current = entry; + } + } + + if (current is not null) + { + return ConsumeEntry(stateBag!, map, current, callId); + } + + if (hasConsumed) + { + throw new InvalidOperationException( + $"Approval result for function call '{callId}' has already been consumed."); + } + + return null; + } + + private static string DecisionIndexKey(string callId, bool isMcpToolCall) => + $"{(isMcpToolCall ? "native" : "local")}:{callId}"; + + private static bool IsMatchingMcpEntry( + ApprovalEntry entry, + string callId, + bool isMcpToolCall) => + entry.McpServerLabel is not null + && entry.IsMcpToolCall == isMcpToolCall + && string.Equals(entry.CallId, callId, StringComparison.Ordinal); + + private static ApprovalEntry? ConsumeEntry( + AgentSessionStateBag stateBag, + Dictionary map, + ApprovalEntry entry, + string callId) + { + if (entry.Approved is null) + { + return null; + } + + if (entry.Consumed) + { + throw new InvalidOperationException( + $"Approval result for function call '{callId}' has already been consumed."); + } + + entry.Consumed = true; + stateBag.SetValue(StateBagKey, map); + return entry; + } + private static Dictionary LoadMap(AgentSessionStateBag stateBag) => TryLoadMap(stateBag, out var map) ? map : new Dictionary(StringComparer.Ordinal); - private static bool TryLoadMap(AgentSessionStateBag? stateBag, out Dictionary map) + private static bool TryLoadMap( + [NotNullWhen(true)] AgentSessionStateBag? stateBag, + out Dictionary map) { if (stateBag is null) { @@ -144,3 +303,186 @@ private static bool TryLoadMap(AgentSessionStateBag? stateBag, out Dictionary +/// Persists the caller-facing MCP item id for a locally executed function-shaped MCP call so +/// AgentServer history can be restored to the original function call/result transcript. +/// +internal static class LocalMcpCallIdMap +{ + public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.LocalMcpCallIdMap"; + + internal sealed class CallEntry + { + public string CallId { get; set; } = string.Empty; + public string FunctionName { get; set; } = string.Empty; + public string ToolName { get; set; } = string.Empty; + public string ServerLabel { get; set; } = string.Empty; + } + + public static void Record( + AgentSessionStateBag? stateBag, + string wireId, + string callId, + string functionName, + string toolName, + string serverLabel) + { + if (stateBag is null) + { + return; + } + + var map = LoadMap(stateBag); + map[wireId] = new CallEntry + { + CallId = callId, + FunctionName = functionName, + ToolName = toolName, + ServerLabel = serverLabel, + }; + stateBag.SetValue(StateBagKey, map); + } + + public static CallEntry? ResolveEntry(AgentSessionStateBag? stateBag, string wireId) + { + if (stateBag?.GetValue>(StateBagKey)?.TryGetValue(wireId, out var entry) is true) + { + return entry; + } + + return null; + } + + private static Dictionary LoadMap(AgentSessionStateBag stateBag) => + stateBag.GetValue>(StateBagKey) + ?? new Dictionary(StringComparer.Ordinal); +} + +/// +/// Persists the caller-facing MCP item id for an MCP call that was already represented as native +/// MCP content inside the agent, preserving its original correlation id across hosted history replay. +/// +internal static class NativeMcpCallIdMap +{ + public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.NativeMcpCallIdMap"; + + internal sealed class CallEntry + { + public string CallId { get; set; } = string.Empty; + public string ToolName { get; set; } = string.Empty; + public string ServerLabel { get; set; } = string.Empty; + } + + public static void Record( + AgentSessionStateBag? stateBag, + string wireId, + string callId, + string toolName, + string serverLabel) + { + if (stateBag is null) + { + return; + } + + var map = stateBag.GetValue>(StateBagKey) + ?? new Dictionary(StringComparer.Ordinal); + map[wireId] = new CallEntry + { + CallId = callId, + ToolName = toolName, + ServerLabel = serverLabel, + }; + stateBag.SetValue(StateBagKey, map); + } + + public static CallEntry? ResolveEntry(AgentSessionStateBag? stateBag, string wireId) + { + if (stateBag?.GetValue>(StateBagKey)?.TryGetValue(wireId, out var entry) is true) + { + return entry; + } + + return null; + } +} + +/// +/// Preserves locally executed MCP call metadata when approval middleware handles the call entirely +/// inside the agent and removes the call/approval request before Hosting observes the stream. +/// +internal static class LocalMcpInvocationMap +{ + public const string StateBagKey = "Microsoft.Agents.AI.Foundry.Hosting.LocalMcpInvocationMap"; + + private static readonly ConditionalWeakTable s_gates = new(); + + internal sealed class CallEntry + { + public string FunctionName { get; set; } = string.Empty; + public string ToolName { get; set; } = string.Empty; + public string ServerLabel { get; set; } = string.Empty; + public string Arguments { get; set; } = "{}"; + } + + public static void Record( + AgentSessionStateBag stateBag, + string callId, + string functionName, + string toolName, + string serverLabel, + string arguments) + { + lock (GetGate(stateBag)) + { + // Copy before mutation so session serialization never observes a dictionary + // being changed by another concurrently invoked tool. + var map = stateBag.GetValue>(StateBagKey) is { } existing + ? new Dictionary(existing, StringComparer.Ordinal) + : new Dictionary(StringComparer.Ordinal); + map[callId] = new CallEntry + { + FunctionName = functionName, + ToolName = toolName, + ServerLabel = serverLabel, + Arguments = arguments, + }; + stateBag.SetValue(StateBagKey, map); + } + } + + public static CallEntry? Consume(AgentSessionStateBag? stateBag, string callId) + { + if (stateBag is null) + { + return null; + } + + lock (GetGate(stateBag)) + { + Dictionary? existing = + stateBag.GetValue>(StateBagKey); + if (existing is null || !existing.TryGetValue(callId, out CallEntry? entry)) + { + return null; + } + + var map = new Dictionary(existing, StringComparer.Ordinal); + _ = map.Remove(callId); + if (map.Count == 0) + { + stateBag.TryRemoveValue(StateBagKey); + } + else + { + stateBag.SetValue(StateBagKey, map); + } + + return entry; + } + } + + private static object GetGate(AgentSessionStateBag stateBag) => + s_gates.GetValue(stateBag, static _ => new object()); +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs index 1e31a665853..14f877d0cf5 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs @@ -1,5 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. +#pragma warning disable MEAI001 // HostedMcpServerTool is experimental + using System.ComponentModel; using Azure; using Azure.AI.Extensions.OpenAI; @@ -40,6 +42,7 @@ "tool-calling" => CreateToolCallingAgent(projectClient, deployment), "tool-calling-approval" => CreateToolCallingApprovalAgent(projectClient, deployment), "mcp-toolbox" => CreateMcpToolboxAgent(projectClient, deployment), + "mcp-hosted" => CreateHostedMcpAgent(projectClient, deployment), "toolbox-oauth-consent" => CreateToolboxOAuthConsentAgent(projectClient, deployment), "custom-storage" => CreateCustomStorageAgent(projectClient, deployment), "memory" => await CreateMemoryAgentAsync(projectClient, deployment).ConfigureAwait(false), @@ -146,12 +149,30 @@ static AIAgent CreateToolCallingApprovalAgent(AIProjectClient client, string dep ]); static AIAgent CreateMcpToolboxAgent(AIProjectClient client, string deployment) => - // TODO: wire MCP toolbox client to https://learn.microsoft.com/api/mcp. + // FoundryToolboxService injects the tools from IT_TOOLBOX_NAME at request time. client.AsAIAgent( model: deployment, - instructions: "You are an assistant with access to Microsoft Learn documentation via MCP.", + instructions: "You have GitHub tools through a Foundry Toolbox. Use get_me for the current GitHub user.", name: "mcp-toolbox-agent", - description: "MCP toolbox test agent (placeholder)."); + description: "Container-local Foundry Toolbox MCP contract test agent."); + +static AIAgent CreateHostedMcpAgent(AIProjectClient client, string deployment) +{ + AITool hostedMcp = new HostedMcpServerTool( + serverName: "microsoft_learn_hosted", + serverAddress: "https://learn.microsoft.com/api/mcp") + { + AllowedTools = ["microsoft_docs_search"], + ApprovalMode = HostedMcpServerToolApprovalMode.NeverRequire + }; + + return client.AsAIAgent( + model: deployment, + instructions: "Use microsoft_docs_search for Microsoft documentation.", + name: "mcp-hosted-agent", + description: "Inference-hosted MCP contract test agent.", + tools: [hostedMcp]); +} // toolbox-oauth-consent scenario: a plain agent whose tools come from a pre-registered Foundry // toolbox (wired via AddFoundryToolboxService from IT_TOOLBOX_NAME). The toolbox's tool source requires diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedMcpHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedMcpHostedAgentFixture.cs new file mode 100644 index 00000000000..6fdcc0f0f5b --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedMcpHostedAgentFixture.cs @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft. All rights reserved. + +namespace Foundry.Hosting.IntegrationTests.Fixtures; + +/// +/// Provisions a hosted agent that runs the test container in IT_SCENARIO=mcp-hosted mode. +/// The inference provider executes the public Microsoft Learn MCP tool so the test can verify the +/// caller-facing native MCP contract independently from locally executed functions. +/// +public sealed class HostedMcpHostedAgentFixture : HostedAgentFixture +{ + protected override string ScenarioName => "mcp-hosted"; +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/McpToolboxHostedAgentFixture.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/McpToolboxHostedAgentFixture.cs index f74be87c457..fbe53fb2b51 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/McpToolboxHostedAgentFixture.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/McpToolboxHostedAgentFixture.cs @@ -1,13 +1,30 @@ // Copyright (c) Microsoft. All rights reserved. +using System; +using System.Collections.Generic; + namespace Foundry.Hosting.IntegrationTests.Fixtures; /// /// Provisions a hosted agent that runs the test container in IT_SCENARIO=mcp-toolbox mode. -/// The container connects to a public MCP server (the Microsoft Learn MCP endpoint) so tests -/// can verify MCP tool discovery and invocation flowing through the Foundry hosted agent. +/// The container pre-registers a Foundry Toolbox whose PAT-backed GitHub MCP tool can run without +/// user consent, allowing tests to verify the caller-facing MCP execution contract. /// public sealed class McpToolboxHostedAgentFixture : HostedAgentFixture { + private const string ToolboxNameEnvironmentVariable = "IT_TOOLBOX_NAME"; + private const string DefaultToolboxName = "auth-paths-pat-toolbox"; + protected override string ScenarioName => "mcp-toolbox"; + + /// + /// Gets the Foundry Toolbox pre-registered in the hosted test container. + /// + public string ToolboxName { get; } = + Environment.GetEnvironmentVariable(ToolboxNameEnvironmentVariable) ?? DefaultToolboxName; + + protected override void ConfigureEnvironment(IDictionary environment) + { + environment[ToolboxNameEnvironmentVariable] = this.ToolboxName; + } } diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedMcpHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedMcpHostedAgentTests.cs new file mode 100644 index 00000000000..b83df042576 --- /dev/null +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/HostedMcpHostedAgentTests.cs @@ -0,0 +1,48 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Foundry.Hosting.IntegrationTests.Fixtures; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 // Experimental Responses API surfaces + +namespace Foundry.Hosting.IntegrationTests; + +/// +/// Tests that inference-hosted MCP execution is preserved as a caller-facing MCP contract. +/// +[Trait("Category", "FoundryHostedAgents")] +public sealed class HostedMcpHostedAgentTests(HostedMcpHostedAgentFixture fixture) + : IClassFixture +{ + private readonly HostedMcpHostedAgentFixture _fixture = fixture; + + [Fact] + public async Task InferenceHostedMcpExecution_UsesMcpCallerContractAsync() + { + // Arrange + ResponsesClient responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient(); + CreateResponseOptions options = ProjectResponsesTestOptions.Create(); + options.StoredOutputEnabled = false; + options.InputItems.Add(ResponseItem.CreateUserMessageItem( + "Use microsoft_docs_search to find the Microsoft Learn page about Agent Framework hosted agents. " + + "Reply with one short fact.")); + using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(3)); + + // Act + ResponseResult response = (await responses.CreateResponseAsync(options, timeout.Token)).Value; + + // Assert + Assert.Equal(ResponseStatus.Completed, response.Status); + McpToolCallItem mcpCall = Assert.Single(response.OutputItems.OfType()); + Assert.Equal("microsoft_learn_hosted", mcpCall.ServerLabel); + Assert.Equal("microsoft_docs_search", mcpCall.ToolName); + Assert.False(string.IsNullOrWhiteSpace(mcpCall.ToolOutput)); + Assert.DoesNotContain(response.OutputItems, item => + item is FunctionCallResponseItem or FunctionCallOutputResponseItem); + Assert.False(string.IsNullOrWhiteSpace(response.GetOutputText())); + } +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/McpToolboxHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/McpToolboxHostedAgentTests.cs index c73805f618c..15141c7cb3c 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/McpToolboxHostedAgentTests.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/McpToolboxHostedAgentTests.cs @@ -1,15 +1,19 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Linq; +using System.Text.Json; +using System.Threading; using System.Threading.Tasks; using Foundry.Hosting.IntegrationTests.Fixtures; -using Microsoft.Extensions.AI; +using OpenAI.Responses; + +#pragma warning disable OPENAI001 // Experimental Responses API surfaces namespace Foundry.Hosting.IntegrationTests; /// -/// Tests for an MCP backed toolbox: the hosted container connects to a public MCP server -/// (the Microsoft Learn MCP endpoint) at startup and exposes its tools to the model. +/// Tests that a container-local Foundry Toolbox execution uses the caller-facing MCP contract. /// [Trait("Category", "FoundryHostedAgents")] public sealed class McpToolboxHostedAgentTests(McpToolboxHostedAgentFixture fixture) @@ -17,44 +21,42 @@ public sealed class McpToolboxHostedAgentTests(McpToolboxHostedAgentFixture fixt { private readonly McpToolboxHostedAgentFixture _fixture = fixture; - [Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")] - public async Task McpTool_IsInvokedSuccessfullyAsync() - { - // Arrange - var agent = this._fixture.Agent; - - // Act - var response = await agent.RunAsync("Use the Microsoft Learn MCP tool to look up 'Microsoft Foundry'. Reply with one short paragraph."); - - // Assert - Assert.False(string.IsNullOrWhiteSpace(response.Text)); - Assert.True(response.Messages.Any(m => m.Contents.OfType().Any()), - "Expected at least one MCP tool invocation in the response messages."); - } - - [Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")] - public async Task McpTool_WithStructuredArguments_ReturnsValidResultAsync() + [Fact] + public async Task ToolboxExecution_UsesMcpCallerContractAsync() { // Arrange - var agent = this._fixture.Agent; + ResponsesClient responses = this._fixture.AgentOpenAIClient.GetProjectResponsesClient(); + CreateResponseOptions options = ProjectResponsesTestOptions.Create(); + options.StoredOutputEnabled = false; + options.InputItems.Add(ResponseItem.CreateUserMessageItem( + "Call the GitHub get_me tool from the toolbox and report only the returned login.")); + using CancellationTokenSource timeout = new(TimeSpan.FromMinutes(3)); // Act - var response = await agent.RunAsync("Use the MCP search tool with the query 'agent framework hosted agents'. Reply with at least one fact."); + ResponseResult response = (await responses.CreateResponseAsync(options, timeout.Token)).Value; // Assert - Assert.False(string.IsNullOrWhiteSpace(response.Text)); + Assert.Equal(ResponseStatus.Completed, response.Status); + McpToolCallItem mcpCall = Assert.Single(response.OutputItems.OfType()); + Assert.Equal(this._fixture.ToolboxName, mcpCall.ServerLabel); + Assert.EndsWith("___get_me", mcpCall.ToolName, StringComparison.Ordinal); + Assert.False(string.IsNullOrWhiteSpace(mcpCall.ToolOutput)); + string login = GetGitHubLogin(mcpCall.ToolOutput); + Assert.DoesNotContain(response.OutputItems, item => + item is FunctionCallResponseItem or FunctionCallOutputResponseItem); + Assert.Contains(login, response.GetOutputText(), StringComparison.OrdinalIgnoreCase); } - [Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")] - public async Task McpTool_ProducesUsableResponseAsync() + private static string GetGitHubLogin(string toolOutput) { - // Arrange - var agent = this._fixture.Agent; - - // Act - var response = await agent.RunAsync("Tell me one thing about Microsoft Foundry that would only be in MS Learn docs."); - - // Assert - Assert.False(string.IsNullOrWhiteSpace(response.Text)); + using JsonDocument document = JsonDocument.Parse(toolOutput); + if (document.RootElement.TryGetProperty("structuredContent", out JsonElement structuredContent) + && structuredContent.TryGetProperty("login", out JsonElement login) + && login.GetString() is { Length: > 0 } value) + { + return value; + } + + throw new InvalidOperationException("The get_me MCP result did not contain structuredContent.login."); } } diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md index 1b5bedf84b2..cf0b0923d0a 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/README.md @@ -182,6 +182,18 @@ To self-serve the `Search Index Data Reader` grant above, you need `User Access need `Search Index Data Contributor`. These are typically granted once per onboarded engineer and reused for every new IT scenario that needs Search. +### Toolbox MCP contract prerequisite (one time, out of band) + +The `mcp-toolbox` scenario assumes a Foundry **toolbox** named by `IT_TOOLBOX_NAME` +(default `auth-paths-pat-toolbox`) exists in the target project. It must expose the GitHub MCP +`get_me` tool without approval or user consent. The test invokes that tool and verifies the hosted +Responses caller receives one `McpToolCallItem` with inline output, with no function-call items. + +The separate `mcp-hosted` scenario configures the public Microsoft Learn endpoint as an +inference-hosted MCP server. It requires no out-of-band resource and verifies +`microsoft_docs_search` uses the same caller contract without mixing local and hosted execution +in one model request. + ### OAuth consent toolbox prerequisite (one time, out of band) The `toolbox-oauth-consent` scenario assumes a Foundry **toolbox** named by `IT_TOOLBOX_NAME` @@ -282,7 +294,8 @@ human-only operation; CI only adds and deletes versions under existing agents. | `DownstreamStoreHostedAgentFixture` | `downstream-store` | `it-downstream-store` | An ordinary Foundry `ChatClientAgent` that reports back which conversation its own run left behind on the service, so the test can assert the container does not keep a second copy of a turn the platform already recorded. | | `ToolCallingHostedAgentFixture` | `tool-calling` | `it-tool-calling` | Server side AIFunction invocation; arguments; multi turn referencing prior tool result. | | `ToolCallingApprovalHostedAgentFixture` | `tool-calling-approval` | `it-tool-calling-approval` | Approval requests raised, approved, denied. | -| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | MCP backed tool invocation against `https://learn.microsoft.com/api/mcp` (placeholder). | +| `McpToolboxHostedAgentFixture` | `mcp-toolbox` | `it-mcp-toolbox` | Foundry Toolbox execution through `auth-paths-pat-toolbox`; verifies the raw Responses caller receives one MCP call item with inline output rather than function-call items. | +| `HostedMcpHostedAgentFixture` | `mcp-hosted` | `it-mcp-hosted` | Inference-hosted Microsoft Learn MCP; verifies native MCP call/result content reaches the raw Responses caller as one MCP call item with inline output. | | `ToolboxOAuthConsentHostedAgentFixture` | `toolbox-oauth-consent` | `it-toolbox-oauth-consent` | Per-user OAuth toolbox consent: pre-registers a consent-gated Foundry toolbox (`IT_TOOLBOX_NAME`), invokes the agent, asserts the consumer captures an `oauth_consent_request` consent link (and the container stays routable, no 424). Requires a consent-gated toolbox in the project (see prerequisite below). | | `CustomStorageHostedAgentFixture` | `custom-storage` | `it-custom-storage` | Round trip with custom `IResponsesStorageProvider`; multi turn reads from the custom store (placeholder). | | `MemoryHostedAgentFixture` | `memory` | `it-memory` | `FoundryMemoryProvider` (scoped via `HostedSessionContext`) running inside the hosted agent recalls user preferences across multiple turns; the memory store name is randomised per fixture (`IT_MEMORY_STORE_ID`). | diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 index 384c4361ea3..35044438de8 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/scripts/it-bootstrap-agents.ps1 @@ -46,6 +46,7 @@ $Scenarios = @( 'tool-calling', 'tool-calling-approval', 'mcp-toolbox', + 'mcp-hosted', 'toolbox-oauth-consent', 'custom-storage', 'memory', diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs index a0e861cef4d..6dafaeb6425 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs @@ -6,13 +6,17 @@ using System.Text.Json.Nodes; using System.Threading; using System.Threading.Tasks; +using Azure.AI.AgentServer.Responses; +using Azure.AI.AgentServer.Responses.Models; using Azure.Core; +using Microsoft.Agents.AI.Hosting; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Moq; namespace Microsoft.Agents.AI.Foundry.Hosting.UnitTests; @@ -127,6 +131,10 @@ public async Task DiscoveredTool_ApprovalMetadataIsAppliedAsync(string? metadata // Assert Assert.Equal(requiresApproval, tool.GetService() is not null); + var toolboxFunction = Assert.IsType( + tool.GetService()); + Assert.Equal("weather", toolboxFunction.ToolboxName); + Assert.Equal("get_weather", toolboxFunction.ToolName); Assert.Equal("get_weather", tool.Name); Assert.Equal("Gets the weather", tool.Description); Assert.True(tool.JsonSchema.GetProperty("properties").TryGetProperty("city", out _)); @@ -227,6 +235,386 @@ await Assert.ThrowsAnyAsync( } } + [Fact] + public async Task MarkedLocalMcpExecution_Cancellation_RemovesInvocationMarkerAsync() + { + // Arrange + var toolStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + AIFunction tool = new ApprovalRequiredAIFunction( + AIFunctionFactory.Create( + async (CancellationToken cancellationToken) => + { + toolStarted.TrySetResult(); + await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); + return "unreachable"; + }, + name: "local_search") + .AsLocalMcpTool(serverLabel: "local-mcp")); + var client = new Mock(); + client.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("call_cancelled", tool.Name)]))); + AIAgent agent = new ChatClientAgent(client.Object, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions { Tools = [tool] }, + }) + .AsBuilder() + .UseToolApproval(new ToolApprovalAgentOptions + { + AutoApprovalRules = [_ => new ValueTask(true)] + }) + .Build(); + AgentSession session = await agent.CreateSessionAsync(); + using var cancellation = new CancellationTokenSource(); + + // Act + Task execution = agent.RunAsync( + [new ChatMessage(ChatRole.User, "Search locally")], + session, + cancellationToken: cancellation.Token); + await toolStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync( + async () => await execution.WaitAsync(TimeSpan.FromSeconds(5))); + + // Assert + Assert.Null(LocalMcpInvocationMap.Consume(session.StateBag, "call_cancelled")); + } + + [Fact] + public async Task ToolboxExecution_Cancellation_RemovesInvocationMarkerAsync() + { + // Arrange + await using var toolbox = await ToolboxServer.CreateAsync("""{"tool_configuration":{"require_approval":"always"}}"""); + toolbox.BlockCalls = true; + await using var service = toolbox.CreateService(); + await service.StartAsync(CancellationToken.None); + AIFunction tool = Assert.IsAssignableFrom(Assert.Single(service.Tools)); + var client = new Mock(); + client.Setup(c => c.GetResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(new ChatResponse(new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent( + "call_cancelled", + tool.Name, + new Dictionary { ["city"] = "Amsterdam" })]))); + AIAgent agent = new ChatClientAgent(client.Object, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions { Tools = [tool] }, + }) + .AsBuilder() + .UseToolApproval(new ToolApprovalAgentOptions + { + AutoApprovalRules = [_ => new ValueTask(true)] + }) + .Build(); + AgentSession session = await agent.CreateSessionAsync(); + using var cancellation = new CancellationTokenSource(); + + // Act + Task execution = agent.RunAsync( + [new ChatMessage(ChatRole.User, "Get the weather")], + session, + cancellationToken: cancellation.Token); + await toolbox.CallStarted.Task.WaitAsync(TimeSpan.FromSeconds(5)); + cancellation.Cancel(); + await Assert.ThrowsAnyAsync( + async () => await execution.WaitAsync(TimeSpan.FromSeconds(5))); + + // Assert + Assert.Null(LocalMcpInvocationMap.Consume(session.StateBag, "call_cancelled")); + } + + [Fact] + public async Task ResponseHandler_ToolboxExecution_EmitsMcpCallContractAsync() + { + // Arrange + await using var toolbox = await ToolboxServer.CreateAsync(metadata: null); + await using var toolboxService = toolbox.CreateService(); + await toolboxService.StartAsync(CancellationToken.None); + var callCount = 0; + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns((IEnumerable _, ChatOptions? options, CancellationToken _) => + { + var tool = Assert.IsAssignableFrom(Assert.Single(options!.Tools!)); + var response = ++callCount == 1 + ? new ChatResponse(new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent( + "call_weather", + tool.Name, + new Dictionary { ["city"] = "Amsterdam" })])) + : new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")); + return ToUpdatesAsync(response); + }); + + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(new ChatClientAgent(client.Object)); + var handler = new AgentFrameworkResponseHandler( + services.BuildServiceProvider(), + NullLogger.Instance, + new FoundryResponsesServerOptions(), + toolboxService); + var request = new CreateResponse { Model = "test" }; + request.Input = BinaryData.FromObjectAsJson(new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Get the weather" } } + } + }); + var context = new Mock("resp_" + new string('8', 46)) { CallBase = true }; + context.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + context.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var responseEvent in handler.CreateAsync(request, context.Object, CancellationToken.None)) + { + events.Add(responseEvent); + } + + // Assert + var mcpCall = Assert.IsType( + Assert.Single(events.OfType(), e => e.Item is OutputItemMcpToolCall).Item); + Assert.Equal("weather", mcpCall.ServerLabel); + Assert.Equal("get_weather", mcpCall.Name); + Assert.Contains("Sunny", mcpCall.Output, StringComparison.Ordinal); + Assert.Equal(1, toolbox.InvocationCount); + Assert.DoesNotContain( + events.OfType(), + e => e.Item is OutputItemFunctionToolCall or OutputItemFunctionToolCallOutput); + } + + [Fact] + public async Task ResponseHandler_MarkedLocalMcpExecution_EmitsMcpCallContractAsync() + { + // Arrange + AIFunction localMcpTool = AIFunctionFactory + .Create(() => "local MCP result", name: "local_search") + .AsLocalMcpTool(serverLabel: "local-mcp"); + var callCount = 0; + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(() => ToUpdatesAsync(++callCount == 1 + ? new ChatResponse(new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("call_local_mcp", "local_search")])) + : new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")))); + var agent = new ChatClientAgent(client.Object, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions { Tools = [localMcpTool] }, + }); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var handler = new AgentFrameworkResponseHandler( + services.BuildServiceProvider(), + NullLogger.Instance); + var request = new CreateResponse { Model = "test" }; + request.Input = BinaryData.FromObjectAsJson(new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Search locally" } } + } + }); + var context = new Mock("resp_" + new string('a', 46)) { CallBase = true }; + context.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + context.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var responseEvent in handler.CreateAsync(request, context.Object, CancellationToken.None)) + { + events.Add(responseEvent); + } + + // Assert + var mcpCall = Assert.IsType( + Assert.Single(events.OfType(), e => e.Item is OutputItemMcpToolCall).Item); + Assert.Equal("local-mcp", mcpCall.ServerLabel); + Assert.Equal("local_search", mcpCall.Name); + Assert.Equal("local MCP result", mcpCall.Output); + Assert.DoesNotContain( + events.OfType(), + e => e.Item is OutputItemFunctionToolCall or OutputItemFunctionToolCallOutput); + } + + [Fact] + public async Task ResponseHandler_AutoApprovedLocalMcpExecution_EmitsMcpCallContractAsync() + { + // Arrange + AIFunction localMcpTool = new ApprovalRequiredAIFunction( + AIFunctionFactory + .Create(() => "auto-approved MCP result", name: "local_search") + .AsLocalMcpTool(serverLabel: "local-mcp", toolName: "remote_search")); + var callCount = 0; + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(() => ToUpdatesAsync(++callCount == 1 + ? new ChatResponse(new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("call_auto_mcp", "local_search")])) + : new ChatResponse(new ChatMessage(ChatRole.Assistant, "Done")))); + AIAgent agent = new ChatClientAgent(client.Object, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions { Tools = [localMcpTool] }, + }) + .AsBuilder() + .UseToolApproval(new ToolApprovalAgentOptions + { + AutoApprovalRules = [context => new ValueTask( + context.FunctionCallContent.Name == "local_search")] + }) + .Build(); + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var handler = new AgentFrameworkResponseHandler( + services.BuildServiceProvider(), + NullLogger.Instance); + var request = new CreateResponse { Model = "test" }; + request.Input = BinaryData.FromObjectAsJson(new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Search locally" } } + } + }); + var context = new Mock("resp_" + new string('b', 46)) { CallBase = true }; + context.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + context.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + var events = new List(); + await foreach (var responseEvent in handler.CreateAsync(request, context.Object, CancellationToken.None)) + { + events.Add(responseEvent); + } + + // Assert + var mcpCall = Assert.IsType( + Assert.Single(events.OfType(), e => e.Item is OutputItemMcpToolCall).Item); + Assert.Equal("local-mcp", mcpCall.ServerLabel); + Assert.Equal("remote_search", mcpCall.Name); + Assert.Equal("auto-approved MCP result", mcpCall.Output); + Assert.DoesNotContain( + events.OfType(), + e => e.Item is OutputItemFunctionToolCall or OutputItemFunctionToolCallOutput); + } + + [Fact] + public async Task ResponseHandler_ToolboxFunctionNameCollidesWithLocalFunction_RejectsRequestAsync() + { + // Arrange + await using var toolbox = await ToolboxServer.CreateAsync(metadata: null); + await using var toolboxService = toolbox.CreateService(); + await toolboxService.StartAsync(CancellationToken.None); + var localInvocationCount = 0; + AIFunction localTool = AIFunctionFactory.Create( + () => + { + localInvocationCount++; + return "local result"; + }, + name: "get_weather"); + var client = new Mock(); + client.Setup(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny())) + .Returns(ToUpdatesAsync(new ChatResponse(new ChatMessage( + ChatRole.Assistant, + [new FunctionCallContent("call_weather", "get_weather")])))); + var agent = new ChatClientAgent(client.Object, new ChatClientAgentOptions + { + ChatOptions = new ChatOptions { Tools = [localTool] }, + }); + + var services = new ServiceCollection(); + services.AddSingleton(new InMemoryAgentSessionStore()); + services.AddSingleton(agent); + var handler = new AgentFrameworkResponseHandler( + services.BuildServiceProvider(), + NullLogger.Instance, + new FoundryResponsesServerOptions(), + toolboxService); + var request = new CreateResponse { Model = "test" }; + request.Input = BinaryData.FromObjectAsJson(new[] + { + new + { + type = "message", + id = "msg_1", + status = "completed", + role = "user", + content = new[] { new { type = "input_text", text = "Get the weather" } } + } + }); + var context = new Mock("resp_" + new string('9', 46)) { CallBase = true }; + context.Setup(x => x.GetHistoryAsync(It.IsAny())) + .ReturnsAsync(Array.Empty()); + context.Setup(x => x.GetInputItemsAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(Array.Empty()); + + // Act + async Task InvokeAsync() + { + await foreach (var _ in handler.CreateAsync(request, context.Object, CancellationToken.None)) + { + // Intentionally empty: enumerate the stream to trigger handler validation. + } + } + + var exception = await Assert.ThrowsAsync(InvokeAsync); + + // Assert + Assert.Contains("collides", exception.Message, StringComparison.Ordinal); + Assert.Equal(0, localInvocationCount); + Assert.Equal(0, toolbox.InvocationCount); + client.Verify(c => c.GetStreamingResponseAsync( + It.IsAny>(), + It.IsAny(), + It.IsAny()), Times.Never); + } + private static async Task RunAsync( AIAgent agent, AgentSession session, IEnumerable messages, bool streaming) { @@ -253,6 +641,9 @@ private sealed class ToolboxServer(WebApplication app) : IAsyncDisposable internal JsonNode? LastArguments { get; private set; } internal JsonObject? ListError { get; set; } internal JsonObject? CallError { get; set; } + internal bool BlockCalls { get; set; } + internal TaskCompletionSource CallStarted { get; } = + new(TaskCreationOptions.RunContinuationsAsynchronously); internal static async Task CreateAsync(string? metadata) { @@ -310,6 +701,12 @@ internal static async Task CreateAsync(string? metadata) case "tools/call": server.InvocationCount++; server.LastArguments = request["params"]!["arguments"]!.DeepClone(); + server.CallStarted.TrySetResult(); + if (server.BlockCalls) + { + await Task.Delay(Timeout.InfiniteTimeSpan, context.RequestAborted); + } + if (server.CallError is { } callError) { await WriteErrorAsync(context, request["id"]!, callError); diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InputConverterTests.cs index 7173bec2e1e..075cf1d144a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InputConverterTests.cs @@ -249,6 +249,164 @@ public void ConvertOutputItemsToMessages_FunctionToolCallOutput_LegacyRawJsonArr Assert.Equal("[{\"id\":1}]", result.Result as string); } + [Fact] + public void ConvertOutputItemsToMessages_LocalToolboxMcpCall_RestoresFunctionTranscript() + { + // Arrange + var stateBag = new AgentSessionStateBag(); + LocalMcpCallIdMap.Record( + stateBag, + "mcp_wire_1", + "call_inner_1", + "search", + "search", + "agent-tools"); + var mcpCall = new OutputItemMcpToolCall( + "mcp_wire_1", + "agent-tools", + "search", + "{\"q\":\"weather\"}") + { + Output = "sunny", + Status = MCPToolCallStatus.Completed, + }; + + // Act + var messages = InputConverter.ConvertOutputItemsToMessages([mcpCall], stateBag); + + // Assert + Assert.Equal(2, messages.Count); + var functionCall = Assert.IsType(Assert.Single(messages[0].Contents)); + Assert.Equal("call_inner_1", functionCall.CallId); + Assert.Equal("search", functionCall.Name); + Assert.Equal("weather", Assert.IsType(functionCall.Arguments!["q"]).GetString()); + var functionResult = Assert.IsType(Assert.Single(messages[1].Contents)); + Assert.Equal("call_inner_1", functionResult.CallId); + Assert.Equal("sunny", functionResult.Result); + } + + [Fact] + public void ConvertOutputItemsToMessages_ProviderMcpCall_RestoresMcpTranscript() + { + // Arrange + var mcpCall = new OutputItemMcpToolCall( + "mcp_wire_2", + "remote-server", + "search", + "{\"q\":\"weather\"}") + { + Output = "sunny", + Status = MCPToolCallStatus.Completed, + }; + + // Act + var messages = InputConverter.ConvertOutputItemsToMessages([mcpCall]); + + // Assert + Assert.Equal(2, messages.Count); + var call = Assert.IsType(Assert.Single(messages[0].Contents)); + Assert.Equal("mcp_wire_2", call.CallId); + Assert.Equal("remote-server", call.ServerName); + Assert.Equal("search", call.Name); + var result = Assert.IsType(Assert.Single(messages[1].Contents)); + Assert.Equal("mcp_wire_2", result.CallId); + Assert.Equal("sunny", Assert.IsType(Assert.Single(result.Outputs!)).Text); + } + + [Theory] + [InlineData(MCPToolCallStatus.Failed)] + [InlineData(MCPToolCallStatus.Incomplete)] + public void ConvertOutputItemsToMessages_LocalMcpNonSuccess_PreservesFailure( + MCPToolCallStatus status) + { + // Arrange + var stateBag = new AgentSessionStateBag(); + LocalMcpCallIdMap.Record( + stateBag, + "mcp_wire_failure", + "call_failure", + "local_search", + "search", + "local-mcp"); + var mcpCall = new OutputItemMcpToolCall( + "mcp_wire_failure", + "local-mcp", + "search", + "{}") + { + Output = "partial output", + Status = status, + }; + mcpCall.Error["message"] = BinaryData.FromObjectAsJson("tool failed"); + + // Act + var messages = InputConverter.ConvertOutputItemsToMessages([mcpCall], stateBag); + + // Assert + FunctionResultContent result = Assert.IsType( + Assert.Single(messages[1].Contents)); + Assert.NotNull(result.Exception); + Assert.Contains("tool failed", result.Exception!.Message, StringComparison.Ordinal); + } + + [Theory] + [InlineData(MCPToolCallStatus.Failed)] + [InlineData(MCPToolCallStatus.Incomplete)] + public void ConvertOutputItemsToMessages_NativeMcpNonSuccess_PreservesFailure( + MCPToolCallStatus status) + { + // Arrange + var mcpCall = new OutputItemMcpToolCall( + "mcp_native_failure", + "remote-server", + "search", + "{}") + { + Output = "partial output", + Status = status, + }; + mcpCall.Error["message"] = BinaryData.FromObjectAsJson("tool failed"); + + // Act + var messages = InputConverter.ConvertOutputItemsToMessages([mcpCall]); + + // Assert + McpServerToolResultContent result = Assert.IsType( + Assert.Single(messages[1].Contents)); + ErrorContent error = Assert.IsType(Assert.Single(result.Outputs!)); + Assert.Contains("tool failed", error.Message, StringComparison.Ordinal); + } + + [Fact] + public void ConvertOutputItemsToMessages_LocalToolboxMcpCall_MismatchedIdentityThrows() + { + // Arrange + var stateBag = new AgentSessionStateBag(); + LocalMcpCallIdMap.Record( + stateBag, + "mcp_wire_3", + "call_inner_3", + "search", + "search", + "agent-tools"); + var mcpCall = new OutputItemMcpToolCall( + "mcp_wire_3", + "different-server", + "search", + "{}") + { + Output = "sunny", + Status = MCPToolCallStatus.Completed, + }; + + // Act + var exception = Assert.Throws( + () => InputConverter.ConvertOutputItemsToMessages([mcpCall], stateBag)); + + // Assert + Assert.Contains("does not match", exception.Message, StringComparison.Ordinal); + } + [Fact] public void ConvertInputToMessages_FunctionCallOutput_JsonStringPayload_Unwraps() { @@ -800,6 +958,42 @@ public void ConvertItemsToMessages_McpApprovalResponse_ResolvesAfRequestIdFromSt Assert.Equal("issue_refund", fcc.Name); Assert.NotNull(fcc.Arguments); Assert.Equal(123, ((JsonElement)fcc.Arguments!["order_id"]!).GetInt32()); + Assert.False(ToolApprovalIdMap.ResolveEntry(stateBag, wireId)!.Approved); + } + + [Theory] + [InlineData(true, true)] + [InlineData(false, false)] + [InlineData(false, true)] + public void ConvertItemsToMessages_McpApprovalResponse_ReplayedDecisionThrows( + bool firstDecision, + bool replayedDecision) + { + // Arrange + const string AfRequestId = "ficc_call_replay"; + string wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId); + var stateBag = new AgentSessionStateBag(); + ToolApprovalIdMap.Record( + stateBag, + wireId, + AfRequestId, + "call_replay", + "delete_resource", + "{\"target\":\"db\"}", + "agent-tools", + "delete_resource"); + _ = InputConverter.ConvertItemsToMessages( + [new MCPApprovalResponse(approvalRequestId: wireId, approve: firstDecision)], + stateBag); + + // Act + var exception = Assert.Throws(() => + InputConverter.ConvertItemsToMessages( + [new MCPApprovalResponse(approvalRequestId: wireId, approve: replayedDecision)], + stateBag)); + + // Assert + Assert.Contains("already been decided", exception.Message, StringComparison.Ordinal); } [Fact] @@ -833,6 +1027,38 @@ public void ConvertOutputItemsToMessages_McpApprovalRequest_ProducesToolApproval Assert.Equal("delete_file", functionCall.Name); } + [Fact] + public void ConvertOutputItemsToMessages_McpApprovalRequest_CustomPublicName_RestoresLocalFunctionName() + { + // Arrange + const string AfRequestId = "ficc_call_local_search"; + var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId); + var stateBag = new AgentSessionStateBag(); + ToolApprovalIdMap.Record( + stateBag, + wireId, + AfRequestId, + "call_local_search", + "local_search", + """{"query":"weather"}""", + "local-mcp", + "remote_search"); + var item = new OutputItemMcpApprovalRequest( + id: wireId, + serverLabel: "local-mcp", + name: "remote_search", + arguments: """{"query":"weather"}"""); + + // Act + var messages = InputConverter.ConvertOutputItemsToMessages([item], stateBag); + + // Assert + var approval = Assert.IsType(Assert.Single(messages[0].Contents)); + var functionCall = Assert.IsType(approval.ToolCall); + Assert.Equal("local_search", functionCall.Name); + Assert.Equal("call_local_search", functionCall.CallId); + } + [Fact] public void ConvertOutputItemsToMessages_McpApprovalRequest_ResolvesAfRequestFromStateBag() { @@ -953,6 +1179,48 @@ public void ConvertOutputItemsToMessages_McpApprovalResponse_ProducesToolApprova Assert.Equal("delete_file", fcc.Name); } + [Fact] + public void ConvertOutputItemsToMessages_RejectedLocalMcpApproval_RestoresSyntheticResult() + { + // Arrange + const string AfRequestId = "ficc_call_rejected"; + var wireId = ToolApprovalIdMap.ComputeWireId(AfRequestId); + var stateBag = new AgentSessionStateBag(); + ToolApprovalIdMap.Record( + stateBag, + wireId, + AfRequestId, + "call_rejected", + "local_delete", + """{"path":"/tmp/x"}""", + "local-mcp", + "delete"); + ToolApprovalIdMap.RecordDecision(stateBag, wireId, approved: false); + OutputItem[] items = + [ + new OutputItemMcpApprovalRequest( + id: wireId, + serverLabel: "local-mcp", + name: "delete", + arguments: """{"path":"/tmp/x"}"""), + new OutputItemMcpApprovalResponseResource( + id: "ar_rejected", + approvalRequestId: wireId, + approve: false), + ]; + + // Act + var messages = InputConverter.ConvertOutputItemsToMessages(items, stateBag); + + // Assert + FunctionResultContent result = Assert.Single( + messages.SelectMany(message => message.Contents).OfType()); + Assert.Equal("call_rejected", result.CallId); + Assert.Equal( + "Tool call invocation was rejected by the approver and must not be retried.", + result.Result); + } + [Fact] public void ConvertItemsToMessages_McpApprovalRequest_MalformedArguments_PreservesRaw() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj index 7677f6fa44e..587b60460da 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests.csproj @@ -19,6 +19,7 @@ + diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentToolInvocationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentToolInvocationTests.cs index 46b44c704bc..cc17917fc4d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentToolInvocationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OAuthConsentToolInvocationTests.cs @@ -110,10 +110,13 @@ public async Task CreateAsync_AfterConsent_ContinuesSameConversationAsync(bool r } var firstOutput = Assert.IsType(consentTurns[0][^1]).Response.Output; - Assert.Contains(firstOutput.OfType(), o => o.CallId == "call_search" && o.Output.ToString().Contains("discovered", StringComparison.Ordinal)); + var discovered = Assert.Single(firstOutput.OfType(), o => o.Name == "tool_search"); + Assert.Equal("test-toolbox", discovered.ServerLabel); + Assert.Contains("discovered", discovered.Output, StringComparison.Ordinal); AssertPairedCalls(completed.Response.Output); - var actualResult = Assert.Single(completed.Response.Output.OfType()); - Assert.Contains("protected result", actualResult.Output.ToString(), StringComparison.Ordinal); + var actualResult = Assert.Single(completed.Response.Output.OfType(), o => o.Name == "call_tool"); + Assert.Equal("test-toolbox", actualResult.ServerLabel); + Assert.Contains("protected result", actualResult.Output, StringComparison.Ordinal); Assert.DoesNotContain(continuation, e => e is ResponseOutputItemAddedEvent { Item: OAuthConsentRequestOutputItem }); Assert.All(consentTurns.Append(continuation), events => { @@ -879,6 +882,7 @@ private static void ValidateModelInput(IEnumerable messages) OutputItemMessage message => message.Id, OAuthConsentRequestOutputItem consent => consent.Id, OutputItemMcpApprovalRequest approval => approval.Id, + OutputItemMcpToolCall mcpCall => mcpCall.Id, _ => throw new InvalidOperationException("Unexpected output item."), }; diff --git a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs index 29eda0d197c..331d8596271 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs @@ -807,6 +807,405 @@ public async Task ConvertUpdatesToEventsAsync_FunctionCallThenResult_EmitsPaired Assert.IsType(events[^1]); } + [Fact] + public async Task ConvertUpdatesToEventsAsync_ToolboxCallThenResult_EmitsSingleMcpCallAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var stateBag = new AgentSessionStateBag(); + var localMcpFunctions = new Dictionary(StringComparer.Ordinal) + { + ["search"] = new("agent-tools", "search", RequiresApproval: false), + }; + var updates = new[] + { + new AgentResponseUpdate + { + Contents = [new FunctionCallContent( + "call_toolbox", + "search", + new Dictionary { ["q"] = "weather" })] + }, + new AgentResponseUpdate { Contents = [new FunctionResultContent("call_toolbox", "sunny")] }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync( + ToAsync(updates), + stream, + stateBag, + localMcpFunctions)) + { + events.Add(evt); + } + + // Assert + Assert.Single(events.OfType()); + Assert.Single(events.OfType()); + Assert.Single(events.OfType()); + Assert.Single(events.OfType()); + Assert.DoesNotContain( + events.OfType(), + e => e.Item is OutputItemFunctionToolCall or OutputItemFunctionToolCallOutput); + + var added = Assert.IsType( + Assert.Single(events.OfType()).Item); + var done = Assert.IsType( + Assert.Single(events.OfType()).Item); + Assert.Equal(added.Id, done.Id); + Assert.Equal("agent-tools", done.ServerLabel); + Assert.Equal("search", done.Name); + Assert.Equal("{\"q\":\"weather\"}", done.Arguments); + Assert.Equal("sunny", done.Output); + Assert.Equal(MCPToolCallStatus.Completed, done.Status); + + var mapping = Assert.IsType( + LocalMcpCallIdMap.ResolveEntry(stateBag, done.Id)); + Assert.Equal("call_toolbox", mapping.CallId); + Assert.Equal("search", mapping.FunctionName); + Assert.Equal("search", mapping.ToolName); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_NativeMcpCallThenResult_EmitsSingleMcpCallAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var stateBag = new AgentSessionStateBag(); + var updates = new[] + { + new AgentResponseUpdate + { + Contents = + [ + new McpServerToolCallContent("mcp_call_1", "search", "remote-server") + { + Arguments = new Dictionary { ["q"] = "Ереван" }, + } + ] + }, + new AgentResponseUpdate + { + Contents = + [ + new McpServerToolResultContent("mcp_call_1") + { + Outputs = [new MeaiTextContent("sunny")], + } + ] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync( + ToAsync(updates), + stream, + stateBag)) + { + events.Add(evt); + } + + // Assert + Assert.Single(events.OfType()); + Assert.Single(events.OfType()); + var done = Assert.IsType( + Assert.Single(events.OfType()).Item); + Assert.Equal("remote-server", done.ServerLabel); + Assert.Equal("search", done.Name); + Assert.Equal("""{"q":"Ереван"}""", done.Arguments); + Assert.Equal("sunny", done.Output); + Assert.DoesNotContain( + events.OfType(), + e => e.Item is OutputItemFunctionToolCall or OutputItemFunctionToolCallOutput); + + var replayMessages = InputConverter.ConvertOutputItemsToMessages([done], stateBag); + var replayedCall = Assert.IsType( + Assert.Single(replayMessages[0].Contents)); + Assert.Equal("mcp_call_1", replayedCall.CallId); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ParallelToolboxCalls_CompleteIndependentlyAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var localMcpFunctions = new Dictionary(StringComparer.Ordinal) + { + ["search"] = new("agent-tools", "search", RequiresApproval: false), + ["lookup"] = new("agent-tools", "lookup", RequiresApproval: false), + }; + var updates = new[] + { + new AgentResponseUpdate + { + Contents = + [ + new FunctionCallContent( + "call_search", + "search", + new Dictionary { ["q"] = "weather" }), + new FunctionCallContent( + "call_lookup", + "lookup", + new Dictionary { ["id"] = 42 }), + ] + }, + new AgentResponseUpdate + { + Contents = + [ + new FunctionResultContent("call_lookup", "record 42"), + new FunctionResultContent("call_search", "sunny"), + ] + }, + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync( + ToAsync(updates), + stream, + localMcpFunctions: localMcpFunctions)) + { + events.Add(evt); + } + + // Assert + var doneItems = events + .OfType() + .Select(e => Assert.IsType(e.Item)) + .ToArray(); + Assert.Equal(2, doneItems.Length); + Assert.Equal( + [("lookup", "record 42"), ("search", "sunny")], + doneItems.Select(item => (item.Name, item.Output)).ToArray()); + Assert.DoesNotContain( + events.OfType(), + e => e.Item is OutputItemFunctionToolCall or OutputItemFunctionToolCallOutput); + } + + [Fact] + public async Task LocalMcpInvocationMap_ConcurrentRecords_PreservesEveryEntryAsync() + { + const int ConcurrentCallCount = 32; + const int Attempts = 10; + + for (int attempt = 0; attempt < Attempts; attempt++) + { + // Arrange + var stateBag = new AgentSessionStateBag(); + using var ready = new CountdownEvent(ConcurrentCallCount); + using var start = new ManualResetEventSlim(); + Task[] tasks = Enumerable.Range(0, ConcurrentCallCount) + .Select(index => Task.Factory.StartNew( + () => + { + ready.Signal(); + start.Wait(); + LocalMcpInvocationMap.Record( + stateBag, + $"call_{index}", + $"function_{index}", + $"tool_{index}", + "server", + "{}"); + }, + CancellationToken.None, + TaskCreationOptions.LongRunning, + TaskScheduler.Default)) + .ToArray(); + + // Act + bool allWorkersReady = ready.Wait(TimeSpan.FromSeconds(10)); + start.Set(); + Assert.True(allWorkersReady); + await Task.WhenAll(tasks); + + // Assert + for (int index = 0; index < ConcurrentCallCount; index++) + { + Assert.NotNull(LocalMcpInvocationMap.Consume(stateBag, $"call_{index}")); + } + } + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ApprovedToolboxResult_EmitsMcpCallAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var stateBag = new AgentSessionStateBag(); + string wireId = "mcpr_" + "1".PadLeft(50, '0'); + ToolApprovalIdMap.Record( + stateBag, + wireId, + "ficc_call_toolbox", + "call_toolbox", + "search", + "{\"q\":\"weather\"}", + "agent-tools", + "search"); + ToolApprovalIdMap.RecordDecision(stateBag, wireId, approved: true); + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync( + ToAsync([new AgentResponseUpdate + { + Contents = [new FunctionResultContent("call_toolbox", "sunny")] + }]), + stream, + stateBag)) + { + events.Add(evt); + } + + // Assert + var done = Assert.IsType( + Assert.Single(events.OfType()).Item); + Assert.Equal("agent-tools", done.ServerLabel); + Assert.Equal("search", done.Name); + Assert.Equal("sunny", done.Output); + Assert.DoesNotContain( + events.OfType(), + e => e.Item is OutputItemFunctionToolCallOutput); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ApprovedToolboxResult_ReplayedResultThrowsAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var stateBag = new AgentSessionStateBag(); + string wireId = "mcpr_" + "3".PadLeft(50, '0'); + ToolApprovalIdMap.Record( + stateBag, + wireId, + "ficc_call_toolbox", + "call_toolbox", + "search", + "{\"q\":\"weather\"}", + "agent-tools", + "search"); + ToolApprovalIdMap.RecordDecision(stateBag, wireId, approved: true); + + // Act + async Task ConvertAsync() + { + await foreach (var _ in OutputConverter.ConvertUpdatesToEventsAsync( + ToAsync( + [ + new AgentResponseUpdate + { + Contents = [new FunctionResultContent("call_toolbox", "sunny")] + }, + new AgentResponseUpdate + { + Contents = [new FunctionResultContent("call_toolbox", "sunny again")] + }, + ]), + stream, + stateBag)) + { + // Intentionally empty: enumerate all updates to trigger duplicate-result validation. + } + } + + var exception = await Assert.ThrowsAsync(ConvertAsync); + + // Assert + Assert.Contains("already been consumed", exception.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_RejectedToolboxResult_DoesNotEmitMcpCallAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var stateBag = new AgentSessionStateBag(); + string wireId = "mcpr_" + "2".PadLeft(50, '0'); + ToolApprovalIdMap.Record( + stateBag, + wireId, + "ficc_call_toolbox", + "call_toolbox", + "search", + "{\"q\":\"weather\"}", + "agent-tools", + "search"); + ToolApprovalIdMap.RecordDecision(stateBag, wireId, approved: false); + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync( + ToAsync([new AgentResponseUpdate + { + Contents = [new FunctionResultContent("call_toolbox", "Tool call rejected by user.")] + }]), + stream, + stateBag)) + { + events.Add(evt); + } + + // Assert + Assert.DoesNotContain(events, e => e is ResponseOutputItemAddedEvent or ResponseOutputItemDoneEvent); + Assert.IsType(Assert.Single(events)); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_ReusedCallId_UsesLatestDecidedApprovalAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var stateBag = new AgentSessionStateBag(); + string oldWireId = "mcpr_" + "4".PadLeft(50, '0'); + ToolApprovalIdMap.Record( + stateBag, + oldWireId, + "ficc_old", + "call_reused", + "old_search", + "{}", + "old-server", + "old_search"); + ToolApprovalIdMap.RecordDecision(stateBag, oldWireId, approved: false); + + string currentWireId = "mcpr_" + "5".PadLeft(50, '0'); + ToolApprovalIdMap.Record( + stateBag, + currentWireId, + "ficc_current", + "call_reused", + "current_search", + """{"q":"weather"}""", + "current-server", + "current_search"); + ToolApprovalIdMap.RecordDecision(stateBag, currentWireId, approved: true); + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync( + ToAsync([new AgentResponseUpdate + { + Contents = [new FunctionResultContent("call_reused", "sunny")] + }]), + stream, + stateBag)) + { + events.Add(evt); + } + + // Assert + var done = Assert.IsType( + Assert.Single(events.OfType()).Item); + Assert.Equal("current-server", done.ServerLabel); + Assert.Equal("current_search", done.Name); + Assert.Equal("sunny", done.Output); + } + // K-05: An FCC with an empty CallId is dropped without disturbing in-flight text. [Fact] public async Task ConvertUpdatesToEventsAsync_FunctionCallEmptyCallIdMidText_PreservesTextBoundaryAsync() @@ -1499,6 +1898,97 @@ public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequest_EmitsMcpApprov Assert.Equal(AfRequestId, ToolApprovalIdMap.Resolve(stateBag, item.Id)); } + [Fact] + public async Task ConvertUpdatesToEventsAsync_ToolboxApprovalRequest_UsesToolboxMcpIdentityAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var stateBag = new AgentSessionStateBag(); + var localMcpFunctions = new Dictionary(StringComparer.Ordinal) + { + ["delete_resource"] = new("agent-tools", "delete_resource", RequiresApproval: true), + }; + var functionCall = new FunctionCallContent( + "call_1", + "delete_resource", + new Dictionary { ["target"] = "db" }); + var update = new AgentResponseUpdate + { + Contents = + [ + functionCall, + new ToolApprovalRequestContent("af_request_abc", functionCall), + ] + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync( + ToAsync([update]), + stream, + stateBag, + localMcpFunctions)) + { + events.Add(evt); + } + + // Assert + var item = Assert.IsType( + Assert.Single(events.OfType()).Item); + Assert.Equal("agent-tools", item.ServerLabel); + Assert.Equal("delete_resource", item.Name); + Assert.DoesNotContain( + events.OfType(), + e => e.Item is OutputItemMcpToolCall or OutputItemFunctionToolCall); + + var mapping = Assert.IsType( + ToolApprovalIdMap.ResolveEntry(stateBag, item.Id)); + Assert.Equal("agent-tools", mapping.McpServerLabel); + Assert.Equal("delete_resource", mapping.McpToolName); + } + + [Fact] + public async Task ConvertUpdatesToEventsAsync_NativeMcpApprovalRequest_RoundTripsMcpToolCallAsync() + { + // Arrange + var (stream, _) = CreateTestStream(); + var stateBag = new AgentSessionStateBag(); + var toolCall = new McpServerToolCallContent("mcp_call_approval", "delete_resource", "remote-server") + { + Arguments = new Dictionary { ["target"] = "Ереван" }, + }; + var update = new AgentResponseUpdate + { + Contents = [new ToolApprovalRequestContent("mcp_request_approval", toolCall)] + }; + + // Act + var events = new List(); + await foreach (var evt in OutputConverter.ConvertUpdatesToEventsAsync( + ToAsync([update]), + stream, + stateBag)) + { + events.Add(evt); + } + + // Assert + var item = Assert.IsType( + Assert.Single(events.OfType()).Item); + Assert.Equal("remote-server", item.ServerLabel); + Assert.Equal("delete_resource", item.Name); + Assert.Equal("""{"target":"Ереван"}""", item.Arguments); + + var messages = InputConverter.ConvertItemsToMessages( + [new MCPApprovalResponse(item.Id, approve: true)], + stateBag); + var response = Assert.IsType(Assert.Single(messages[0].Contents)); + var replayedCall = Assert.IsType(response.ToolCall); + Assert.Equal("mcp_call_approval", replayedCall.CallId); + Assert.Equal("remote-server", replayedCall.ServerName); + Assert.Equal("delete_resource", replayedCall.Name); + } + [Fact] public async Task ConvertUpdatesToEventsAsync_ToolApprovalRequest_NonFunctionToolCall_SkippedAsync() {