Skip to content

Commit 15df115

Browse files
authored
.NET: Add sample for per-run refreshable MCP authentication headers (#6624)
* 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 * 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.
1 parent a2018b4 commit 15df115

5 files changed

Lines changed: 248 additions & 0 deletions

File tree

dotnet/agent-framework-dotnet.slnx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,7 @@
218218
<Folder Name="/Samples/02-agents/ModelContextProtocol/">
219219
<File Path="samples/02-agents/ModelContextProtocol/README.md" />
220220
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_LongRunningTask_Client/Agent_MCP_LongRunningTask_Client.csproj" />
221+
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Agent_MCP_PerRun_AuthHeaders.csproj" />
221222
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server/Agent_MCP_Server.csproj" />
222223
<Project Path="samples/02-agents/ModelContextProtocol/Agent_MCP_Server_Auth/Agent_MCP_Server_Auth.csproj" />
223224
<Project Path="samples/02-agents/ModelContextProtocol/FoundryAgent_Hosted_MCP/FoundryAgent_Hosted_MCP.csproj" />
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFrameworks>net10.0</TargetFrameworks>
6+
7+
<Nullable>enable</Nullable>
8+
<ImplicitUsings>enable</ImplicitUsings>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<PackageReference Include="Azure.Identity" />
13+
<PackageReference Include="ModelContextProtocol" />
14+
</ItemGroup>
15+
16+
<ItemGroup>
17+
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" />
18+
<ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" />
19+
</ItemGroup>
20+
21+
</Project>
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// Copyright (c) Microsoft. All rights reserved.
2+
3+
// This sample shows how to attach per-run (refreshable) authentication headers to MCP requests.
4+
//
5+
// The agent connects to an MCP server with a custom HttpClient. A DelegatingHandler reads a token
6+
// for the current run from an AsyncLocal scope and stamps it on each outbound MCP request, so a
7+
// short-lived token (for example an OBO or cloud identity token that expires) can be refreshed on
8+
// every run without rebuilding the agent or the MCP connection.
9+
//
10+
// The agent backend is Microsoft Foundry via the Responses API (RAPI). The MCP server is the public
11+
// Microsoft Learn MCP server, which ignores the demonstration token; in production you point the
12+
// handler at your own protected MCP server and mint a real token per run.
13+
14+
using System.Net.Http.Headers;
15+
using Azure.AI.Projects;
16+
using Azure.Identity;
17+
using Microsoft.Agents.AI;
18+
using Microsoft.Extensions.AI;
19+
using ModelContextProtocol.Client;
20+
21+
var projectEndpoint = new Uri(Environment.GetEnvironmentVariable("FOUNDRY_PROJECT_ENDPOINT")
22+
?? throw new InvalidOperationException("FOUNDRY_PROJECT_ENDPOINT is not set."));
23+
var deploymentName = Environment.GetEnvironmentVariable("FOUNDRY_MODEL") ?? "gpt-5.4-mini";
24+
25+
var serverEndpoint = new Uri("https://learn.microsoft.com/api/mcp");
26+
27+
// Custom HttpClient for the MCP transport. The per-run handler attaches the bearer; the inner
28+
// handler disables cookies (no cross-context state), disables auto-redirect (so a redirect cannot
29+
// carry the bearer past the origin re-check), and checks certificate revocation.
30+
using var httpClient = new HttpClient(new PerRunAuthHeaderHandler(serverEndpoint)
31+
{
32+
InnerHandler = new HttpClientHandler
33+
{
34+
UseCookies = false,
35+
AllowAutoRedirect = false,
36+
CheckCertificateRevocationList = true,
37+
},
38+
});
39+
40+
Console.WriteLine($"Connecting to MCP server at {serverEndpoint} ...");
41+
42+
await using var mcpClient = await McpClient.CreateAsync(new HttpClientTransport(new()
43+
{
44+
Endpoint = serverEndpoint,
45+
Name = "Microsoft Learn MCP",
46+
TransportMode = HttpTransportMode.StreamableHttp,
47+
}, httpClient));
48+
49+
IList<McpClientTool> mcpTools = await mcpClient.ListToolsAsync();
50+
Console.WriteLine($"MCP tools available: {string.Join(", ", mcpTools.Select(t => t.Name))}");
51+
52+
// Build the agent from Microsoft Foundry using the Responses API (RAPI).
53+
// WARNING: DefaultAzureCredential is convenient for development but requires careful consideration in production.
54+
// In production, consider using a specific credential (e.g., ManagedIdentityCredential) to avoid
55+
// latency issues, unintended credential probing, and potential security risks from fallback mechanisms.
56+
AIAgent agent = new AIProjectClient(projectEndpoint, new DefaultAzureCredential())
57+
.AsAIAgent(
58+
model: deploymentName,
59+
instructions: "You answer Microsoft documentation questions using the available tools.",
60+
name: "DocsAgent",
61+
tools: [.. mcpTools.Cast<AITool>()]);
62+
63+
// Run the same agent twice under two different contexts. Each run gets a freshly minted token,
64+
// proving the auth header is per-run rather than bound when the agent or MCP connection was created.
65+
await RunForContextAsync(agent, "tenant-a", "How do I create an Azure storage account with az cli?");
66+
await RunForContextAsync(agent, "tenant-b", "What is Azure Functions?");
67+
68+
static async Task RunForContextAsync(AIAgent agent, string label, string prompt)
69+
{
70+
// Stand-in for a real per-run token (for example an OBO or cloud identity token).
71+
// It carries no PII and is regenerated on every run. The label is non-secret and used for logging.
72+
McpRunContext? previous = McpRunScope.Current;
73+
McpRunScope.Current = new McpRunContext(label, $"{label}.{Guid.NewGuid():N}");
74+
try
75+
{
76+
Console.WriteLine($"\n=== Run for '{label}' (fresh per-run token) ===");
77+
Console.WriteLine(await agent.RunAsync(prompt));
78+
}
79+
finally
80+
{
81+
// Restore the prior scope (stack-like) so this is safe to call from within an outer scope.
82+
McpRunScope.Current = previous;
83+
}
84+
}
85+
86+
/// <summary>
87+
/// Carries the context for the current run. <see cref="Label"/> is a non-secret identifier safe to
88+
/// log; <see cref="Token"/> is the secret that must never be logged or persisted.
89+
/// </summary>
90+
internal sealed record McpRunContext(string Label, string Token);
91+
92+
/// <summary>
93+
/// Flows the current <see cref="McpRunContext"/> to the MCP <see cref="DelegatingHandler"/> without
94+
/// threading it through every call. Set it before a run and reset it afterwards.
95+
/// </summary>
96+
internal static class McpRunScope
97+
{
98+
private static readonly AsyncLocal<McpRunContext?> s_current = new();
99+
100+
public static McpRunContext? Current
101+
{
102+
get => s_current.Value;
103+
set => s_current.Value = value;
104+
}
105+
}
106+
107+
/// <summary>
108+
/// Attaches the current run's bearer token to outbound MCP requests. The token is read fresh on
109+
/// every request, so refreshing it between runs needs no agent or connection rebuild.
110+
/// </summary>
111+
/// <remarks>
112+
/// Security: the bearer is attached only over HTTPS and only when the request targets the configured
113+
/// MCP server origin, which prevents the credential from leaking over plaintext or to a redirect
114+
/// target on another origin. Only the non-secret label is logged, never the token.
115+
/// </remarks>
116+
internal sealed class PerRunAuthHeaderHandler(Uri serverEndpoint) : DelegatingHandler
117+
{
118+
private readonly string _serverOrigin = serverEndpoint.GetLeftPart(UriPartial.Authority);
119+
120+
protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
121+
{
122+
McpRunContext? context = McpRunScope.Current;
123+
Uri? requestUri = request.RequestUri;
124+
125+
if (context is not null
126+
&& requestUri is not null
127+
&& requestUri.Scheme == Uri.UriSchemeHttps
128+
&& string.Equals(requestUri.GetLeftPart(UriPartial.Authority), this._serverOrigin, StringComparison.OrdinalIgnoreCase))
129+
{
130+
request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", context.Token);
131+
Console.WriteLine($"[mcp-auth] attached bearer for '{context.Label}' -> {request.Method} {requestUri.AbsolutePath}");
132+
}
133+
134+
return await base.SendAsync(request, cancellationToken).ConfigureAwait(false);
135+
}
136+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
# Per-Run MCP Authentication Headers
2+
3+
This sample shows how to attach per-run (refreshable) authentication headers to Model Context
4+
Protocol (MCP) requests using existing Agent Framework primitives. It addresses scenarios where the
5+
header value changes from one run to the next, for example a short-lived On-Behalf-Of (OBO) or cloud
6+
identity token that expires and must be refreshed.
7+
8+
The agent backend is Microsoft Foundry accessed through the Responses API (RAPI). The MCP server is
9+
the public Microsoft Learn MCP server.
10+
11+
## What this sample demonstrates
12+
13+
- A custom `HttpClient` on the MCP transport whose `DelegatingHandler` stamps an `Authorization`
14+
header on every outbound MCP request.
15+
- An `AsyncLocal` scope (`McpRunScope`) that carries the current run's context to the handler, set
16+
immediately before each run and cleared in a `finally` block.
17+
- Running the same agent twice under two different contexts, each with a freshly minted token, so the
18+
header is per-run rather than fixed when the agent or the MCP connection was created.
19+
20+
Because the handler reads the token fresh on every request, an expiring token is refreshed simply by
21+
placing a new value in scope before the next run. No agent or connection rebuild is required.
22+
23+
## How it works
24+
25+
```text
26+
RunForContextAsync sets McpRunScope.Current
27+
-> agent.RunAsync invokes an MCP tool
28+
-> PerRunAuthHeaderHandler reads McpRunScope.Current
29+
-> stamps Authorization: Bearer <token> on the MCP request
30+
RunForContextAsync clears McpRunScope.Current in finally
31+
```
32+
33+
The public Microsoft Learn MCP server is anonymous and ignores the demonstration token. In production
34+
you point the handler at your own protected MCP server and mint a real token per run.
35+
36+
## Prerequisites
37+
38+
- .NET 10 SDK or later
39+
- A Microsoft Foundry project endpoint and a model deployment
40+
- An authenticated Azure identity (for example, sign in with `az login`)
41+
42+
Set the following environment variables:
43+
44+
```powershell
45+
$env:FOUNDRY_PROJECT_ENDPOINT="https://your-foundry-service.services.ai.azure.com/api/projects/your-foundry-project"
46+
$env:FOUNDRY_MODEL="gpt-5.4-mini"
47+
```
48+
49+
## Run the sample
50+
51+
```powershell
52+
dotnet run
53+
```
54+
55+
## Security considerations
56+
57+
This sample is written to demonstrate the pattern safely. When you adapt it, keep these in place:
58+
59+
- **Never log the token.** Only the non-secret label is printed. Avoid printing the token even in a
60+
masked form.
61+
- **Attach the header over HTTPS only.** The handler skips the header when the request is not HTTPS,
62+
so a credential is never sent over plaintext.
63+
- **Scope the header to the MCP server origin.** The handler attaches the header only when the
64+
request targets the configured server origin (scheme, host, and port). Auto-redirect is also
65+
disabled (`AllowAutoRedirect = false`) so a redirect cannot carry the token to another origin
66+
below the handler before the origin check runs.
67+
- **Reset the scope after each run.** `McpRunScope.Current` is restored to its prior value in a
68+
`finally` block so a token does not bleed into later, unrelated work and nesting stays safe.
69+
- **Disable cookies on the shared handler.** `UseCookies = false` avoids cross-context state on a
70+
shared client, and `CheckCertificateRevocationList = true` validates the server certificate.
71+
- **Use non-identifying labels and tokens.** The labels and tokens here carry no personal data and are
72+
regenerated per run.
73+
- **Do not persist secrets in serialized session state.** Agent session state is serializable, so keep
74+
raw tokens in memory or mint them per run rather than storing them there.
75+
76+
## Production notes
77+
78+
- Replace the demonstration token with a real per-request exchange inside the handler, for example an
79+
Azure `TokenCredential`, MSAL OBO flow, or a cloud identity token. Performing the exchange per
80+
request lets expiry self-heal because each request obtains a current token.
81+
- The `AsyncLocal` scope isolates concurrent runs from each other, so parallel runs with different
82+
tokens do not interfere.
83+
- As an alternative carrier, the token can be read from `AgentSession` state by an `AIContextProvider`
84+
that copies it into the scope at the start of each invocation. Remember the serialized-state warning
85+
above and avoid persisting the raw secret.
86+
- For MCP servers that implement standard OAuth, `HttpClientTransportOptions.OAuth` already handles the
87+
authorization and refresh flow, so a custom handler is unnecessary.
88+
- This sample attaches the same header for every tool call in a run. Selecting different headers based
89+
on the specific tool or its arguments is intentionally out of scope here.

dotnet/samples/02-agents/ModelContextProtocol/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ Before you begin, ensure you have the following prerequisites:
2121
|---|---|
2222
|[Agent with MCP server tools](./Agent_MCP_Server/)|This sample demonstrates how to use MCP server tools with a simple agent|
2323
|[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|
24+
|[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.|
2425
|[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|
2526
|[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.|
2627

0 commit comments

Comments
 (0)