-
Notifications
You must be signed in to change notification settings - Fork 2k
.NET: Add sample for per-run refreshable MCP authentication headers #6624
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
rogerbarreto
wants to merge
3
commits into
microsoft:main
Choose a base branch
from
rogerbarreto:rogerbarreto/investigate-per-run-mcp-auth-headers
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from 1 commit
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
5466aff
Add sample for per-run refreshable MCP authentication headers
rogerbarreto d4477cd
Address PR review: harden redirect handling, nest-safe scope, README …
rogerbarreto 73ddfdb
Merge branch 'main' into rogerbarreto/investigate-per-run-mcp-auth-he…
rogerbarreto File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
21 changes: 21 additions & 0 deletions
21
...nts/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Agent_MCP_PerRun_AuthHeaders.csproj
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| <Project Sdk="Microsoft.NET.Sdk"> | ||
|
|
||
| <PropertyGroup> | ||
| <OutputType>Exe</OutputType> | ||
| <TargetFrameworks>net10.0</TargetFrameworks> | ||
|
|
||
| <Nullable>enable</Nullable> | ||
| <ImplicitUsings>enable</ImplicitUsings> | ||
| </PropertyGroup> | ||
|
|
||
| <ItemGroup> | ||
| <PackageReference Include="Azure.Identity" /> | ||
| <PackageReference Include="ModelContextProtocol" /> | ||
| </ItemGroup> | ||
|
|
||
| <ItemGroup> | ||
| <ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI\Microsoft.Agents.AI.csproj" /> | ||
| <ProjectReference Include="..\..\..\..\src\Microsoft.Agents.AI.Foundry\Microsoft.Agents.AI.Foundry.csproj" /> | ||
| </ItemGroup> | ||
|
|
||
| </Project> |
133 changes: 133 additions & 0 deletions
133
dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Program.cs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<McpClientTool> 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<AITool>()]); | ||
|
|
||
| // 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; | ||
| } | ||
|
rogerbarreto marked this conversation as resolved.
|
||
| } | ||
|
|
||
| /// <summary> | ||
| /// Carries the context for the current run. <see cref="Label"/> is a non-secret identifier safe to | ||
| /// log; <see cref="Token"/> is the secret that must never be logged or persisted. | ||
| /// </summary> | ||
| internal sealed record McpRunContext(string Label, string Token); | ||
|
|
||
| /// <summary> | ||
| /// Flows the current <see cref="McpRunContext"/> to the MCP <see cref="DelegatingHandler"/> without | ||
| /// threading it through every call. Set it before a run and reset it afterwards. | ||
| /// </summary> | ||
| internal static class McpRunScope | ||
| { | ||
| private static readonly AsyncLocal<McpRunContext?> s_current = new(); | ||
|
|
||
| public static McpRunContext? Current | ||
| { | ||
| get => s_current.Value; | ||
| set => s_current.Value = value; | ||
| } | ||
| } | ||
|
|
||
| /// <summary> | ||
| /// 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. | ||
| /// </summary> | ||
| /// <remarks> | ||
| /// 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. | ||
| /// </remarks> | ||
| internal sealed class PerRunAuthHeaderHandler(Uri serverEndpoint) : DelegatingHandler | ||
| { | ||
| private readonly string _serverOrigin = serverEndpoint.GetLeftPart(UriPartial.Authority); | ||
|
|
||
| protected override async Task<HttpResponseMessage> 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); | ||
| } | ||
| } | ||
88 changes: 88 additions & 0 deletions
88
...t/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/README.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 <token> 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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.