diff --git a/dotnet/agent-framework-dotnet.slnx b/dotnet/agent-framework-dotnet.slnx index f5bcc57b733..345a5d86367 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..cf79cbde368 --- /dev/null +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/Program.cs @@ -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 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. + 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; + } +} + +/// +/// 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..ea18f786cc9 --- /dev/null +++ b/dotnet/samples/02-agents/ModelContextProtocol/Agent_MCP_PerRun_AuthHeaders/README.md @@ -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 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. diff --git a/dotnet/samples/02-agents/ModelContextProtocol/README.md b/dotnet/samples/02-agents/ModelContextProtocol/README.md index 227817c9762..d9a1b1a4d41 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. 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.|