Files
agent-framework/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/ObjectModel/InvokeMcpToolExecutor.cs
T
CopilotGitHubCopilotcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>stephentoubrogerbarreto
88ea9d08c7 .NET: Update to OpenAI 2.9.1, Azure.AI.OpenAI 2.9.0-beta.1, Microsoft.Extensions.AI 10.4.0, and Azure.AI.Projects 2.0.0-beta.2 (#4613)
* Initial plan

* Update code for Microsoft.Extensions.AI.Abstractions 10.4.0 breaking changes

- Rename FunctionApprovalRequestContent → ToolApprovalRequestContent
- Rename FunctionApprovalResponseContent → ToolApprovalResponseContent
- Rename UserInputRequestContent → ToolApprovalRequestContent
- Rename UserInputResponseContent → ToolApprovalResponseContent
- Update .FunctionCall property → .ToolCall with FunctionCallContent casts where needed
- Update .Id property → .RequestId on the renamed types
- Rename FunctionApprovalRequestEventGenerator → ToolApprovalRequestEventGenerator
- Rename FunctionApprovalResponseEventGenerator → ToolApprovalResponseEventGenerator

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Update OpenAI 2.9.1, ME.AI 10.4.0, fix breaking API changes

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Fix remaining ME.AI 10.4.0 breaking changes: MCP approval types, .Output→.Outputs

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Use pattern matching with `when` for ToolApprovalRequestContent/FunctionCallContent

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Update Azure.AI.OpenAI to 2.9.0-beta.1

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Fix remaining GetResponsesClient(model) build failures for Azure.AI.OpenAI 2.9.0-beta.1

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Address review feedback: remove redundant type checks in TestRequestAgent.cs and fix error message in AIAgentHostExecutor.cs

Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>

* Update Azure.AI.Projects to 2.0.0-beta.2 with namespace migration

- Azure.AI.Projects 2.0.0-beta.1 → 2.0.0-beta.2
- Azure.AI.Projects.OpenAI → Azure.AI.Extensions.OpenAI (transitive)
- Agent types moved to Azure.AI.Projects.Agents namespace
- AgentRecord.Versions.Latest → AgentRecord.GetLatestVersion()
- OpenAPIFunctionDefinition → OpenApiFunctionDefinition
- BingCustomSearchToolParameters → BingCustomSearchToolOptions
- MemorySearchPreviewTool.UpdateDelay → UpdateDelayInSecs
- Azure.Identity 1.17.1 → 1.19.0
- Microsoft.Identity.Client.Extensions.Msal 4.78.0 → 4.83.1

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Fix remaining type renames for Azure.AI.Projects 2.0.0-beta.2

- BrowserAutomationToolParameters → BrowserAutomationToolOptions
- MemoryUpdateOptions.UpdateDelay stays as UpdateDelay (not renamed)
- WaitForMemoriesUpdateAsync parameter order: pollingInterval before options
- AIProjectAgentsOperations → AgentsClient

Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>

* Fix format errors and OpenTelemetry test for ME.AI 10.4.0

- Remove unused 'using Azure.AI.Extensions.OpenAI' and fix import ordering
  in Agent_With_AzureAIProject/Program.cs
- Update OpenTelemetryAgentTests: gen_ai.tool.definitions is now always
  emitted regardless of EnableSensitiveData per ME.AI 10.4.0 change
  (dotnet/extensions#7346). Tool definitions are not considered sensitive.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix GetRepoFolder() to work in git worktrees

Use 'workflow-samples' directory as repo root marker instead of '.git',
which fails in worktrees (.git is a file) and also matches too early
when a '.github' folder exists in subdirectories.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix formatting: remove unused usings and fix import ordering

dotnet format applied across 59 impacted projects. Primarily removes
unnecessary 'using Azure.AI.Projects' where Azure.AI.Projects.Agents
provides all needed types, and fixes import ordering per editorconfig.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Disable AzureAIAgentsPersistent integration tests (#4769)

Azure.AI.Agents.Persistent 1.2.0-beta.9 references McpServerToolApprovalResponseContent
which was removed in ME.AI 10.4.0 (renamed to ToolApprovalResponseContent), causing
TypeLoadException at runtime. Mark all 6 test classes with IntegrationDisabled trait
until Persistent ships a version targeting ME.AI 10.4.0+.

Upstream fix: https://github.com/Azure/azure-sdk-for-net/pull/56929

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Add README with compatibility note for AzureAI.Persistent (#4769)

Documents that Azure.AI.Agents.Persistent 1.2.0-beta.9 is only compatible
with ME.AI ≤10.3.0 and OpenAI ≤2.8.0 due to type renames in ME.AI 10.4.0.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Fix file encoding: restore UTF-8 BOM on Persistent test files

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Mark AzureAI.Persistent as IsPackable=false (#4769)

Prevent shipping until Azure.AI.Agents.Persistent targets ME.AI 10.4.0+.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* Moving IsPackable after import

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: stephentoub <2642209+stephentoub@users.noreply.github.com>
Co-authored-by: rogerbarreto <19890735+rogerbarreto@users.noreply.github.com>
2026-03-20 14:29:29 +00:00

367 lines
13 KiB
C#

// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Agents.AI.Workflows.Declarative.Events;
using Microsoft.Agents.AI.Workflows.Declarative.Extensions;
using Microsoft.Agents.AI.Workflows.Declarative.Interpreter;
using Microsoft.Agents.AI.Workflows.Declarative.Kit;
using Microsoft.Agents.AI.Workflows.Declarative.PowerFx;
using Microsoft.Agents.ObjectModel;
using Microsoft.Extensions.AI;
using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows.Declarative.ObjectModel;
/// <summary>
/// Executor for the <see cref="InvokeMcpTool"/> action.
/// This executor invokes MCP tools on remote servers and handles approval flows.
/// </summary>
internal sealed class InvokeMcpToolExecutor(
InvokeMcpTool model,
IMcpToolHandler mcpToolHandler,
ResponseAgentProvider agentProvider,
WorkflowFormulaState state) :
DeclarativeActionExecutor<InvokeMcpTool>(model, state)
{
/// <summary>
/// Step identifiers for the MCP tool invocation workflow.
/// </summary>
public static class Steps
{
/// <summary>
/// Step for waiting for external input (approval or direct response).
/// </summary>
public static string ExternalInput(string id) => $"{id}_{nameof(ExternalInput)}";
/// <summary>
/// Step for resuming after receiving external input.
/// </summary>
public static string Resume(string id) => $"{id}_{nameof(Resume)}";
}
/// <summary>
/// Determines if the message indicates external input is required.
/// </summary>
public static bool RequiresInput(object? message) => message is ExternalInputRequest;
/// <summary>
/// Determines if the message indicates no external input is required.
/// </summary>
public static bool RequiresNothing(object? message) => message is ActionExecutorResult;
/// <inheritdoc/>
protected override bool EmitResultEvent => false;
/// <inheritdoc/>
protected override bool IsDiscreteAction => false;
/// <inheritdoc/>
[SendsMessage(typeof(ExternalInputRequest))]
protected override async ValueTask<object?> ExecuteAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
{
string serverUrl = this.GetServerUrl();
string? serverLabel = this.GetServerLabel();
string toolName = this.GetToolName();
bool requireApproval = this.GetRequireApproval();
Dictionary<string, object?>? arguments = this.GetArguments();
Dictionary<string, string>? headers = this.GetHeaders();
string? connectionName = this.GetConnectionName();
if (requireApproval)
{
// Create tool call content for approval request
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
{
Arguments = arguments
};
if (headers != null)
{
toolCall.AdditionalProperties ??= [];
toolCall.AdditionalProperties.Add(headers);
}
ToolApprovalRequestContent approvalRequest = new(this.Id, toolCall);
ChatMessage requestMessage = new(ChatRole.Assistant, [approvalRequest]);
AgentResponse agentResponse = new([requestMessage]);
// Yield to the caller for approval
ExternalInputRequest inputRequest = new(agentResponse);
await context.SendMessageAsync(inputRequest, cancellationToken).ConfigureAwait(false);
return default;
}
// No approval required - invoke the tool directly
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
serverUrl,
serverLabel,
toolName,
arguments,
headers,
connectionName,
cancellationToken).ConfigureAwait(false);
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
// Signal completion so the workflow routes via RequiresNothing
await context.SendResultMessageAsync(this.Id, result: null, cancellationToken).ConfigureAwait(false);
return default;
}
/// <summary>
/// Captures the external input response and processes the MCP tool result.
/// </summary>
/// <param name="context">The workflow context.</param>
/// <param name="response">The external input response.</param>
/// <param name="cancellationToken">A cancellation token.</param>
/// <returns>A <see cref="ValueTask"/> representing the asynchronous operation.</returns>
public async ValueTask CaptureResponseAsync(
IWorkflowContext context,
ExternalInputResponse response,
CancellationToken cancellationToken)
{
ToolApprovalResponseContent? approvalResponse = response.Messages
.SelectMany(m => m.Contents)
.OfType<ToolApprovalResponseContent>()
.FirstOrDefault(r => r.RequestId == this.Id);
if (approvalResponse?.Approved != true)
{
// Tool call was rejected
await this.AssignErrorAsync(context, "MCP tool invocation was not approved by user.").ConfigureAwait(false);
return;
}
// Approved - now invoke the tool
string serverUrl = this.GetServerUrl();
string? serverLabel = this.GetServerLabel();
string toolName = this.GetToolName();
Dictionary<string, object?>? arguments = this.GetArguments();
Dictionary<string, string>? headers = this.GetHeaders();
string? connectionName = this.GetConnectionName();
McpServerToolResultContent resultContent = await mcpToolHandler.InvokeToolAsync(
serverUrl,
serverLabel,
toolName,
arguments,
headers,
connectionName,
cancellationToken).ConfigureAwait(false);
await this.ProcessResultAsync(context, resultContent, cancellationToken).ConfigureAwait(false);
}
/// <summary>
/// Completes the MCP tool invocation by raising the completion event.
/// </summary>
public async ValueTask CompleteAsync(IWorkflowContext context, ActionExecutorResult message, CancellationToken cancellationToken)
{
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
}
private async ValueTask ProcessResultAsync(IWorkflowContext context, McpServerToolResultContent resultContent, CancellationToken cancellationToken)
{
bool autoSend = this.GetAutoSendValue();
string? conversationId = this.GetConversationId();
await this.AssignResultAsync(context, resultContent).ConfigureAwait(false);
ChatMessage resultMessage = new(ChatRole.Tool, resultContent.Outputs);
// Store messages if output path is configured
if (this.Model.Output?.Messages is not null)
{
await this.AssignAsync(this.Model.Output.Messages?.Path, resultMessage.ToFormula(), context).ConfigureAwait(false);
}
// Auto-send the result if configured
if (autoSend)
{
AgentResponse resultResponse = new([resultMessage]);
await context.AddEventAsync(new AgentResponseEvent(this.Id, resultResponse), cancellationToken).ConfigureAwait(false);
}
// Add messages to conversation if conversationId is provided
if (conversationId is not null)
{
ChatMessage assistantMessage = new(ChatRole.Assistant, resultContent.Outputs);
await agentProvider.CreateMessageAsync(conversationId, assistantMessage, cancellationToken).ConfigureAwait(false);
}
}
private async ValueTask AssignResultAsync(IWorkflowContext context, McpServerToolResultContent toolResult)
{
if (this.Model.Output?.Result is null || toolResult.Outputs is null || toolResult.Outputs.Count == 0)
{
return;
}
List<object?> parsedResults = [];
foreach (AIContent resultContent in toolResult.Outputs)
{
object? resultValue = resultContent switch
{
TextContent text => text.Text,
DataContent data => data.Uri,
_ => resultContent.ToString(),
};
// Convert JsonElement to its raw JSON string for processing
if (resultValue is JsonElement jsonElement)
{
resultValue = jsonElement.GetRawText();
}
// Attempt to parse as JSON if it's a string (or was converted from JsonElement)
if (resultValue is string jsonString)
{
try
{
using JsonDocument jsonDocument = JsonDocument.Parse(jsonString);
// Handle different JSON value kinds
object? parsedValue = jsonDocument.RootElement.ValueKind switch
{
JsonValueKind.Object => jsonDocument.ParseRecord(VariableType.RecordType),
JsonValueKind.Array => jsonDocument.ParseList(jsonDocument.RootElement.GetListTypeFromJson()),
JsonValueKind.String => jsonDocument.RootElement.GetString(),
JsonValueKind.Number => jsonDocument.RootElement.TryGetInt64(out long l) ? l : jsonDocument.RootElement.GetDouble(),
JsonValueKind.True => true,
JsonValueKind.False => false,
JsonValueKind.Null => null,
_ => jsonString,
};
parsedResults.Add(parsedValue);
continue;
}
catch (JsonException)
{
// Not a valid JSON
}
}
parsedResults.Add(resultValue);
}
await this.AssignAsync(this.Model.Output.Result?.Path, parsedResults.ToFormula(), context).ConfigureAwait(false);
}
private async ValueTask AssignErrorAsync(IWorkflowContext context, string errorMessage)
{
// Store error in result if configured (as a simple string)
if (this.Model.Output?.Result is not null)
{
await this.AssignAsync(this.Model.Output.Result?.Path, $"Error: {errorMessage}".ToFormula(), context).ConfigureAwait(false);
}
}
private string GetServerUrl() =>
this.Evaluator.GetValue(
Throw.IfNull(
this.Model.ServerUrl,
$"{nameof(this.Model)}.{nameof(this.Model.ServerUrl)}")).Value;
private string? GetServerLabel()
{
if (this.Model.ServerLabel is null)
{
return null;
}
string value = this.Evaluator.GetValue(this.Model.ServerLabel).Value;
return value.Length == 0 ? null : value;
}
private string GetToolName() =>
this.Evaluator.GetValue(
Throw.IfNull(
this.Model.ToolName,
$"{nameof(this.Model)}.{nameof(this.Model.ToolName)}")).Value;
private string? GetConversationId()
{
if (this.Model.ConversationId is null)
{
return null;
}
string value = this.Evaluator.GetValue(this.Model.ConversationId).Value;
return value.Length == 0 ? null : value;
}
private bool GetRequireApproval()
{
if (this.Model.RequireApproval is null)
{
return false;
}
return this.Evaluator.GetValue(this.Model.RequireApproval).Value;
}
private bool GetAutoSendValue()
{
if (this.Model.Output?.AutoSend is null)
{
return true;
}
return this.Evaluator.GetValue(this.Model.Output.AutoSend).Value;
}
private string? GetConnectionName()
{
if (this.Model.Connection?.Name is null)
{
return null;
}
string value = this.Evaluator.GetValue(this.Model.Connection.Name).Value;
return value.Length == 0 ? null : value;
}
private Dictionary<string, object?>? GetArguments()
{
if (this.Model.Arguments is null)
{
return null;
}
Dictionary<string, object?> result = [];
foreach (KeyValuePair<string, ValueExpression> argument in this.Model.Arguments)
{
result[argument.Key] = this.Evaluator.GetValue(argument.Value).Value.ToObject();
}
return result;
}
private Dictionary<string, string>? GetHeaders()
{
if (this.Model.Headers is null)
{
return null;
}
Dictionary<string, string> result = [];
foreach (KeyValuePair<string, StringExpression> header in this.Model.Headers)
{
string value = this.Evaluator.GetValue(header.Value).Value;
if (!string.IsNullOrEmpty(value))
{
result[header.Key] = value;
}
}
return result;
}
}