From 1b3af323553e22cb3745f129115ef5ac38d539ef Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 3 Nov 2026 00:27:14 -0800 Subject: [PATCH 1/7] feat(dotnet): preserve hosted MCP contracts Project local and native MCP execution as Responses mcp_call items while preserving each agent's private replay shape. --- .../Hosted-Toolbox-AuthPaths/README.md | 7 + .../responses/Hosted-Toolbox/README.md | 8 + .../AgentFrameworkResponseHandler.cs | 56 ++- .../ConsentAwareMcpClientAIFunction.cs | 43 +- .../FoundryAIToolExtensions.cs | 24 ++ .../InputConverter.cs | 181 +++++++- .../OutputConverter.cs | 358 +++++++++++++++- .../ToolApprovalIdMap.cs | 199 ++++++++- .../ObjectModel/InvokeMcpToolExecutor.cs | 67 ++- .../Program.cs | 27 +- .../Fixtures/HostedMcpHostedAgentFixture.cs | 13 + .../Fixtures/McpToolboxHostedAgentFixture.cs | 21 +- .../HostedMcpHostedAgentTests.cs | 48 +++ .../McpToolboxHostedAgentTests.cs | 59 ++- .../README.md | 15 +- .../scripts/it-bootstrap-agents.ps1 | 1 + .../FoundryToolboxApprovalTests.cs | 218 ++++++++++ .../InputConverterTests.cs | 130 ++++++ .../OutputConverterTests.cs | 392 ++++++++++++++++++ .../ObjectModel/InvokeMcpToolExecutorTest.cs | 35 ++ 20 files changed, 1819 insertions(+), 83 deletions(-) create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/Fixtures/HostedMcpHostedAgentFixture.cs create mode 100644 dotnet/tests/Foundry.Hosting.IntegrationTests/HostedMcpHostedAgentTests.cs 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 5cfed48179c..93ca1a9459b 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/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs index 32324b05b1d..9300a955ee6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/AgentFrameworkResponseHandler.cs @@ -274,7 +274,6 @@ public override async IAsyncEnumerable CreateAsync( agentOptions?.ChatOptions?.RawRepresentationFactory, hostingOptions); chatOptions.Instructions = request.Instructions; - // Inject Foundry Toolbox tools when the toolbox service is available. ResolveToolboxToolsAsync // describes the toolbox sources and returns the tools, the outstanding consents, or an error. if (this._toolboxService is not null) @@ -317,6 +316,8 @@ public override async IAsyncEnumerable CreateAsync( } } + 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 @@ -376,6 +377,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 @@ -843,6 +845,58 @@ await toolboxService : new(Tools: toolsToAdd, Consents: [], Error: null); } + 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 eeb1518d2c8..0802fde44ae 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs @@ -10,6 +10,33 @@ 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); + +/// +/// 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); +} + /// /// An wrapper around that intercepts /// JSON-RPC error -32006 (OAuth consent required) from the Foundry Toolsets proxy and @@ -29,12 +56,13 @@ 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); } public override string Name => this._inner.Name; @@ -47,6 +75,15 @@ 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) @@ -72,7 +109,7 @@ internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName // handler rejects this consent response instead of falling back to the message. _ => string.Empty, }; - state.Pending = new McpConsentInfo(this._toolboxName, this._inner.Name, consentUrl); + state.Pending = new McpConsentInfo(this.ToolboxName, this._inner.Name, consentUrl); state.CancellationSource?.Cancel(); } diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAIToolExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAIToolExtensions.cs index c279e1092b6..37846291935 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAIToolExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/FoundryAIToolExtensions.cs @@ -15,6 +15,30 @@ namespace Microsoft.Agents.AI.Foundry.Hosting; [Experimental(DiagnosticIds.Experiments.AgentsAIExperiments)] public static class FoundryAIToolExtensions { + /// + /// 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); + } + /// /// Creates an marker from a retrieved /// from AIProjectClient. Uses and diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs index fd6f1242960..b01a4e6fcc0 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/InputConverter.cs @@ -77,6 +77,12 @@ 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, @@ -312,24 +327,29 @@ 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) + if (entry is not { AfRequestId: string requestId } + || entry.Name != name + || 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))]); } /// @@ -342,20 +362,128 @@ 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; + + 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) + { + messages.Add(new ChatMessage( + ChatRole.Tool, + [new FunctionResultContent(localMcpEntry.CallId, mcpCall.Output)])); + } + + 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); + return; + } + + AppendNativeMcpMessages( + messages, + mcpCall.Id, + mcpCall.Name, + mcpCall.ServerLabel, + arguments, + mcpCall.Output, + hasTerminalResult); + } + + private static void AppendNativeMcpMessages( + List messages, + string callId, + string toolName, + string serverLabel, + IDictionary? arguments, + string output, + bool hasTerminalResult) + { + messages.Add(new ChatMessage( + ChatRole.Assistant, + [new McpServerToolCallContent(callId, toolName, serverLabel) + { + Arguments = arguments, + }])); + if (hasTerminalResult) + { + messages.Add(new ChatMessage( + ChatRole.Tool, + [new McpServerToolResultContent(callId) + { + Outputs = [new MeaiTextContent(output)], + }])); + } } [UnconditionalSuppressMessage("Trimming", "IL2026", Justification = "Deserializing tool-call arguments from SDK input.")] @@ -384,8 +512,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/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs index 282f45ee82f..975aed1b587 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -46,6 +46,18 @@ public static implicit operator OutputConverterItem(ResponseStreamEvent value) = /// internal static class OutputConverter { + 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; + } + /// /// Converts a stream of into a stream of /// using the SDK builder pattern. @@ -53,6 +65,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. @@ -61,12 +74,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) @@ -83,6 +98,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.")] @@ -91,6 +107,7 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA IAsyncEnumerable updates, ResponseEventStream stream, AgentSessionStateBag? stateBag = null, + IReadOnlyDictionary? localMcpFunctions = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) { ResponseUsage? accumulatedUsage = null; @@ -101,6 +118,7 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA string? previousMessageId = null; bool hasTerminalEvent = false; var executorItemIds = new Dictionary(); + var pendingMcpCalls = new Dictionary(StringComparer.Ordinal); await foreach (var update in updates.WithCancellation(cancellationToken).ConfigureAwait(false)) { @@ -212,6 +230,35 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA ? JsonSerializer.Serialize(functionCall.Arguments) : "{}"; + if (localMcpFunctions?.TryGetValue(functionCall.Name, out var localMcpFunction) is true) + { + // 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; + } + var fcBuilder = stream.AddOutputItemFunctionCall(functionCall.Name, functionCall.CallId); yield return fcBuilder.EmitAdded(); yield return fcBuilder.EmitArgumentsDelta(arguments); @@ -220,6 +267,54 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA 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) + : "{}"; + 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; + } + // The MEAI OpenAI Responses client has no dedicated content type for computer calls: it yields a // base ToolCallContent carrying the OpenAI item. Without this case the caller would never see the // call it is expected to execute. @@ -330,6 +425,8 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA var approvalArguments = approvalFunctionCall.Arguments is not null ? JsonSerializer.Serialize(approvalFunctionCall.Arguments) : "{}"; + LocalMcpExecutionContract? localMcpFunction = null; + _ = localMcpFunctions?.TryGetValue(approvalFunctionCall.Name, out localMcpFunction); ToolApprovalIdMap.Record( stateBag, @@ -337,12 +434,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); @@ -351,8 +450,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) + : "{}"; + 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; @@ -381,6 +520,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.", @@ -413,6 +570,77 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA accumulatedAnnotations = null; previousMessageId = null; + 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 + }) + { + string toolName = approvalEntry.McpToolName ?? name; + var contract = new LocalMcpExecutionContract( + serverLabel, + toolName, + RequiresApproval: true); + var arguments = approvalEntry.Arguments ?? "{}"; + 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 not null) + { + 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; + } + var outputText = EncodeFunctionResultAsJsonStringPayload(functionResult.Result); // Use the SDK's convenience method so the OutputItemFunctionToolCallOutput @@ -431,6 +659,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; } @@ -469,6 +776,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); @@ -755,9 +1078,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.")] - private static string EncodeFunctionResultAsJsonStringPayload(object? result) + private 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, @@ -769,7 +1097,23 @@ private static string EncodeFunctionResultAsJsonStringPayload(object? result) : jd.RootElement.GetRawText(), _ => JsonSerializer.Serialize(result), }; + } + + private static string ConvertMcpResultToText(IList? outputs) + { + if (outputs is not { Count: > 0 }) + { + return string.Empty; + } - return JsonSerializer.Serialize(innerText); + 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 24e2e25261f..d972b85e845 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs @@ -44,6 +44,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 +78,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 +106,46 @@ 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); + return entry; + } + /// /// Looks up the AF request id for a given wire id. Returns the wire id verbatim /// when no mapping is present. @@ -126,6 +176,49 @@ 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; + } + + foreach (var entry in map.Values) + { + if (entry.McpServerLabel is not null + && entry.IsMcpToolCall == isMcpToolCall + && string.Equals(entry.CallId, callId, StringComparison.Ordinal)) + { + 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; + } + } + + return null; + } + private static Dictionary LoadMap(AgentSessionStateBag stateBag) => TryLoadMap(stateBag, out var map) ? map : new Dictionary(StringComparer.Ordinal); @@ -144,3 +237,107 @@ 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; + } +} diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs index 02dafb8ce2c..7f8c716d711 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -109,7 +109,14 @@ await this.RequestApprovalAsync( return default; } - // No approval required - invoke the tool directly + // The MCP handler owns transport execution, while this executor owns the stable call identity + // carried through workflow output and hosted protocol projection. + string callId = Guid.NewGuid().ToString("N"); + McpServerToolCallContent toolCall = CreateMcpToolCall( + callId, + serverLabel ?? serverUrl, + toolName, + arguments); McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync( serverUrl, serverLabel, @@ -118,8 +125,15 @@ await this.RequestApprovalAsync( headers, connectionName, cancellationToken).ConfigureAwait(false); + resultContent = CopyResultWithCallId(resultContent, callId); - await this.ProcessResultAsync(context, resultContent, conversationId, autoSend, cancellationToken).ConfigureAwait(false); + await this.ProcessResultAsync( + context, + toolCall, + resultContent, + conversationId, + autoSend, + cancellationToken).ConfigureAwait(false); // Signal completion so the workflow routes via RequiresNothing await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); @@ -201,8 +215,15 @@ public async ValueTask CaptureResponseAsync( headers, snapshot.ConnectionName, cancellationToken).ConfigureAwait(false); + resultContent = CopyResultWithCallId(resultContent, approvalResponse.ToolCall.CallId); - await this.ProcessResultAsync(context, resultContent, conversationId, autoSend, cancellationToken).ConfigureAwait(false); + await this.ProcessResultAsync( + context, + toolCall: null, + resultContent, + conversationId, + autoSend, + cancellationToken).ConfigureAwait(false); await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); } @@ -302,10 +323,11 @@ private async ValueTask RequestApprovalAsync( this._approvalHeaderSnapshots[requestId] = new HeaderSnapshot(headers); // Transport headers are intentionally excluded from externally-surfaced approval content. - McpServerToolCallContent toolCall = new(requestId, toolName, serverLabel ?? serverUrl) - { - Arguments = arguments - }; + McpServerToolCallContent toolCall = CreateMcpToolCall( + requestId, + serverLabel ?? serverUrl, + toolName, + arguments); ToolApprovalRequestContent approvalRequest = new(requestId, toolCall); ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]); @@ -314,6 +336,7 @@ private async ValueTask RequestApprovalAsync( private async ValueTask ProcessResultAsync( IWorkflowContext context, + McpServerToolCallContent? toolCall, McpServerToolResultContent resultContent, string? conversationId, bool autoSend, @@ -331,7 +354,14 @@ private async ValueTask ProcessResultAsync( // Auto-send the result if configured if (autoSend) { - AgentResponse resultResponse = new([resultMessage]); + List responseMessages = []; + if (toolCall is not null) + { + responseMessages.Add(new ChatMessage(ChatRole.Assistant, [toolCall])); + } + + responseMessages.Add(new ChatMessage(ChatRole.Tool, [resultContent])); + AgentResponse resultResponse = new(responseMessages); await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false); } @@ -343,6 +373,27 @@ private async ValueTask ProcessResultAsync( } } + private static McpServerToolCallContent CreateMcpToolCall( + string callId, + string serverName, + string toolName, + IDictionary? arguments) => + new(callId, toolName, serverName) + { + Arguments = arguments + }; + + private static McpServerToolResultContent CopyResultWithCallId( + McpServerToolResultContent result, + string callId) => + new(callId) + { + AdditionalProperties = result.AdditionalProperties, + Annotations = result.Annotations, + Outputs = result.Outputs, + RawRepresentation = result.RawRepresentation, + }; + private async ValueTask AssignResultAsync(IWorkflowContext context, McpServerToolResultContent toolResult) { if (this.Model.Output?.Result is null || toolResult.Outputs is null || toolResult.Outputs.Count == 0) diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests.TestContainer/Program.cs index 61430ea6e5b..a02df400f2c 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), @@ -147,12 +150,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 AddFoundryToolboxes 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..94fadac5ec1 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/McpToolboxHostedAgentTests.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/McpToolboxHostedAgentTests.cs @@ -1,15 +1,18 @@ // Copyright (c) Microsoft. All rights reserved. +using System; using System.Linq; +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 +20,30 @@ 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)); + Assert.Contains("rogerbarreto", mcpCall.ToolOutput, StringComparison.OrdinalIgnoreCase); + Assert.DoesNotContain(response.OutputItems, item => + item is FunctionCallResponseItem or FunctionCallOutputResponseItem); + Assert.Contains("rogerbarreto", response.GetOutputText(), StringComparison.OrdinalIgnoreCase); } - [Fact(Skip = "Pending TestContainer build and end to end smoke (step 5).")] - public async Task McpTool_ProducesUsableResponseAsync() - { - // 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)); - } } 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 1423494d2ee..3f9671fe66a 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs @@ -6,6 +6,8 @@ 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.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; @@ -13,6 +15,7 @@ using Microsoft.Extensions.AI; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Options; using Moq; @@ -128,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 _)); @@ -238,6 +245,217 @@ await Assert.ThrowsAnyAsync( } } + [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)); + services.AddSingleton(new FakeHostedSessionIsolationKeyProvider()); + var handler = new AgentFrameworkResponseHandler( + services.BuildServiceProvider(), + NullLogger.Instance, + 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); + services.AddSingleton(new FakeHostedSessionIsolationKeyProvider()); + 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_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); + services.AddSingleton(new FakeHostedSessionIsolationKeyProvider()); + var handler = new AgentFrameworkResponseHandler( + services.BuildServiceProvider(), + NullLogger.Instance, + 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)) + { + } + } + + 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) { 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 ddc7f3247ed..4489a90f8c1 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,100 @@ 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); + } + + [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() { @@ -896,6 +990,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] 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 48982e6a518..4c8d825ab4f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs @@ -703,6 +703,308 @@ 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"] = "weather" }, + } + ] + }, + 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\":\"weather\"}", 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 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)) + { + } + } + + 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)); + } + // K-05: An FCC with an empty CallId is dropped without disturbing in-flight text. [Fact] public async Task ConvertUpdatesToEventsAsync_FunctionCallEmptyCallIdMidText_PreservesTextBoundaryAsync() @@ -1395,6 +1697,96 @@ 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"] = "db" }, + }; + 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); + + 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() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs index b3cd7d8c8d1..5167e485b7f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs @@ -129,6 +129,41 @@ public async Task InvokeMcpToolExecuteWithoutApprovalAsync() await this.ExecuteTestAsync(model); } + [Fact] + public async Task InvokeMcpToolExecuteWithoutApproval_EmitsPairedMcpCallAndResultAsync() + { + // Arrange + this.State.InitializeSystem(); + InvokeMcpTool.Builder builder = this.CreateModel( + displayName: nameof(InvokeMcpToolExecuteWithoutApproval_EmitsPairedMcpCallAndResultAsync), + serverUrl: TestServerUrl, + serverLabel: TestServerLabel, + toolName: TestToolName, + requireApproval: false).ToBuilder(); + builder.Output = new InvokeToolOutput.Builder + { + AutoSend = BoolExpression.Literal(true).ToBuilder(), + }; + MockMcpToolProvider provider = new(); + InvokeMcpTool model = AssignParent(builder); + InvokeMcpToolExecutor action = new(model, provider.Object, new MockAgentProvider().Object, this.State); + + // Act + WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); + + // Assert + AIContent[] contents = events + .OfType() + .SelectMany(responseEvent => responseEvent.Response.Messages) + .SelectMany(message => message.Contents) + .ToArray(); + McpServerToolCallContent call = Assert.Single(contents.OfType()); + McpServerToolResultContent result = Assert.Single(contents.OfType()); + Assert.Equal(call.CallId, result.CallId); + Assert.Equal(TestServerLabel, call.ServerName); + Assert.Equal(TestToolName, call.Name); + } + [Fact] public async Task InvokeMcpToolExecuteWithServerLabelAsync() { From 5a6fd0ae276bc99d7a18cd262b3e35956d7c97dd Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:20:54 -0800 Subject: [PATCH 2/7] refactor(dotnet): preserve workflow MCP output Declarative workflow events are shared by multiple hosting adapters. Keep their existing output shape and scope native MCP projection to producers that already emit native MCP content. --- .../ObjectModel/InvokeMcpToolExecutor.cs | 67 +++---------------- .../ObjectModel/InvokeMcpToolExecutorTest.cs | 35 ---------- 2 files changed, 8 insertions(+), 94 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs index 7f8c716d711..02dafb8ce2c 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs @@ -109,14 +109,7 @@ await this.RequestApprovalAsync( return default; } - // The MCP handler owns transport execution, while this executor owns the stable call identity - // carried through workflow output and hosted protocol projection. - string callId = Guid.NewGuid().ToString("N"); - McpServerToolCallContent toolCall = CreateMcpToolCall( - callId, - serverLabel ?? serverUrl, - toolName, - arguments); + // No approval required - invoke the tool directly McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync( serverUrl, serverLabel, @@ -125,15 +118,8 @@ await this.RequestApprovalAsync( headers, connectionName, cancellationToken).ConfigureAwait(false); - resultContent = CopyResultWithCallId(resultContent, callId); - await this.ProcessResultAsync( - context, - toolCall, - resultContent, - conversationId, - autoSend, - cancellationToken).ConfigureAwait(false); + await this.ProcessResultAsync(context, resultContent, conversationId, autoSend, cancellationToken).ConfigureAwait(false); // Signal completion so the workflow routes via RequiresNothing await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); @@ -215,15 +201,8 @@ public async ValueTask CaptureResponseAsync( headers, snapshot.ConnectionName, cancellationToken).ConfigureAwait(false); - resultContent = CopyResultWithCallId(resultContent, approvalResponse.ToolCall.CallId); - await this.ProcessResultAsync( - context, - toolCall: null, - resultContent, - conversationId, - autoSend, - cancellationToken).ConfigureAwait(false); + await this.ProcessResultAsync(context, resultContent, conversationId, autoSend, cancellationToken).ConfigureAwait(false); await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false); } @@ -323,11 +302,10 @@ private async ValueTask RequestApprovalAsync( this._approvalHeaderSnapshots[requestId] = new HeaderSnapshot(headers); // Transport headers are intentionally excluded from externally-surfaced approval content. - McpServerToolCallContent toolCall = CreateMcpToolCall( - requestId, - serverLabel ?? serverUrl, - toolName, - arguments); + McpServerToolCallContent toolCall = new(requestId, toolName, serverLabel ?? serverUrl) + { + Arguments = arguments + }; ToolApprovalRequestContent approvalRequest = new(requestId, toolCall); ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]); @@ -336,7 +314,6 @@ private async ValueTask RequestApprovalAsync( private async ValueTask ProcessResultAsync( IWorkflowContext context, - McpServerToolCallContent? toolCall, McpServerToolResultContent resultContent, string? conversationId, bool autoSend, @@ -354,14 +331,7 @@ private async ValueTask ProcessResultAsync( // Auto-send the result if configured if (autoSend) { - List responseMessages = []; - if (toolCall is not null) - { - responseMessages.Add(new ChatMessage(ChatRole.Assistant, [toolCall])); - } - - responseMessages.Add(new ChatMessage(ChatRole.Tool, [resultContent])); - AgentResponse resultResponse = new(responseMessages); + AgentResponse resultResponse = new([resultMessage]); await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false); } @@ -373,27 +343,6 @@ private async ValueTask ProcessResultAsync( } } - private static McpServerToolCallContent CreateMcpToolCall( - string callId, - string serverName, - string toolName, - IDictionary? arguments) => - new(callId, toolName, serverName) - { - Arguments = arguments - }; - - private static McpServerToolResultContent CopyResultWithCallId( - McpServerToolResultContent result, - string callId) => - new(callId) - { - AdditionalProperties = result.AdditionalProperties, - Annotations = result.Annotations, - Outputs = result.Outputs, - RawRepresentation = result.RawRepresentation, - }; - private async ValueTask AssignResultAsync(IWorkflowContext context, McpServerToolResultContent toolResult) { if (this.Model.Output?.Result is null || toolResult.Outputs is null || toolResult.Outputs.Count == 0) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs index 5167e485b7f..b3cd7d8c8d1 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/ObjectModel/InvokeMcpToolExecutorTest.cs @@ -129,41 +129,6 @@ public async Task InvokeMcpToolExecuteWithoutApprovalAsync() await this.ExecuteTestAsync(model); } - [Fact] - public async Task InvokeMcpToolExecuteWithoutApproval_EmitsPairedMcpCallAndResultAsync() - { - // Arrange - this.State.InitializeSystem(); - InvokeMcpTool.Builder builder = this.CreateModel( - displayName: nameof(InvokeMcpToolExecuteWithoutApproval_EmitsPairedMcpCallAndResultAsync), - serverUrl: TestServerUrl, - serverLabel: TestServerLabel, - toolName: TestToolName, - requireApproval: false).ToBuilder(); - builder.Output = new InvokeToolOutput.Builder - { - AutoSend = BoolExpression.Literal(true).ToBuilder(), - }; - MockMcpToolProvider provider = new(); - InvokeMcpTool model = AssignParent(builder); - InvokeMcpToolExecutor action = new(model, provider.Object, new MockAgentProvider().Object, this.State); - - // Act - WorkflowEvent[] events = await this.ExecuteAsync(action, isDiscrete: false); - - // Assert - AIContent[] contents = events - .OfType() - .SelectMany(responseEvent => responseEvent.Response.Messages) - .SelectMany(message => message.Contents) - .ToArray(); - McpServerToolCallContent call = Assert.Single(contents.OfType()); - McpServerToolResultContent result = Assert.Single(contents.OfType()); - Assert.Equal(call.CallId, result.CallId); - Assert.Equal(TestServerLabel, call.ServerName); - Assert.Equal(TestToolName, call.Name); - } - [Fact] public async Task InvokeMcpToolExecuteWithServerLabelAsync() { From 86a35ab2251dd766a62888777ba7a66d7cbd1869 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 3 Nov 2026 09:48:19 -0800 Subject: [PATCH 3/7] fix(dotnet): harden hosted MCP replay Preserve MCP identity through approval middleware, retain rejection and failure semantics, and correlate reused call IDs deterministically. --- .../ConsentAwareMcpClientAIFunction.cs | 47 ++++++ .../InputConverter.cs | 85 +++++++++- .../OutputConverter.cs | 28 ++++ .../ToolApprovalIdMap.cs | 154 ++++++++++++++++-- .../McpToolboxHostedAgentTests.cs | 17 +- .../FoundryToolboxApprovalTests.cs | 74 +++++++++ .../InputConverterTests.cs | 138 ++++++++++++++++ .../OutputConverterTests.cs | 52 ++++++ 8 files changed, 570 insertions(+), 25 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs index 0802fde44ae..edd39811d96 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.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -35,6 +36,14 @@ internal LocalMcpAIFunction(AIFunction innerFunction, string serverLabel, string serviceKey is null && serviceType == typeof(LocalMcpExecutionContract) ? this._contract : base.GetService(serviceType, serviceKey); + + protected override ValueTask InvokeCoreAsync( + AIFunctionArguments arguments, + CancellationToken cancellationToken) + { + LocalMcpInvocationTracker.Record(this._contract); + return base.InvokeCoreAsync(arguments, cancellationToken); + } } /// @@ -88,12 +97,14 @@ internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName 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) { @@ -118,3 +129,39 @@ internal ConsentAwareMcpClientAIFunction(McpClientTool inner, string toolboxName } } } + +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 b01a4e6fcc0..fd78c52f594 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; @@ -88,6 +115,11 @@ public static List ConvertOutputItemsToMessages(IReadOnlyList? arguments, string output, - bool hasTerminalResult) + bool hasTerminalResult, + string? failureMessage) { messages.Add(new ChatMessage( ChatRole.Assistant, @@ -477,15 +520,45 @@ [new McpServerToolCallContent(callId, toolName, serverLabel) }])); if (hasTerminalResult) { + AIContent result = failureMessage is null + ? new MeaiTextContent(output) + : new ErrorContent(failureMessage); messages.Add(new ChatMessage( ChatRole.Tool, [new McpServerToolResultContent(callId) { - Outputs = [new MeaiTextContent(output)], + 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.")] [UnconditionalSuppressMessage("AOT", "IL3050", Justification = "Deserializing tool-call arguments from SDK input.")] private static Dictionary? ParseFunctionArgumentsObject(string? arguments) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs index 975aed1b587..aaf1ea44db7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/OutputConverter.cs @@ -614,9 +614,37 @@ public static async IAsyncEnumerable ConvertUpdatesToItemsA yield return mcpBuilder.EmitArgumentsDone(arguments); } } + if (pendingMcpCall is null + && LocalMcpInvocationMap.Consume(stateBag, functionResult.CallId) is { } invocation) + { + 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 diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs index d972b85e845..e7b73d70b86 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs @@ -29,6 +29,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. @@ -143,6 +146,14 @@ public static ApprovalEntry RecordDecision(AgentSessionStateBag? stateBag, strin 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; } @@ -193,32 +204,83 @@ public static string Resolve(AgentSessionStateBag? stateBag, string wireId) 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 (entry.McpServerLabel is not null - && entry.IsMcpToolCall == isMcpToolCall - && string.Equals(entry.CallId, callId, StringComparison.Ordinal)) + if (!IsMatchingMcpEntry(entry, callId, isMcpToolCall)) + { + continue; + } + + if (entry.Consumed) { - 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; + 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); @@ -341,3 +403,61 @@ public static void Record( 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"; + + 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) + { + var map = stateBag.GetValue>(StateBagKey) + ?? 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) + { + Dictionary? map = + stateBag?.GetValue>(StateBagKey); + if (map?.Remove(callId, out CallEntry? entry) is not true) + { + return null; + } + + if (map.Count == 0) + { + stateBag!.TryRemoveValue(StateBagKey); + } + else + { + stateBag!.SetValue(StateBagKey, map); + } + + return entry; + } +} diff --git a/dotnet/tests/Foundry.Hosting.IntegrationTests/McpToolboxHostedAgentTests.cs b/dotnet/tests/Foundry.Hosting.IntegrationTests/McpToolboxHostedAgentTests.cs index 94fadac5ec1..15141c7cb3c 100644 --- a/dotnet/tests/Foundry.Hosting.IntegrationTests/McpToolboxHostedAgentTests.cs +++ b/dotnet/tests/Foundry.Hosting.IntegrationTests/McpToolboxHostedAgentTests.cs @@ -2,6 +2,7 @@ using System; using System.Linq; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using Foundry.Hosting.IntegrationTests.Fixtures; @@ -40,10 +41,22 @@ public async Task ToolboxExecution_UsesMcpCallerContractAsync() Assert.Equal(this._fixture.ToolboxName, mcpCall.ServerLabel); Assert.EndsWith("___get_me", mcpCall.ToolName, StringComparison.Ordinal); Assert.False(string.IsNullOrWhiteSpace(mcpCall.ToolOutput)); - Assert.Contains("rogerbarreto", mcpCall.ToolOutput, StringComparison.OrdinalIgnoreCase); + string login = GetGitHubLogin(mcpCall.ToolOutput); Assert.DoesNotContain(response.OutputItems, item => item is FunctionCallResponseItem or FunctionCallOutputResponseItem); - Assert.Contains("rogerbarreto", response.GetOutputText(), StringComparison.OrdinalIgnoreCase); + Assert.Contains(login, response.GetOutputText(), StringComparison.OrdinalIgnoreCase); } + private static string GetGitHubLogin(string toolOutput) + { + 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/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs index 3f9671fe66a..e2ded3fab94 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs @@ -382,6 +382,79 @@ [new FunctionCallContent("call_local_mcp", "local_search")])) 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); + services.AddSingleton(new FakeHostedSessionIsolationKeyProvider()); + 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() { @@ -441,6 +514,7 @@ async Task InvokeAsync() { await foreach (var _ in handler.CreateAsync(request, context.Object, CancellationToken.None)) { + // Intentionally empty: enumerate the stream to trigger handler validation. } } 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 4489a90f8c1..a3bdd56737b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/InputConverterTests.cs @@ -313,6 +313,70 @@ public void ConvertOutputItemsToMessages_ProviderMcpCall_RestoresMcpTranscript() 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() { @@ -1059,6 +1123,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() { @@ -1179,6 +1275,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/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs index 4c8d825ab4f..c90bfc97b44 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs @@ -960,6 +960,7 @@ async Task ConvertAsync() stream, stateBag)) { + // Intentionally empty: enumerate all updates to trigger duplicate-result validation. } } @@ -1005,6 +1006,57 @@ public async Task ConvertUpdatesToEventsAsync_RejectedToolboxResult_DoesNotEmitM 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() From 419b32e91e1211836e7942671e77e2b1c3a9d593 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:10:27 -0800 Subject: [PATCH 4/7] refactor(dotnet): simplify approval map lookup --- .../ToolApprovalIdMap.cs | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs index e7b73d70b86..f8cdd59d906 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; using System.Text; using Microsoft.Extensions.AI; @@ -205,7 +206,7 @@ public static string Resolve(AgentSessionStateBag? stateBag, string wireId) } string indexKey = DecisionIndexKey(callId, isMcpToolCall); - if (stateBag?.GetValue>(DecisionIndexStateBagKey) + if (stateBag.GetValue>(DecisionIndexStateBagKey) ?.TryGetValue(indexKey, out string? wireId) is true && map.TryGetValue(wireId, out ApprovalEntry? indexedEntry) && IsMatchingMcpEntry(indexedEntry, callId, isMcpToolCall)) @@ -284,7 +285,9 @@ entry.McpServerLabel is not null 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) { From 9d865ae2997a4da516eabd5758da8cd1e40efd72 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 3 Nov 2026 11:44:25 -0800 Subject: [PATCH 5/7] fix(dotnet): preserve native MCP approvals Keep the framework approval response so pending approvals are settled once, while using the native item on the wire. --- .../Hosted-ToolboxMcpSkills/README.md | 5 ++++ .../Program.cs | 23 +++++++++++-------- 2 files changed, 19 insertions(+), 9 deletions(-) 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 d5d06e5eb1f..a5e67ddc1de 100644 --- a/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md +++ b/dotnet/samples/04-hosting/FoundryHostedAgents/responses/Hosted-ToolboxMcpSkills/README.md @@ -171,6 +171,11 @@ To approve and execute `load_skill`, use the detects `mcp_approval_request`, asks for `Y` or `N`, and sends the native OpenAI `mcp_approval_response` item on the same session. +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 9995f623736..9a7f809e2dd 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,19 +156,24 @@ 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(); bool approved = Console.ReadLine()?.Trim().Equals("Y", StringComparison.OrdinalIgnoreCase) ?? false; - // Hosted MCP approval IDs use the mcpr_ wire prefix. They need the native OpenAI - // response item; CreateResponse produces the Agent Framework function-tool shape. - AIContent decision = approval.RequestId.StartsWith("mcpr_", StringComparison.Ordinal) - ? new AIContent - { - RawRepresentation = new McpToolCallApprovalResponseItem(approval.RequestId, approved), - } - : approval.CreateResponse(approved); + ToolApprovalResponseContent decision = approval.CreateResponse(approved); + if (approval.RequestId.StartsWith("mcpr_", StringComparison.Ordinal)) + { + // Keep the framework response so the pending approval is settled exactly once, + // while the raw representation preserves the native hosted MCP wire contract. + decision.RawRepresentation = new McpToolCallApprovalResponseItem(approval.RequestId, approved); + } + return new ChatMessage(ChatRole.User, [decision]); }); From 5ca0c9101543115ac9c265c60f96ac7cab0cb390 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 3 Nov 2026 13:42:50 -0800 Subject: [PATCH 6/7] fix(dotnet): synchronize local MCP tracking Concurrent tool invocation could overwrite session markers and expose MCP results as function outputs. --- .../ToolApprovalIdMap.cs | 62 +++++++++++++------ .../OutputConverterTests.cs | 45 ++++++++++++++ 2 files changed, 87 insertions(+), 20 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs index f8cdd59d906..6b1b9c852e7 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ToolApprovalIdMap.cs @@ -3,6 +3,7 @@ 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; @@ -415,6 +416,8 @@ 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; @@ -431,36 +434,55 @@ public static void Record( string serverLabel, string arguments) { - var map = stateBag.GetValue>(StateBagKey) - ?? new Dictionary(StringComparer.Ordinal); - map[callId] = new CallEntry + lock (GetGate(stateBag)) { - FunctionName = functionName, - ToolName = toolName, - ServerLabel = serverLabel, - Arguments = arguments, - }; - stateBag.SetValue(StateBagKey, map); + // 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) { - Dictionary? map = - stateBag?.GetValue>(StateBagKey); - if (map?.Remove(callId, out CallEntry? entry) is not true) + if (stateBag is null) { return null; } - if (map.Count == 0) - { - stateBag!.TryRemoveValue(StateBagKey); - } - else + lock (GetGate(stateBag)) { - stateBag!.SetValue(StateBagKey, map); - } + Dictionary? existing = + stateBag.GetValue>(StateBagKey); + if (existing is null || !existing.TryGetValue(callId, out CallEntry? entry)) + { + return null; + } - return entry; + 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/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs index c90bfc97b44..d341e920e80 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/OutputConverterTests.cs @@ -882,6 +882,51 @@ public async Task ConvertUpdatesToEventsAsync_ParallelToolboxCalls_CompleteIndep 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() { From aa3ae3fd02a42e68e1d7fda1b196f9ed0f0c689c Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 3 Nov 2026 14:42:37 -0800 Subject: [PATCH 7/7] fix(dotnet): clear canceled MCP markers Canceled tools emit no result, so remove their temporary session markers before persistence. --- .../ConsentAwareMcpClientAIFunction.cs | 21 +++- .../FoundryToolboxApprovalTests.cs | 106 ++++++++++++++++++ 2 files changed, 125 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs index edd39811d96..09c228e6bfd 100644 --- a/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs +++ b/dotnet/src/Microsoft.Agents.AI.Foundry.Hosting/ConsentAwareMcpClientAIFunction.cs @@ -37,12 +37,22 @@ internal LocalMcpAIFunction(AIFunction innerFunction, string serverLabel, string ? this._contract : base.GetService(serviceType, serviceKey); - protected override ValueTask InvokeCoreAsync( + protected override async ValueTask InvokeCoreAsync( AIFunctionArguments arguments, CancellationToken cancellationToken) { LocalMcpInvocationTracker.Record(this._contract); - return base.InvokeCoreAsync(arguments, cancellationToken); + 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; + } } } @@ -127,6 +137,13 @@ 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; + } } } 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 e2ded3fab94..ddc31d29cfd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Foundry.Hosting.UnitTests/FoundryToolboxApprovalTests.cs @@ -245,6 +245,103 @@ 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() { @@ -556,6 +653,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) { @@ -613,6 +713,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);