OAuth Identity Passthrough no Foundry Agent Service com MCP Server customizado em .NET - Parte 2
O OAuth Identity Passthrough permite que o Foundry Agent Service chame um MCP Server em nome do usuário logado, preservando a identidade e os scopes que aquela pessoa consentiu. Na Parte 1 percorremos todo esse fluxo pelo portal e pelo Agent Playground, criando os dois App Registrations, expondo a API, publicando o MCP Server no Azure App Service e consentindo como usuário para executar chamadas reais ao MCP pelo chat do agente.
Nesta Parte 2 saímos do Playground e construímos uma .NET Web App de verdade, com um backend em ASP.NET Core conversando com o Foundry e um frontend em HTML, CSS e JavaScript exibindo um chatbot. A ideia é que a própria aplicação envie as mensagens ao agente, detecte quando o Foundry pede consentimento OAuth e conduza o usuário por esse consentimento sem depender do portal.
Antes de escrever qualquer linha de código, vale recuperar rapidamente o que já existe desde a Parte 1. O MCP Server customizado já está publicado no Azure App Service, o App Registration server é o custom-mcp-cars, o App Registration client é o custom-mcp-cars-client e a API expõe os três scopes Cars.Read, Cars.Write e Cars.Delete. O Foundry Agent já foi configurado com um Custom MCP usando OAuth Identity Passthrough, e o teste dentro do portal foi feito lá, então aqui esse teste não será repetido. O foco agora é o consumo programático desse mesmo fluxo.
O primeiro passo prático da Parte 2 é remover a connection antiga que criamos no Foundry durante a Parte 1. O motivo é começar esta parte com uma connection limpa, garantindo que o consentimento seja pedido de novo a partir da aplicação e que o comportamento observado seja fiel a um cenário recém configurado, sem estado de consentimento herdado do Playground. Para deletar uma connection de projeto no Foundry precisamos de cinco informações, que são o subscriptionId, o resourceGroupName, o accountName, o projectName e o connectionName. O accountName corresponde ao recurso do Foundry, o projectName ao projeto dentro dele e o connectionName ao nome que demos à connection MCP na Parte 1.
Com esses dados em mãos, a remoção é feita chamando a Project Connections Delete REST API diretamente pela Azure CLI. O comando a seguir dispara um DELETE no endpoint de management do Azure Resource Manager, apontando exatamente para a connection do projeto.
Com a connection antiga removida, recriamos a Custom MCP connection no Foundry Project, agora do zero. A configuração é a mesma que descrevemos na Parte 1 ao conectar uma MCP tool remota a um agente, apontando para o endpoint público do MCP Server publicado, algo como https://<app-service-name>.azurewebsites.net/mcp, e escolhendo OAuth Identity Passthrough como método de autenticação. O Client ID e o Client Secret continuam sendo os do custom-mcp-cars-client, a Auth URL fica em https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/authorize e a Token URL junto com a Refresh URL apontam ambas para https://login.microsoftonline.com/<tenant-id>/oauth2/v2.0/token, que são os endpoints padrão do Authorization Code Flow do Microsoft Entra ID.
Os scopes voltam a ser configurados no formato completo com o Application ID URI, ficando api://<server-app-client-id>/Cars.Read, api://<server-app-client-id>/Cars.Write, api://<server-app-client-id>/Cars.Delete e, por último, o offline_access, que é o scope OIDC responsável por habilitar o refresh token. Continua valendo o cuidado de separar os scopes por espaço, e não por vírgula. Ao salvar essa nova connection, o Foundry gera um novo Redirect URI próprio, normalmente no formato https://global.consent.azure-apim.net/redirect/<id>, e esse valor precisa ser copiado, porque ele muda em relação ao da Parte 1.
Como o Redirect URI é novo, precisamos registrá-lo no App Registration client. Abrimos o custom-mcp-cars-client, vamos até Authentication e adicionamos essa URL como Redirect URI da plataforma Web, seguindo o procedimento oficial de adição de redirect URI. Vale reforçar o ponto que mais gera confusão nesse cenário, que é o fato de o Redirect URI ser cadastrado sempre no custom-mcp-cars-client, o client, e nunca no custom-mcp-cars, o server. Aproveitamos também para conferir que as permissões delegadas Cars.Read, Cars.Write e Cars.Delete continuam presentes no client, já que é por meio delas que o usuário poderá consentir no momento da primeira chamada.

Ainda no portal do Foundry, há um ajuste importante na configuração da MCP tool do agente antes de partir para a aplicação. Ao abrir a configuração da tool MCP, marcamos a opção Always auto-approve all tools, para que o Foundry possa usar as tools do MCP sem exigir uma aprovação manual adicional a cada chamada. Sem esse ajuste, a web app pode receber erros quando o agente tenta usar uma tool que não foi previamente habilitada, então deixar o auto-approve ativo evita esse tipo de tropeço durante os testes.

Resolvida a parte de portal, entramos na construção da aplicação em si, que é o coração desta Parte 2. Adicionamos à solution um novo projeto Web App chamado Chat.WebApp, que passa a ser a interface final do usuário. O backend em ASP.NET Core conversa com o Foundry usando o SDK do Azure AI Projects, enquanto o frontend em HTML, CSS e JavaScript exibe o chatbot e trata a experiência de consentimento. A partir daqui o usuário não usa mais o Agent Playground, e toda a conversa acontece dentro da nossa aplicação. O projeto referencia quatro pacotes principais, visíveis no arquivo de projeto.
<ItemGroup>
<PackageReference Include="Azure.AI.Extensions.OpenAI" Version="2.0.0" />
<PackageReference Include="Azure.AI.Projects" Version="2.0.1" />
<PackageReference Include="Azure.Identity" Version="1.21.0" />
<PackageReference Include="OpenAI" Version="2.9.1" />
</ItemGroup>O Azure.AI.Projects traz o AIProjectClient e o acesso aos recursos do projeto Foundry, o Azure.AI.Extensions.OpenAI junto com o pacote OpenAI dão suporte ao Responses API que usamos para conversar com o agente, e o Azure.Identity fornece o DefaultAzureCredential que autentica a aplicação no Foundry.
A composição da aplicação vive no Program.cs, e é nele que registramos o AIProjectClient e amarramos a autenticação da aplicação ao Foundry.
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddControllersWithViews();
builder.Services.Configure<FoundryOptions>(
builder.Configuration.GetSection("Foundry"));
builder.Services.AddSingleton(sp =>
{
var configuration = sp.GetRequiredService<IConfiguration>();
var projectEndpoint = configuration["Foundry:ProjectEndpoint"];
if (string.IsNullOrWhiteSpace(projectEndpoint))
throw new InvalidOperationException("Missing configuration: Foundry:ProjectEndpoint");
return new AIProjectClient(
endpoint: new Uri(projectEndpoint),
tokenProvider: new DefaultAzureCredential());
});
builder.Services.AddScoped<FoundryChatService>();
var app = builder.Build();- AddControllersWithViews() habilita os controllers MVC e as Views Razor, servindo tanto a API interna do chat quanto a página que renderiza o chatbot.
Configure<FoundryOptions>() vincula a seção Foundry da configuração à classe de opções tipada FoundryOptions, seguindo o options pattern do ASP.NET Core. - AddSingleton<AIProjectClient>() registra o AIProjectClient como singleton, construído a partir do endpoint do projeto e autenticado com o DefaultAzureCredential.
- AddScoped<FoundryChatService>() registra o serviço de aplicação que concentra toda a conversa com o Foundry, injetado depois no controller.
Aqui está o ponto conceitual mais importante de toda a Parte 2, então vale insistir nele. O backend usa o DefaultAzureCredential para autenticar a própria aplicação no plano de dados do Foundry. Em desenvolvimento local, essa credencial resolve automaticamente a identidade do desenvolvedor logado na Azure CLI ou no Visual Studio Code, e em produção o mesmo código passaria a usar uma Managed Identity sem alteração. Essa identidade da aplicação é o que dá acesso ao projeto Foundry para criar conversas e respostas, e ela é diferente da identidade delegada do usuário que trafega até o MCP Server via OAuth Identity Passthrough. Em outras palavras, o DefaultAzureCredential autentica o app no Foundry, enquanto o consentimento OAuth que veremos adiante autoriza o Foundry a chamar o MCP em nome do usuário.
A configuração consumida por esse código fica na seção Foundry do appsettings.json e aponta para o endpoint do projeto e para o nome do agente que já existe no Foundry.
{
"Foundry": {
"ProjectEndpoint": "https://<foundry-resource-name>.services.ai.azure.com/api/projects/<project-name>",
"AgentName": "my-agent"
}
}Essa seção é materializada em uma classe de opções simples, com as duas propriedades obrigatórias que o serviço precisa para funcionar.
public sealed class FoundryOptions
{
public required string ProjectEndpoint { get; init; }
public required string AgentName { get; init; }
}FoundryOptions representa a configuração tipada do Foundry, expondo o ProjectEndpoint usado para construir o AIProjectClient e o AgentName usado para direcionar as respostas ao agente correto.
Antes de olhar o serviço, precisamos entender o contrato de dados que ele troca com o frontend, definido em um único arquivo de records. Esses tipos descrevem o pedido de criação de conversa, o envio de mensagem, a continuação após o consentimento e a resposta unificada que o frontend interpreta.
public sealed record CreateConversationResponse(string ConversationId);
public sealed record SendMessageRequest(string Message);
public sealed record ContinueAfterConsentRequest(string Message, string PreviousResponseId);
public static class ChatStatus
{
public const string Completed = "completed";
public const string OAuthConsentRequired = "oauth_consent_required";
}
public sealed record SendMessageResponse(
string ConversationId,
string ResponseId,
string Status,
string? OutputText = null,
string? ConsentLink = null,
string? ConsentServerLabel = null);- CreateConversationResponse devolve ao frontend o ConversationId recém criado, que passa a identificar toda a conversa.
- SendMessageRequest e ContinueAfterConsentRequest carregam a mensagem do usuário, sendo que o segundo ainda transporta o PreviousResponseId necessário para retomar a chamada MCP depois do consentimento.
- ChatStatus define as duas situações possíveis de uma resposta, Completed quando o agente já respondeu e OAuthConsentRequired quando o Foundry pede consentimento antes de continuar.
- SendMessageResponse é a resposta unificada, que ora traz o OutputText final, ora traz o ConsentLink e o ConsentServerLabel usados para montar o card de consentimento na tela.
Com o contrato definido, chegamos ao FoundryChatService, que é onde a aplicação realmente fala com o Foundry usando o Responses API. O serviço recebe o AIProjectClient e as FoundryOptions por injeção de dependência e guarda ambos para uso nas operações seguintes.
public sealed class FoundryChatService
{
private readonly AIProjectClient _projectClient;
private readonly FoundryOptions _options;
public FoundryChatService(
AIProjectClient projectClient,
IOptions<FoundryOptions> options)
{
_projectClient = projectClient;
_options = options.Value;
}
A primeira responsabilidade do serviço é criar uma conversa no projeto, que funciona como o contêiner de estado onde as mensagens e respostas vão sendo acumuladas.
public async Task<CreateConversationResponse> CreateConversationAsync()
{
var conversationsClient =
_projectClient.ProjectOpenAIClient.GetProjectConversationsClient();
ProjectConversation conversation =
await conversationsClient.CreateProjectConversationAsync(
new ProjectConversationCreationOptions());
return new CreateConversationResponse(ConversationId: conversation.Id);
}CreateConversationAsync() obtém o client de conversas do projeto, cria uma nova ProjectConversation e devolve o identificador dela para o frontend, que passará a usar esse ConversationId em todas as mensagens seguintes.
O envio de uma mensagem comum acontece no método a seguir, que direciona a solicitação ao agente configurado e trata a resposta de forma uniforme.
public async Task<SendMessageResponse> SendMessageAsync(
string conversationId,
SendMessageRequest request)
{
if (string.IsNullOrWhiteSpace(conversationId))
throw new ArgumentException("Conversation id is required.", nameof(conversationId));
if (string.IsNullOrWhiteSpace(request.Message))
throw new ArgumentException("Message is required.", nameof(request));
ProjectResponsesClient responsesClient =
_projectClient.ProjectOpenAIClient
.GetProjectResponsesClientForAgent(_options.AgentName);
CreateResponseOptions responseOptions = new()
{
AgentConversationId = conversationId
};
responseOptions.InputItems.Add(
ResponseItem.CreateUserMessageItem(request.Message));
ResponseResult response =
await responsesClient.CreateResponseAsync(responseOptions);
return BuildResponse(conversationId, response);
}SendMessageAsync() valida a entrada, obtém um ProjectResponsesClient vinculado ao agente pelo AgentName, monta um CreateResponseOptions apontando para a conversa atual, adiciona a mensagem do usuário como input item e chama o Responses API, delegando a interpretação do resultado ao BuildResponse.
O ponto que torna esse fluxo especial é o que acontece quando o agente precisa usar a MCP tool pela primeira vez. Nesse instante o Foundry não devolve a resposta final, e sim um item de consentimento, sinalizando que o usuário ainda não autorizou o custom-mcp-cars-client a acessar o MCP em seu nome. O método ContinueAfterConsentAsync existe justamente para retomar a conversa depois que esse consentimento foi concedido.
public async Task<SendMessageResponse> ContinueAfterConsentAsync(
string conversationId,
ContinueAfterConsentRequest request)
{
if (string.IsNullOrWhiteSpace(conversationId))
throw new ArgumentException("Conversation id is required.", nameof(conversationId));
if (string.IsNullOrWhiteSpace(request.Message))
throw new ArgumentException("Message is required.", nameof(request));
if (string.IsNullOrWhiteSpace(request.PreviousResponseId))
throw new ArgumentException("Previous response id is required.", nameof(request));
ProjectResponsesClient responsesClient =
_projectClient.ProjectOpenAIClient
.GetProjectResponsesClientForAgent(_options.AgentName);
CreateResponseOptions responseOptions = new()
{
PreviousResponseId = request.PreviousResponseId,
ToolChoice = ResponseToolChoice.CreateRequiredChoice()
};
responseOptions.InputItems.Add(
ResponseItem.CreateUserMessageItem(request.Message));
ResponseResult response =
await responsesClient.CreateResponseAsync(responseOptions);
return BuildResponse(conversationId, response);
}ContinueAfterConsentAsync() retoma o fluxo após o consentimento usando o PreviousResponseId da resposta que pediu consentimento e forçando o uso da tool com ResponseToolChoice.CreateRequiredChoice(), de modo que o Foundry volte a executar a chamada MCP agora com a identidade do usuário já consentida, encadeando a nova resposta a partir da anterior.
A leitura da resposta do Foundry fica isolada em um único método privado, que decide se o retorno é um pedido de consentimento ou a resposta final do agente.
private static SendMessageResponse BuildResponse(
string conversationId,
ResponseResult response)
{
foreach (ResponseItem item in response.OutputItems)
{
if (item.AsAgentResponseItem() is OAuthConsentRequestResponseItem consentRequest)
{
return new SendMessageResponse(
ConversationId: conversationId,
ResponseId: response.Id,
Status: ChatStatus.OAuthConsentRequired,
ConsentLink: consentRequest.ConsentLink?.ToString(),
ConsentServerLabel: consentRequest.ServerLabel);
}
}
return new SendMessageResponse(
ConversationId: conversationId,
ResponseId: response.Id,
Status: ChatStatus.Completed,
OutputText: response.GetOutputText()?.Trim());BuildResponse() percorre os output items da resposta e, ao encontrar um OAuthConsentRequestResponseItem, devolve o status OAuthConsentRequired junto com o ConsentLink e o ServerLabel, e quando nenhum item de consentimento aparece, devolve o status Completed com o texto final produzido pelo agente. É esse ResponseId retornado que o frontend guarda para conseguir chamar a continuação depois do consentimento.
Vale registrar um detalhe do arquivo do serviço, que traz a diretiva #pragma warning disable OPENAI001 no topo. Isso acontece porque parte dos tipos do Responses API ainda está marcada como experimental no SDK, e a diretiva apenas silencia esse aviso de recurso em avaliação, sem alterar o comportamento em runtime.
O serviço é exposto ao frontend por um controller de API enxuto, com três endpoints que espelham exatamente os três métodos que acabamos de ver.
[ApiController]
[Route("api/chat")]
public sealed class ChatApiController : ControllerBase
{
private readonly FoundryChatService _chatService;
public ChatApiController(FoundryChatService chatService)
{
_chatService = chatService;
}
[HttpPost("conversations")]
public async Task<IActionResult> CreateConversation()
{
CreateConversationResponse response =
await _chatService.CreateConversationAsync();
return Created(
$"/api/chat/conversations/{response.ConversationId}",
response);
}
[HttpPost("conversations/{conversationId}/responses")]
public async Task<ActionResult<SendMessageResponse>> SendMessage(
[FromRoute] string conversationId,
[FromBody] SendMessageRequest request)
{
SendMessageResponse response =
await _chatService.SendMessageAsync(conversationId, request);
return Ok(response);
}
[HttpPost("conversations/{conversationId}/responses/continue")]
public async Task<ActionResult<SendMessageResponse>> ContinueAfterConsent(
[FromRoute] string conversationId,
[FromBody] ContinueAfterConsentRequest request)
{
SendMessageResponse response =
await _chatService.ContinueAfterConsentAsync(conversationId, request);
return Ok(response);
}
}- CreateConversation() cria uma nova conversa e responde 201 com o ConversationId no corpo e no header de localização.
- SendMessage() recebe o texto do usuário para uma conversa existente e devolve a resposta do agente, que pode ser o texto final ou o pedido de consentimento.
- ContinueAfterConsent() recebe a mensagem original junto com o PreviousResponseId e retoma a chamada MCP depois que o usuário consentiu, devolvendo então o resultado real da tool.
Do lado do navegador, a página do chat concentra a lógica de conversa em JavaScript, e o trecho mais relevante é o que envia a mensagem e decide o que fazer com a resposta. Quando o usuário manda um texto, o script faz um POST para o endpoint de responses e entrega o resultado para uma função que interpreta o status.
const res = await fetch(`/api/chat/conversations/${encodeURIComponent(conversationId)}/responses`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message: text })
});
removeTyping(typingId);
if (!res.ok) throw new Error(`Server error: ${res.status}`);
const data = await res.json();
handleChatResponse(data, text);Essa chamada envia a mensagem digitada para a conversa atual e passa a resposta recebida, junto com o texto original, para a função handleChatResponse, que precisa saber o texto para conseguir reenviá-lo caso o consentimento seja necessário.
function handleChatResponse(data, originalMessage) {
if (data && data.status === 'oauth_consent_required') {
appendConsentCard(data, originalMessage);
} else {
appendMessage('assistant', (data && data.outputText) || '');
}
scrollToBottom();
}openConsentAndContinue() abre o ConsentLink em um popup, troca os botões do card por um estado de espera com a opção de continuar manualmente e, ao detectar que o popup foi fechado, chama automaticamente a continuação da conversa. O botão I've finished, continue existe como caminho alternativo para os casos em que o popup é bloqueado pelo navegador.
O fechamento do ciclo acontece na continuação, que chama o endpoint de continue enviando a mensagem original e o PreviousResponseId, exatamente os dados que o backend precisa para retomar a chamada MCP com a identidade já consentida.
async function continueAfterConsent(previousResponseId, message, wrapper) {
if (wrapper.dataset.continuing === '1') return;
wrapper.dataset.continuing = '1';
wrapper.remove();
hideError();
const typingId = appendTyping();
try {
const res = await fetch(`/api/chat/conversations/${encodeURIComponent(conversationId)}/responses/continue`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ message, previousResponseId })
});
removeTyping(typingId);
if (!res.ok) throw new Error(`Server error: ${res.status}`);
const data = await res.json();
handleChatResponse(data, message);
} catch (e) {
removeTyping(typingId);
showError('Failed to continue after consent: ' + e.message);
} finally {
scrollToBottom();
}
}
continueAfterConsent() evita continuações duplicadas com uma trava simples, remove o card de consentimento da tela, chama o endpoint de continue com a mensagem original e o previousResponseId e reaproveita o `handleChatResponse` para renderizar o resultado, que agora deve ser a resposta real vinda do MCP Server.
Com backend e frontend prontos, é hora de testar tudo rodando localmente.
dotnet runO desenvolvedor sobe a web app na própria máquina, garantindo antes que está logado na Azure CLI para que o DefaultAzureCredential resolva a identidade corretamente, e abre o chat no navegador. Na caixa de mensagem, envia algo simples que dependa do MCP, como listar todos os carros, digitando liste todos os carros. Como a connection foi recriada e o consentimento ainda não foi concedido nesta aplicação, o Foundry devolve um OAuthConsentRequestResponseItem, e a web app exibe o item de consentimento na conversa em vez da resposta final.

O usuário então clica em Open consent, e o navegador abre a tela de consentimento do Microsoft Entra ID, onde ele autoriza o client usado pelo Foundry a acessar o MCP Server em seu nome. A experiência de consentimento passa pelas telas de permissões solicitadas e pela confirmação final exigida pelo Foundry, momento em que o Microsoft Entra ID devolve o authorization code que o Foundry troca por access token e refresh token.



Concluído o consentimento, o usuário volta para a aplicação. Se o popup foi fechado, a continuação dispara sozinha, e se preferir, o usuário pode clicar em I've finished, continue para retomar manualmente. Nesse ponto a web app chama o endpoint de continue, passando o PreviousResponseId, e o Foundry finalmente executa a tool cars_list em nome do usuário.

A resposta então retorna à conversa, e a lista de carros aparece dentro do próprio chat da aplicação, mostrando que o fluxo delegado funcionou de ponta a ponta fora do Playground. A partir dessa primeira autorização, o usuário não precisa consentir de novo, e as próximas mensagens que dependem do MCP são atendidas diretamente.

Chegamos assim ao fim da Parte 2, na qual removemos a connection antiga do Foundry pela Project Connections Delete REST API, recriamos a Custom MCP connection com OAuth Identity Passthrough, atualizamos o Redirect URI no custom-mcp-cars-client, habilitamos o auto-approve das tools no agente e construímos a Chat.WebApp com backend em ASP.NET Core e frontend em HTML, CSS e JavaScript. A grande diferença em relação à Parte 1 é que o fluxo agora acontece dentro de uma aplicação real, com o Foundry Playground deixando de ser o ponto de teste e a nossa web app assumindo o controle da experiência do usuário. O chatbot consegue detectar o pedido de consentimento, conduzir o usuário pelo fluxo OAuth e continuar a conversa, enquanto o MCP Server segue sendo chamado em nome do usuário via OAuth Identity Passthrough, com cada tool rodando sob os scopes reais que aquela pessoa consentiu.
Você já pode baixar o projeto por esse link, e não esquece de me seguir no LinkedIn!
Até a próxima, abraços!