mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
* Add mcp tool execution fix * Apply IsolationKeyScopedAgentSessionStore to MapAGUI by default if not yet set and improve comments in samples * Address PR comments * Fix formatting
426 lines
16 KiB
C#
426 lines
16 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)
|
|
{
|
|
private const string ApprovalSnapshotStateKey = nameof(_approvalSnapshot);
|
|
|
|
/// <summary>
|
|
/// Snapshot of evaluated parameters at approval-request time.
|
|
/// Used to prevent TOCTOU attacks where state mutates during the approval window.
|
|
/// </summary>
|
|
private ApprovalSnapshot? _approvalSnapshot;
|
|
|
|
/// <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 || (message is PortableValue pv && pv.IsType(out ExternalInputRequest? _));
|
|
|
|
/// <summary>
|
|
/// Determines if the message indicates no external input is required.
|
|
/// </summary>
|
|
public static bool RequiresNothing(object? message) =>
|
|
message is ActionExecutorResult || (message is PortableValue pv && pv.IsType(out 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)
|
|
{
|
|
// Snapshot the evaluated parameters to prevent TOCTOU attacks.
|
|
// If state mutates during the approval window, the approved values are used on resume.
|
|
this._approvalSnapshot = new ApprovalSnapshot(serverUrl, serverLabel, toolName, arguments, connectionName);
|
|
|
|
// Create tool call content for approval request.
|
|
// Transport headers (e.g. Authorization) are intentionally excluded from the
|
|
// approval event: they must not cross into the externally-surfaced approval request.
|
|
McpServerToolCallContent toolCall = new(this.Id, toolName, serverLabel ?? serverUrl)
|
|
{
|
|
Arguments = arguments
|
|
};
|
|
|
|
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 - use the snapshot from approval-request time to prevent TOCTOU attacks.
|
|
// Headers are re-evaluated (they may contain auth secrets that should not be persisted).
|
|
string serverUrl = this._approvalSnapshot?.ServerUrl ?? this.GetServerUrl();
|
|
string? serverLabel = this._approvalSnapshot?.ServerLabel ?? this.GetServerLabel();
|
|
string toolName = this._approvalSnapshot?.ToolName ?? this.GetToolName();
|
|
Dictionary<string, object?>? arguments = this._approvalSnapshot?.Arguments ?? this.GetArguments();
|
|
Dictionary<string, string>? headers = this.GetHeaders();
|
|
string? connectionName = this._approvalSnapshot?.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)
|
|
{
|
|
// Clear the approval snapshot after successful completion.
|
|
this._approvalSnapshot = null;
|
|
await ClearSnapshotStateAsync(context, cancellationToken).ConfigureAwait(false);
|
|
|
|
await context.RaiseCompletionEventAsync(this.Model, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
/// <remarks>
|
|
/// Persists the approval snapshot to workflow state so it survives checkpoint/restore cycles.
|
|
/// </remarks>
|
|
protected override async ValueTask OnCheckpointingAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
|
{
|
|
await context.QueueStateUpdateAsync(ApprovalSnapshotStateKey, this._approvalSnapshot, null, cancellationToken).ConfigureAwait(false);
|
|
await base.OnCheckpointingAsync(context, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <inheritdoc/>
|
|
/// <remarks>
|
|
/// Restores the approval snapshot from workflow state after a checkpoint restore.
|
|
/// </remarks>
|
|
protected override async ValueTask OnCheckpointRestoredAsync(IWorkflowContext context, CancellationToken cancellationToken = default)
|
|
{
|
|
await base.OnCheckpointRestoredAsync(context, cancellationToken).ConfigureAwait(false);
|
|
this._approvalSnapshot = await context.ReadStateAsync<ApprovalSnapshot>(ApprovalSnapshotStateKey, null, 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()
|
|
{
|
|
// InvokeToolOutput.AutoSend is never null — it returns a literal-false default
|
|
// when the YAML omits the field. Use AutoSendIsDefaultValue to distinguish an
|
|
// explicit autoSend value from the implicit default, and treat the implicit
|
|
// default as autoSend = true (the historical behavior).
|
|
if (this.Model.Output is { AutoSendIsDefaultValue: false } output)
|
|
{
|
|
return this.Evaluator.GetValue(output.AutoSend).Value;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Clears the persisted approval snapshot state after a successful tool invocation.
|
|
/// </summary>
|
|
private static async ValueTask ClearSnapshotStateAsync(IWorkflowContext context, CancellationToken cancellationToken)
|
|
{
|
|
await context.QueueStateUpdateAsync<ApprovalSnapshot?>(ApprovalSnapshotStateKey, null, null, cancellationToken).ConfigureAwait(false);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Stores the evaluated parameters at approval-request time so that
|
|
/// <see cref="CaptureResponseAsync"/> uses the values the user reviewed,
|
|
/// even if <see cref="WorkflowFormulaState"/> mutates during the approval window.
|
|
/// </summary>
|
|
internal sealed record ApprovalSnapshot(
|
|
string ServerUrl,
|
|
string? ServerLabel,
|
|
string ToolName,
|
|
Dictionary<string, object?>? Arguments,
|
|
string? ConnectionName);
|
|
}
|