chore: import upstream snapshot with attribution
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
dotnet-build-and-test / dotnet-test-functions (push) Has been cancelled
dotnet-build-and-test / paths-filter (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Debug, windows-latest, net9.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, ubuntu-latest, net8.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-build (Release, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, ubuntu-latest, net10.0) (push) Has been cancelled
dotnet-build-and-test / dotnet-test (Release, integration, true, windows-latest, net472) (push) Has been cancelled
dotnet-build-and-test / dotnet-foundry-hosted-it (push) Has been cancelled
dotnet-build-and-test / dotnet-build-and-test-check (push) Has been cancelled
dotnet-build-and-test / Integration Test Report (push) Has been cancelled
CodeQL / Analyze (csharp) (push) Has been cancelled
CodeQL / Analyze (python) (push) Has been cancelled
This commit is contained in:
+21
@@ -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>
|
||||
+136
@@ -0,0 +1,136 @@
|
||||
// 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), 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,
|
||||
},
|
||||
});
|
||||
|
||||
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.
|
||||
McpRunContext? previous = McpRunScope.Current;
|
||||
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
|
||||
{
|
||||
// Restore the prior scope (stack-like) so this is safe to call from within an outer scope.
|
||||
McpRunScope.Current = previous;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
# 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). 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
|
||||
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.
|
||||
Reference in New Issue
Block a user