Skip to content

.NET: [Feature]: .NET Support for oauth_consent_request via AGUI #6032

@staned-pcs

Description

@staned-pcs

Description

We have a foundry agent that has access to an MCP tool. This tool requires oauth based authentication. If we try this in the Foundry playground we receive a message with a Consent Link to allow us to authenticate the user, this works and subsequently can use the MCP tool.

Using the agent framework and AGUI we do not receive any content while processing responses. As this consent request is the first message we receive from the agent if we cannot handle this event we cannot use the agent with the MCP tool.

I have used middleware (code below) to inspect the responses, and I note that the update is of type OpenAI.Responses.UnknownResponseStreamEvent which has a RawRepresentation.Kind (internal only property) is oauth_consent_response.

I would like to have a way of identity the response, either in middleware or by ToolCallContent to then allow the user to use the authentication link.

What does it solve?
This would allow us to use MCP tools that have oauth authentication required.

What is expected behaviour?
Either a way to obtain the event type in Middleware (that we can then map to a ToolCallContent or equivalent) or a ToolCallContent being provided by agent.RunStreamingAsync(messages, session) that can be interpreted to be an authentication link.

Once authentication is complete we should be able to resend the original message.

Alternatives considered
I appreciate from exploring the responses in middleware that these follow the OpenAI responses under the hood (hence the UnknownResponseStreamEvent response model), I had hoped that I could obtain this information from this object, but it wasn't possible. However, there has been some work done by OpenAI on this: openai/openai-dotnet#1079, which has been merged but not released yet.

Despite this, I still believe there is merit in having a ToolCallContent or equivalent being provided to avoid having to use middleware.

Possibly linked items:
#5535
#5594

Code Sample

Backend

AIAgent baseAgent = new AIProjectClient(
    new Uri(endpoint),
    new DefaultAzureCredential()).AsAIAgent(agentId);

// Wrap with ServerFunctionApprovalAgent
var agent = new ServerFunctionApprovalAgent(baseAgent, jsonOptions.SerializerOptions);

app.MapAGUI("/", agent);
await app.RunAsync();



Basic client

// Create the AG-UI client agent
using HttpClient httpClient = new()
{
    Timeout = TimeSpan.FromSeconds(60)
};

AGUIChatClient chatClient = new (httpClient, serverUrl);

AIAgent agent = chatClient.AsAIAgent(
    name: "agui-client",
    description: "AG-UI Client Agent");

AgentSession session = await agent.CreateSessionAsync();
List<ChatMessage> messages =
[
    new(ChatRole.System, "You are a helpful assistant.")
];

try
{
    while (true)
    {
        // Get user input
        Console.Write("\nUser (:q or quit to exit): ");
        string? message = Console.ReadLine();

        if (string.IsNullOrWhiteSpace(message))
        {
            Console.WriteLine("Request cannot be empty.");
            continue;
        }

        if (message is ":q" or "quit")
        {
            break;
        }

        messages.Add(new ChatMessage(ChatRole.User, message));

        // Stream the response
        bool isFirstUpdate = true;
        string? threadId = null;

        await foreach (AgentResponseUpdate update in agent.RunStreamingAsync(messages, session))
        {
            ChatResponseUpdate chatUpdate = update.AsChatResponseUpdate();

            // First update indicates run started
            if (isFirstUpdate)
            {
                threadId = chatUpdate.ConversationId;
                Console.ForegroundColor = ConsoleColor.Yellow;
                Console.WriteLine($"\n[Run Started - Thread: {chatUpdate.ConversationId}, Run: {chatUpdate.ResponseId}]");
                Console.ResetColor();
                isFirstUpdate = false;
            }

            // Display streaming text content
            foreach (AIContent content in update.Contents)
            {
                if (content is TextContent textContent)
                {
                    Console.ForegroundColor = ConsoleColor.Cyan;
                    Console.Write(textContent.Text);
                    Console.ResetColor();
                }
                else if (content is ErrorContent errorContent)
                {
                    Console.ForegroundColor = ConsoleColor.Red;
                    Console.WriteLine($"\n[Error: {errorContent.Message}]");
                    Console.ResetColor();
                }
            }
        }

        Console.ForegroundColor = ConsoleColor.Green;
        Console.WriteLine($"\n[Run Finished - Thread: {threadId}]");
        Console.ResetColor();
    }
}
catch (Exception ex)
{
    Console.WriteLine($"\nAn error occurred: {ex.Message}");
}


Middleware


using Microsoft.Agents.AI;
using Microsoft.AspNetCore.Http.HttpResults;
using Microsoft.Extensions.AI;
using ServerFunctionApproval;
using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using OpenAI.Responses;

namespace AGUIBackend2
{
    // Copyright (c) Microsoft. All rights reserved.


    /// <summary>
    /// A delegating agent that handles function approval requests on the server side.
    /// Transforms between ToolApprovalRequestContent/ToolApprovalResponseContent
    /// and the request_approval tool call pattern for client communication.
    /// </summary>
    internal sealed class ServerFunctionApprovalAgent : DelegatingAIAgent
    {
        private readonly JsonSerializerOptions _jsonSerializerOptions;

        public ServerFunctionApprovalAgent(AIAgent innerAgent, JsonSerializerOptions jsonSerializerOptions)
            : base(innerAgent)
        {
            this._jsonSerializerOptions = jsonSerializerOptions;
        }

        protected override Task<AgentResponse> RunCoreAsync(
            IEnumerable<ChatMessage> messages,
            AgentSession? session = null,
            AgentRunOptions? options = null,
            CancellationToken cancellationToken = default)
        {
            return this.RunCoreStreamingAsync(messages, session, options, cancellationToken)
                .ToAgentResponseAsync(cancellationToken);
        }

        protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
            IEnumerable<ChatMessage> messages,
            AgentSession? session = null,
            AgentRunOptions? options = null,
            [EnumeratorCancellation] CancellationToken cancellationToken = default)
        {
            // Process and transform incoming approval responses from client, creating a new message list
            var processedMessages = ProcessIncomingFunctionApprovals(messages.ToList(), this._jsonSerializerOptions);

            // Run the inner agent and intercept any approval requests
            await foreach (var update in this.InnerAgent.RunStreamingAsync(
                processedMessages, session, options, cancellationToken).ConfigureAwait(false))
            {
                yield return ProcessOutgoingApprovalRequests(update, this._jsonSerializerOptions);
            }
        }

#pragma warning disable MEAI001 // Type is for evaluation purposes only
        private static ToolApprovalRequestContent ConvertToolCallToApprovalRequest(FunctionCallContent toolCall, JsonSerializerOptions jsonSerializerOptions)
        {
            if (toolCall.Name != "request_approval" || toolCall.Arguments == null)
            {
                throw new InvalidOperationException("Invalid request_approval tool call");
            }

            var request = (toolCall.Arguments.TryGetValue("request", out var reqObj) &&
                reqObj is JsonElement argsElement &&
                argsElement.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalRequest))) is ApprovalRequest approvalRequest &&
                approvalRequest != null ? approvalRequest : null) ?? throw new InvalidOperationException("Failed to deserialize approval request from tool call");
            return new ToolApprovalRequestContent(
                requestId: request.ApprovalId,
                new FunctionCallContent(
                    callId: request.ApprovalId,
                    name: request.FunctionName,
                    arguments: request.FunctionArguments));
        }

        private static ToolApprovalResponseContent ConvertToolResultToApprovalResponse(FunctionResultContent result, ToolApprovalRequestContent approval, JsonSerializerOptions jsonSerializerOptions)
        {
            var approvalResponse = (result.Result is JsonElement je ?
                (ApprovalResponse?)je.Deserialize(jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) :
                result.Result is string str ?
                    (ApprovalResponse?)JsonSerializer.Deserialize(str, jsonSerializerOptions.GetTypeInfo(typeof(ApprovalResponse))) :
                    result.Result as ApprovalResponse) ?? throw new InvalidOperationException("Failed to deserialize approval response from tool result");
            return approval.CreateResponse(approvalResponse.Approved);
        }
#pragma warning restore MEAI001

        private static List<ChatMessage> CopyMessagesUpToIndex(List<ChatMessage> messages, int index)
        {
            var result = new List<ChatMessage>(index);
            for (int i = 0; i < index; i++)
            {
                result.Add(messages[i]);
            }
            return result;
        }

        private static List<AIContent> CopyContentsUpToIndex(IList<AIContent> contents, int index)
        {
            var result = new List<AIContent>(index);
            for (int i = 0; i < index; i++)
            {
                result.Add(contents[i]);
            }
            return result;
        }

        private static List<ChatMessage> ProcessIncomingFunctionApprovals(
            List<ChatMessage> messages,
            JsonSerializerOptions jsonSerializerOptions)
        {
            List<ChatMessage>? result = null;

            // Track approval ID to original call ID mapping
            _ = new Dictionary<string, string>();
#pragma warning disable MEAI001 // Type is for evaluation purposes only and is subject to change or removal in future updates. Suppress this diagnostic to proceed.
            Dictionary<string, ToolApprovalRequestContent> trackedRequestApprovalToolCalls = []; // Remote approvals
            for (int messageIndex = 0; messageIndex < messages.Count; messageIndex++)
            {
                var message = messages[messageIndex];
                List<AIContent>? transformedContents = null;
                for (int j = 0; j < message.Contents.Count; j++)
                {
                    var content = message.Contents[j];
                    if (content is FunctionCallContent { Name: "request_approval" } toolCall)
                    {
                        result ??= CopyMessagesUpToIndex(messages, messageIndex);
                        transformedContents ??= CopyContentsUpToIndex(message.Contents, j);
                        var approvalRequest = ConvertToolCallToApprovalRequest(toolCall, jsonSerializerOptions);
                        transformedContents.Add(approvalRequest);
                        trackedRequestApprovalToolCalls[toolCall.CallId] = approvalRequest;
                        result.Add(new ChatMessage(message.Role, transformedContents)
                        {
                            AuthorName = message.AuthorName,
                            MessageId = message.MessageId,
                            CreatedAt = message.CreatedAt,
                            RawRepresentation = message.RawRepresentation,
                            AdditionalProperties = message.AdditionalProperties
                        });
                    }
                    else if (content is FunctionResultContent toolResult &&
                        trackedRequestApprovalToolCalls.TryGetValue(toolResult.CallId, out var approval))
                    {
                        result ??= CopyMessagesUpToIndex(messages, messageIndex);
                        transformedContents ??= CopyContentsUpToIndex(message.Contents, j);
                        var approvalResponse = ConvertToolResultToApprovalResponse(toolResult, approval, jsonSerializerOptions);
                        transformedContents.Add(approvalResponse);
                        result.Add(new ChatMessage(message.Role, transformedContents)
                        {
                            AuthorName = message.AuthorName,
                            MessageId = message.MessageId,
                            CreatedAt = message.CreatedAt,
                            RawRepresentation = message.RawRepresentation,
                            AdditionalProperties = message.AdditionalProperties
                        });
                    }
                    else
                    {
                        result?.Add(message);
                    }
                }
            }
#pragma warning restore MEAI001

            return result ?? messages;
        }

        private static AgentResponseUpdate ProcessOutgoingApprovalRequests(
            AgentResponseUpdate update,
            JsonSerializerOptions jsonSerializerOptions)
        {
            IList<AIContent>? updatedContents = null;
            for (var i = 0; i < update.Contents.Count; i++)
            {
                var content = update.Contents[i];
#pragma warning disable MEAI001 // Type is for evaluation purposes only
                if (content is ToolApprovalRequestContent request && request.ToolCall is FunctionCallContent functionCall)
                {
                    updatedContents ??= [.. update.Contents];
                    var approvalId = request.RequestId;

                    var approvalData = new ApprovalRequest
                    {
                        ApprovalId = approvalId,
                        FunctionName = functionCall.Name,
                        FunctionArguments = functionCall.Arguments,
                        Message = $"Approve execution of '{functionCall.Name}'?"
                    };

                    updatedContents[i] = new FunctionCallContent(
                        callId: approvalId,
                        name: "request_approval",
                        arguments: new Dictionary<string, object?> { ["request"] = approvalData });
                }
#pragma warning restore MEAI001
            }

            if (updatedContents is not null)
            {
                var chatUpdate = update.AsChatResponseUpdate();
                // Yield a tool call update that represents the approval request
                return new AgentResponseUpdate(new ChatResponseUpdate()
                {
                    Role = chatUpdate.Role,
                    Contents = updatedContents,
                    MessageId = chatUpdate.MessageId,
                    AuthorName = chatUpdate.AuthorName,
                    CreatedAt = chatUpdate.CreatedAt,
                    RawRepresentation = chatUpdate.RawRepresentation,
                    ResponseId = chatUpdate.ResponseId,
                    AdditionalProperties = chatUpdate.AdditionalProperties
                })
                {
                    AgentId = update.AgentId,
                    ContinuationToken = update.ContinuationToken
                };
            }

            return update;
        }
    }
}

namespace ServerFunctionApproval
{
    // Define approval models
    public sealed class ApprovalRequest
    {
        [JsonPropertyName("approval_id")]
        public required string ApprovalId { get; init; }

        [JsonPropertyName("function_name")]
        public required string FunctionName { get; init; }

        [JsonPropertyName("function_arguments")]
        public IDictionary<string, object?>? FunctionArguments { get; init; }

        [JsonPropertyName("message")]
        public string? Message { get; init; }
    }

    public sealed class ApprovalResponse
    {
        [JsonPropertyName("approval_id")]
        public required string ApprovalId { get; init; }

        [JsonPropertyName("approved")]
        public required bool Approved { get; init; }
    }

    [JsonSerializable(typeof(ApprovalRequest))]
    [JsonSerializable(typeof(ApprovalResponse))]
    [JsonSerializable(typeof(Dictionary<string, object?>))]
    public sealed partial class ApprovalJsonContext : JsonSerializerContext;
}

Language/SDK

.NET

Metadata

Metadata

Assignees

No one assigned
    No fields configured for Feature.

    Projects

    Status

    No status

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions