|
| 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 | +} |
0 commit comments