From 5466aff4c976c8a1b00c45d5cfe27104e6847f54 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 19 Jun 2026 12:39:41 +0100 Subject: [PATCH 1/2] Add sample for per-run refreshable MCP authentication headers Adds a Foundry RAPI sample that attaches per-run, refreshable authentication headers to MCP requests using existing primitives: a DelegatingHandler on the MCP transport's HttpClient plus an AsyncLocal run scope. The same agent runs under two contexts, each minting a fresh token, proving the header is per run rather than bound at agent or connection creation time. The handler attaches the bearer only over HTTPS to the MCP server's own origin, logs the non-secret label only, disables cookies, and checks certificate revocation. The README covers security considerations and production notes. Fixes #1631 --- dotnet/agent-framework-dotnet.slnx | 1 + .../Agent_MCP_PerRun_AuthHeaders.csproj | 21 +++ .../Agent_MCP_PerRun_AuthHeaders/Program.cs | 133 ++++++++++++++++++ .../Agent_MCP_PerRun_AuthHeaders/README.md | 88 ++++++++++++ .../02-agents/ModelContextProtocol/README.md | 1 + 5 files changed, 244 insertions(+) create mode 100644 dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Agent_MCP_PerRun_AuthHeaders.csproj create mode 100644 dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Program.cs create mode 100644 dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/README.md diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index c36bc227d93..2fcc86e139b 100644 --- a/dotnet/agent-framework-dotnet.slnx +++ b/dotnet/agent-framework-dotnet.slnx @@ -217,6 +217,7 @@ + diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Agent_MCP_PerRun_AuthHeaders.csproj b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Agent_MCP_PerRun_AuthHeaders.csproj new file mode 100644 index 00000000000..1f596a94a10 --- /dev/null +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Agent_MCP_PerRun_AuthHeaders.csproj @@ -0,0 +1,21 @@ + + + + Exe + net10.0 + + enable + enable + + + + + + + + + + + + + diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Program.cs new file mode 100644 index 00000000000..1f8e4a8dccc --- /dev/null +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Program.cs @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft. All rights reserved. + +// This sample shows how to attach per-run (refreshable) authentication headers to MCP requests. +// +// The agent connects to an MCP server with a custom HttpClient. A DelegatingHandler reads a token +// for the current run from an AsyncLocal scope and stamps it on each outbound MCP request, so a +// short-lived token (for example an OBO or cloud identity token that expires) can be refreshed on +// every run without rebuilding the agent or the MCP connection. +// +// The agent backend is Microsoft Foundry via the Responses API (RAPI). The MCP server is the public +// Microsoft Learn MCP server, which ignores the demonstration token; in production you point the +// handler at your own protected MCP server and mint a real token per run. + +using System.Net.Http.Headers; +using Azure.AI.Projects; +using Azure.Identity; +using Microsoft.Agents.AI; +using Microsoft.Extensions.AI; +using ModelContextProtocol.Client; + +var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT") + ?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set.")); +var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini"; + +var serverEndpoint = new Uri("https://learn.microsoft.com/api/mcp"); + +// Custom HttpClient for the MCP transport. The per-run handler attaches the bearer; the inner +// handler disables cookies (no cross-context state) and checks certificate revocation. +using var httpClient = new HttpClient(new PerRunAuthHeaderHandler(serverEndpoint) +{ + InnerHandler = new HttpClientHandler + { + UseCookies = false, + CheckCertificateRevocationList = true, + }, +}); + +Console.WriteLine($"Connecting to MCP server at {serverEndpoint} ..."); + +await using var mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new() +{ + Endpoint = serverEndpoint, + Name = "Microsoft Learn MCP", + TransportMode = HttpTransportMode.StreamableHttp, +}, httpClient)); + +IList mcpTools = await mcpClient.ListToolsAsync(); +Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}"); + +// Build the agent from Microsoft Foundry using the Responses API (RAPI). +// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production. +// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid +// latency issues, unintended credential probing, and potential security risks from fallback mechanisms. +AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential()) + .AsAIAgent( + model: deploymentName, + instructions: "You answer Microsoft documentation questions using the available tools.", + name: "DocsAgent", + tools: [.. mcpTools.Cast()]); + +// Run the same agent twice under two different contexts. Each run gets a freshly minted token, +// proving the auth header is per-run rather than bound when the agent or MCP connection was created. +await RunForContextAsync(agent, "tenant-a", "How do I create an Azure storage account with az cli?"); +await RunForContextAsync(agent, "tenant-b", "What is Azure Functions?"); + +static async Task RunForContextAsync(AIAgent agent, string label, string prompt) +{ + // Stand-in for a real per-run token (for example an OBO or cloud identity token). + // It carries no PII and is regenerated on every run. The label is non-secret and used for logging. + McpRunScope.Current = new McpRunContext(label, $"{label}.{Guid.NewGuid():N}"); + try + { + Console.WriteLine($"\n=== Run for '{label}' (fresh per-run token) ==="); + Console.WriteLine(await agent.RunAsync(prompt)); + } + finally + { + // Clear the scope so a token never bleeds into later, unrelated work. + McpRunScope.Current = null; + } +} + +/// +/// Carries the context for the current run. is a non-secret identifier safe to +/// log; is the secret that must never be logged or persisted. +/// +internal sealed record McpRunContext(string Label, string Token); + +/// +/// Flows the current to the MCP without +/// threading it through every call. Set it before a run and reset it afterwards. +/// +internal static class McpRunScope +{ + private static readonly AsyncLocal s_current = new(); + + public static McpRunContext? Current + { + get => s_current.Value; + set => s_current.Value = value; + } +} + +/// +/// Attaches the current run's bearer token to outbound MCP requests. The token is read fresh on +/// every request, so refreshing it between runs needs no agent or connection rebuild. +/// +/// +/// Security: the bearer is attached only over HTTPS and only when the request targets the configured +/// MCP server origin, which prevents the credential from leaking over plaintext or to a redirect +/// target on another origin. Only the non-secret label is logged, never the token. +/// +internal sealed class PerRunAuthHeaderHandler(Uri serverEndpoint) : DelegatingHandler +{ + private readonly string _serverOrigin = serverEndpoint.GetLeftPart(UriPartial.Authority); + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + McpRunContext? context = McpRunScope.Current; + Uri? requestUri = request.RequestUri; + + if (context is not null + && requestUri is not null + && requestUri.Scheme == Uri.UriSchemeHttps + && string.Equals(requestUri.GetLeftPart(UriPartial.Authority), this._serverOrigin, StringComparison.OrdinalIgnoreCase)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.Token); + Console.WriteLine($"[mcp-auth] attached bearer for '{context.Label}' -> {request.Method} {requestUri.AbsolutePath}"); + } + + return await base.SendAsync(request, cancellationToken).ConfigureAwait(false); + } +} diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/README.md b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/README.md new file mode 100644 index 00000000000..d19bc7639c0 --- /dev/null +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/README.md @@ -0,0 +1,88 @@ +# Per-Run MCP Authentication Headers + +This sample shows how to attach per-run (refreshable) authentication headers to Model Context +Protocol (MCP) requests using existing Agent Framework primitives. It addresses scenarios where the +header value changes from one run to the next, for example a short-lived On-Behalf-Of (OBO) or cloud +identity token that expires and must be refreshed. + +The agent backend is Microsoft Foundry accessed through the Responses API (RAPI). The MCP server is +the public Microsoft Learn MCP server. + +## What this sample demonstrates + +- A custom `HttpClient` on the MCP transport whose `DelegatingHandler` stamps an `Authorization` + header on every outbound MCP request. +- An `AsyncLocal` scope (`McpRunScope`) that carries the current run's context to the handler, set + immediately before each run and cleared in a `finally` block. +- Running the same agent twice under two different contexts, each with a freshly minted token, so the + header is per-run rather than fixed when the agent or the MCP connection was created. + +Because the handler reads the token fresh on every request, an expiring token is refreshed simply by +placing a new value in scope before the next run. No agent or connection rebuild is required. + +## How it works + +```text +RunForContextAsync sets McpRunScope.Current + -> agent.RunAsync invokes an MCP tool + -> PerRunAuthHeaderHandler reads McpRunScope.Current + -> stamps Authorization: Bearer on the MCP request +RunForContextAsync clears McpRunScope.Current in finally +``` + +The public Microsoft Learn MCP server is anonymous and ignores the demonstration token. In production +you point the handler at your own protected MCP server and mint a real token per run. + +## Prerequisites + +- .NET 10 SDK or later +- A Microsoft Foundry project endpoint and a model deployment +- An authenticated Azure identity (for example, sign in with `az login`) + +Set the following environment variables: + +```powershell +$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project" +$env:FOUNDRY_MODEL="gpt-5.4-mini" +``` + +## Run the sample + +```powershell +dotnet run +``` + +## Security considerations + +This sample is written to demonstrate the pattern safely. When you adapt it, keep these in place: + +- **Never log the token.** Only the non-secret label is printed. Avoid printing the token even in a + masked form. +- **Attach the header over HTTPS only.** The handler skips the header when the request is not HTTPS, + so a credential is never sent over plaintext. +- **Scope the header to the MCP server origin.** The handler attaches the header only when the + request targets the configured server origin (scheme, host, and port). This prevents the token from + being sent to a redirect target on another origin. +- **Reset the scope after each run.** `McpRunScope.Current` is cleared in a `finally` block so a token + does not bleed into later, unrelated work. +- **Disable cookies on the shared handler.** `UseCookies = false` avoids cross-context state on a + shared client, and `CheckCertificateRevocationList = true` validates the server certificate. +- **Use non-identifying labels and tokens.** The labels and tokens here carry no personal data and are + regenerated per run. +- **Do not persist secrets in serialized session state.** Agent session state is serializable, so keep + raw tokens in memory or mint them per run rather than storing them there. + +## Production notes + +- Replace the demonstration token with a real per-request exchange inside the handler, for example an + Azure `TokenCredential`, MSAL OBO flow, or a cloud identity token. Performing the exchange per + request lets expiry self-heal because each request obtains a current token. +- The `AsyncLocal` scope isolates concurrent runs from each other, so parallel runs with different + tokens do not interfere. +- As an alternative carrier, the token can be read from `AgentSession` state by an `AIContextProvider` + that copies it into the scope at the start of each invocation. Remember the serialized-state warning + above and avoid persisting the raw secret. +- For MCP servers that implement standard OAuth, `HttpClientTransportOptions.OAuth` already handles the + authorization and refresh flow, so a custom handler is unnecessary. +- This sample attaches the same header for every tool call in a run. Selecting different headers based + on the specific tool or its arguments is intentionally out of scope here. diff --git a/dotnet/samples/02-agents/ModelContextProtocol/README.md b/dotnet/samples/02-agents/ModelContextProtocol/README.md index 227817c9762..575dc5c61fa 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/README.md @@ -21,6 +21,7 @@ Before you begin, ensure you have the following prerequisites: |---|---| |[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent| |[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent| +|[Agent with per-run MCP authentication headers](./Agent_MCP_PerRun_AuthHeaders/)|This sample demonstrates how to attach per-run, refreshable authentication headers to MCP requests using a custom HttpClient handler and an AsyncLocal scope| |[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly| |[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.| From d4477cd7d1c3329c267590834a10d9fec75d48ff Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 19 Jun 2026 13:00:01 +0100 Subject: [PATCH 2/2] Address PR review: harden redirect handling, nest-safe scope, README env vars Disable AllowAutoRedirect on the shared handler so a redirect cannot carry the bearer past the origin check. Save and restore the prior run scope instead of clearing to null so the helper is safe under nesting. Note the Foundry env vars in the samples folder README row and update the sample README security notes. --- .../Agent_MCP_PerRun_AuthHeaders/Program.cs | 9 ++++++--- .../Agent_MCP_PerRun_AuthHeaders/README.md | 9 +++++---- dotnet/samples/02-agents/ModelContextProtocol/README.md | 2 +- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Program.cs b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Program.cs index 1f8e4a8dccc..cf79cbde368 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Program.cs +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Program.cs @@ -25,12 +25,14 @@ var serverEndpoint = new Uri("https://learn.microsoft.com/api/mcp"); // Custom HttpClient for the MCP transport. The per-run handler attaches the bearer; the inner -// handler disables cookies (no cross-context state) and checks certificate revocation. +// handler disables cookies (no cross-context state), disables auto-redirect (so a redirect cannot +// carry the bearer past the origin re-check), and checks certificate revocation. using var httpClient = new HttpClient(new PerRunAuthHeaderHandler(serverEndpoint) { InnerHandler = new HttpClientHandler { UseCookies = false, + AllowAutoRedirect = false, CheckCertificateRevocationList = true, }, }); @@ -67,6 +69,7 @@ static async Task RunForContextAsync(AIAgent agent, string label, string prompt) { // Stand-in for a real per-run token (for example an OBO or cloud identity token). // It carries no PII and is regenerated on every run. The label is non-secret and used for logging. + McpRunContext? previous = McpRunScope.Current; McpRunScope.Current = new McpRunContext(label, $"{label}.{Guid.NewGuid():N}"); try { @@ -75,8 +78,8 @@ static async Task RunForContextAsync(AIAgent agent, string label, string prompt) } finally { - // Clear the scope so a token never bleeds into later, unrelated work. - McpRunScope.Current = null; + // Restore the prior scope (stack-like) so this is safe to call from within an outer scope. + McpRunScope.Current = previous; } } diff --git a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/README.md b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/README.md index d19bc7639c0..ea18f786cc9 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/README.md @@ -61,10 +61,11 @@ This sample is written to demonstrate the pattern safely. When you adapt it, kee - **Attach the header over HTTPS only.** The handler skips the header when the request is not HTTPS, so a credential is never sent over plaintext. - **Scope the header to the MCP server origin.** The handler attaches the header only when the - request targets the configured server origin (scheme, host, and port). This prevents the token from - being sent to a redirect target on another origin. -- **Reset the scope after each run.** `McpRunScope.Current` is cleared in a `finally` block so a token - does not bleed into later, unrelated work. + request targets the configured server origin (scheme, host, and port). Auto-redirect is also + disabled (`AllowAutoRedirect = false`) so a redirect cannot carry the token to another origin + below the handler before the origin check runs. +- **Reset the scope after each run.** `McpRunScope.Current` is restored to its prior value in a + `finally` block so a token does not bleed into later, unrelated work and nesting stays safe. - **Disable cookies on the shared handler.** `UseCookies = false` avoids cross-context state on a shared client, and `CheckCertificateRevocationList = true` validates the server certificate. - **Use non-identifying labels and tokens.** The labels and tokens here carry no personal data and are diff --git a/dotnet/samples/02-agents/ModelContextProtocol/README.md b/dotnet/samples/02-agents/ModelContextProtocol/README.md index 575dc5c61fa..d9a1b1a4d41 100644 --- a/dotnet/samples/02-agents/ModelContextProtocol/README.md +++ b/dotnet/samples/02-agents/ModelContextProtocol/README.md @@ -21,7 +21,7 @@ Before you begin, ensure you have the following prerequisites: |---|---| |[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent| |[Agent with MCP server tools and authorization](./Agent_MCP_Server_Auth/)|This sample demonstrates how to use MCP Server tools from a protected MCP server with a simple agent| -|[Agent with per-run MCP authentication headers](./Agent_MCP_PerRun_AuthHeaders/)|This sample demonstrates how to attach per-run, refreshable authentication headers to MCP requests using a custom HttpClient handler and an AsyncLocal scope| +|[Agent with per-run MCP authentication headers](./Agent_MCP_PerRun_AuthHeaders/)|This sample demonstrates how to attach per-run, refreshable authentication headers to MCP requests using a custom HttpClient handler and an AsyncLocal scope. Uses Microsoft Foundry (`FOUNDRY_PROJECT_ENDPOINT` / `FOUNDRY_MODEL`) rather than the Azure OpenAI variables in the prerequisites above.| |[Responses Agent with Hosted MCP tool](./ResponseAgent_Hosted_MCP/)|This sample demonstrates how to use the Hosted MCP tool with the Responses Service, where the service invokes any MCP tools directly| |[Agent with long-running MCP task (transparent polling)](./Agent_MCP_LongRunningTask_Client/)|This sample demonstrates how an agent transparently drives a long-running MCP task (SEP-2663) to completion. The wrapper polls the task internally on both `RunAsync` and `RunStreamingAsync` invocations.|