-
Notifications
You must be signed in to change notification settings - Fork 13
Expand file tree
/
Copy pathMcpToolServerConfigurationService.cs
More file actions
663 lines (587 loc) · 31.2 KB
/
Copy pathMcpToolServerConfigurationService.cs
File metadata and controls
663 lines (587 loc) · 31.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
namespace Microsoft.Agents.A365.Tooling.Services
{
using System;
using System.Collections.Generic;
using System.IO;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Reflection;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Agents.A365.Runtime;
using Microsoft.Agents.A365.Tooling.Handlers;
using Microsoft.Agents.A365.Tooling.Models;
using Microsoft.Agents.A365.Tooling.Utils;
using Microsoft.Agents.Builder;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using ModelContextProtocol.Client;
using RuntimeUtility = Microsoft.Agents.A365.Runtime.Utils.Utility;
/// <summary>
/// Provides services for managing MCP server configurations.
/// </summary>
public partial class McpToolServerConfigurationService : IMcpToolServerConfigurationService
{
private readonly ILogger<IMcpToolServerConfigurationService> _logger;
private readonly IConfiguration _configuration;
private readonly ILoggerFactory? _loggerFactory;
private readonly IHttpClientFactory _httpClientFactory;
/// <summary>
/// Initializes a new instance of the <see cref="McpToolServerConfigurationService"/> class.
/// </summary>
/// <param name="logger">Logger instance for logging.</param>
/// <param name="configuration">Configuration collection.</param>
/// <param name="serviceProvider">Service provider</param>
/// <param name="httpClientFactory">HTTP client factory for creating HTTP clients.</param>
public McpToolServerConfigurationService(ILogger<IMcpToolServerConfigurationService> logger, IConfiguration configuration, IServiceProvider serviceProvider, IHttpClientFactory httpClientFactory)
{
this._configuration = configuration;
this._logger = logger;
this._loggerFactory = serviceProvider.GetService<ILoggerFactory>();
this._httpClientFactory = httpClientFactory;
}
/// <inheritdoc/>
public virtual async Task<List<MCPServerConfig>> ListToolServersAsync(string agentInstanceId, string authToken)
{
return await ListToolServersAsync(agentInstanceId, authToken, new ToolOptions());
}
/// <inheritdoc/>
public virtual async Task<List<MCPServerConfig>> ListToolServersAsync(string agentInstanceId, string authToken, ToolOptions toolOptions)
{
return IsDevScenario() ? GetMCPServersFromManifest() : await GetMCPServerFromToolingGatewayAsync(agentInstanceId, authToken, toolOptions);
}
/// <summary>
/// Gets the list of MCP servers and attaches per-audience Bearer tokens to each server's
/// <see cref="MCPServerConfig.Headers"/> dictionary before returning.
/// V1 servers share the ATG-scoped token; V2 servers receive audience-specific tokens.
/// </summary>
internal virtual async Task<List<MCPServerConfig>> ListToolServersWithTokensAsync(
string agentInstanceId,
string authToken,
IMcpTokenProvider tokenProvider,
ToolOptions toolOptions,
CancellationToken cancellationToken = default)
{
var servers = await ListToolServersAsync(agentInstanceId, authToken, toolOptions).ConfigureAwait(false);
await AttachPerAudienceTokensAsync(servers, tokenProvider, cancellationToken).ConfigureAwait(false);
return servers;
}
/// <inheritdoc/>
public virtual async Task<IList<McpClientTool>> GetMcpClientToolsAsync(
ITurnContext turnContext,
MCPServerConfig mCPServerConfig,
string authToken,
ToolOptions toolOptions)
{
try
{
// Validate the server name
if (string.IsNullOrWhiteSpace(mCPServerConfig.mcpServerName))
{
throw new ArgumentException("MCP Server name cannot be null or empty", nameof(mCPServerConfig.mcpServerName));
}
// Prefer the per-server token injected by AttachPerAudienceTokensAsync (V2 path).
// Fall back to the caller-supplied authToken for V1 servers and dev scenarios.
var effectiveToken = ResolveEffectiveToken(mCPServerConfig, authToken);
this._logger.LogInformation($"Creating custom MCP client for: {mCPServerConfig.mcpServerName} at {mCPServerConfig.url}");
// Use custom HTTP-based implementation since MCP client library doesn't work
var mcpClient = await CreateMcpClientWithAuthHandlers(turnContext, new Uri(mCPServerConfig.url), effectiveToken, toolOptions);
var tools = await mcpClient.ListToolsAsync();
this._logger.LogInformation($"Successfully retrieved {tools.Count} tools from {mCPServerConfig.mcpServerName}");
return tools;
}
catch (HttpRequestException httpEx)
{
throw new InvalidOperationException($"HTTP error connecting to MCP server '{mCPServerConfig.mcpServerName}' at '{mCPServerConfig.url}': {httpEx.Message}", httpEx);
}
catch (ArgumentException argEx)
{
throw new InvalidOperationException($"Invalid configuration for MCP server '{mCPServerConfig.mcpServerName}': {argEx.Message}", argEx);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to get tools from MCP server '{mCPServerConfig.mcpServerName}' at '{mCPServerConfig.url}': {ex.Message}", ex);
}
}
/// <inheritdoc/>
public async Task<OperationResult> SendChatHistoryAsync(ITurnContext turnContext, ChatHistoryMessage[] chatHistoryMessages, CancellationToken cancellationToken = default)
{
return await SendChatHistoryAsync(turnContext, chatHistoryMessages, new ToolOptions { UserAgentConfiguration = Agent365SdkUserAgentConfiguration.Instance }, cancellationToken);
}
/// <inheritdoc/>
public async Task<OperationResult> SendChatHistoryAsync(ITurnContext turnContext, ChatHistoryMessage[] chatHistoryMessages, ToolOptions toolOptions, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(turnContext, nameof(turnContext));
ArgumentNullException.ThrowIfNull(chatHistoryMessages, nameof(chatHistoryMessages));
cancellationToken.ThrowIfCancellationRequested();
// Extract required information from turn context
var conversationId = turnContext.Activity?.Conversation?.Id ?? throw new InvalidOperationException("Conversation ID is required but not found in turn context");
var messageId = turnContext.Activity?.Id ?? throw new InvalidOperationException("Message ID is required but not found in turn context");
var userMessage = turnContext.Activity?.Text ?? throw new InvalidOperationException("User message is required but not found in turn context");
// Get the endpoint URL
var endpoint = Utility.GetChatHistoryEndpoint(this._configuration);
this._logger.LogInformation($"Sending chat history to endpoint: {endpoint}");
// Create the request payload
var request = new ChatMessageRequest(conversationId, messageId, userMessage, chatHistoryMessages);
try
{
var userAgentConfiguration = toolOptions?.UserAgentConfiguration ?? Agent365SdkUserAgentConfiguration.Instance;
var httpClient = RuntimeUtility.GetDefaultHttpClient(httpClientFactory: this._httpClientFactory, userAgentConfiguration: userAgentConfiguration);
var jsonContent = JsonSerializer.Serialize(request);
using var content = new StringContent(jsonContent, Encoding.UTF8, "application/json");
using var response = await httpClient.PostAsync(endpoint, content, cancellationToken);
response.EnsureSuccessStatusCode();
this._logger.LogInformation("Successfully sent chat history to MCP platform");
return OperationResult.Success;
}
catch (HttpRequestException httpEx)
{
this._logger.LogError(httpEx, "HTTP error sending chat history to '{Endpoint}': {Message}", endpoint, httpEx.Message);
return OperationResult.Failed(new OperationError(httpEx));
}
catch (TaskCanceledException tcEx)
{
this._logger.LogError(tcEx, "Request timeout sending chat history to '{Endpoint}': {Message}", endpoint, tcEx.Message);
return OperationResult.Failed(new OperationError(tcEx));
}
catch (Exception ex)
{
this._logger.LogError(ex, "Failed to send chat history to '{Endpoint}': {Message}", endpoint, ex.Message);
return OperationResult.Failed(new OperationError(ex));
}
}
private async Task<List<MCPServerConfig>> GetMCPServerFromToolingGatewayAsync(
string agentInstanceId, string authToken, ToolOptions toolOptions)
{
string configEndpoint = Utility.GetToolingGatewayForDigitalWorker(agentInstanceId, this._configuration);
if (string.IsNullOrWhiteSpace(configEndpoint))
{
throw new InvalidOperationException("Configuration endpoint is not configured");
}
try
{
var userAgentConfiguration = toolOptions?.UserAgentConfiguration ?? Agent365SdkUserAgentConfiguration.Instance;
var httpClient = RuntimeUtility.GetDefaultHttpClient(httpClientFactory: this._httpClientFactory, userAgentConfiguration: userAgentConfiguration);
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", authToken);
var response = await httpClient.GetStringAsync(configEndpoint);
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
};
// Single parse approach
var jsonDoc = JsonSerializer.Deserialize<JsonElement>(response, options);
IEnumerable<JsonElement> serverElements = jsonDoc.ValueKind switch
{
JsonValueKind.Array => jsonDoc.EnumerateArray(),
JsonValueKind.Object when jsonDoc.TryGetProperty("mcpServers", out var servers)
&& servers.ValueKind == JsonValueKind.Array
=> servers.EnumerateArray(),
_ => throw new InvalidOperationException(
$"Unexpected JSON structure. Expected array or object with 'mcpServers' property, got {jsonDoc.ValueKind}")
};
return serverElements
.Select(ParseServerConfig)
.Where(config => config != null)
.ToList()!;
}
catch (HttpRequestException httpEx)
{
throw new InvalidOperationException(
$"Failed to retrieve configuration from '{configEndpoint}': {httpEx.Message}", httpEx);
}
catch (JsonException jsonEx)
{
throw new InvalidOperationException(
$"Failed to parse configuration response from '{configEndpoint}': {jsonEx.Message}", jsonEx);
}
}
/// <summary>
/// Parses a JSON element into an MCPServerConfig object.
/// </summary>
/// <param name="serverElement">The JSON element containing server configuration</param>
/// <returns>MCPServerConfig object or null if parsing fails</returns>
private static MCPServerConfig? ParseServerConfig(JsonElement serverElement)
{
try
{
string? name = null;
string? endpoint = null;
string? id = null;
string? scope = null;
string? audience = null;
string? publisher = null;
if (serverElement.TryGetProperty("mcpServerName", out var nameElement) &&
nameElement.ValueKind == JsonValueKind.String)
{
name = nameElement.GetString();
}
else if (serverElement.TryGetProperty("mcpServerUniqueName", out var mcpServerUniqueNameElement) &&
mcpServerUniqueNameElement.ValueKind == JsonValueKind.String)
{
name = mcpServerUniqueNameElement.GetString();
}
if (serverElement.TryGetProperty("url", out var urlElement) &&
urlElement.ValueKind == JsonValueKind.String)
{
endpoint = urlElement.GetString();
}
if (serverElement.TryGetProperty("id", out var idElement) &&
idElement.ValueKind == JsonValueKind.String)
{
id = idElement.GetString();
}
if (serverElement.TryGetProperty("scope", out var scopeElement) &&
scopeElement.ValueKind == JsonValueKind.String)
{
scope = scopeElement.GetString();
}
if (serverElement.TryGetProperty("audience", out var audienceElement) &&
audienceElement.ValueKind == JsonValueKind.String)
{
audience = audienceElement.GetString();
}
if (serverElement.TryGetProperty("publisher", out var publisherElement) &&
publisherElement.ValueKind == JsonValueKind.String)
{
publisher = publisherElement.GetString();
}
// Both Name and Endpoint are required
if (string.IsNullOrWhiteSpace(name) || string.IsNullOrWhiteSpace(endpoint))
{
return null;
}
return new MCPServerConfig
{
mcpServerName = name,
url = endpoint,
id = id ?? string.Empty,
scope = scope,
audience = audience,
publisher = publisher
};
}
catch (Exception)
{
// Return null if parsing fails for this individual server
return null;
}
}
/// <summary>
/// Parses a JSON element into an MCPServerConfig object from manifest, constructing full URL.
/// </summary>
/// <param name="serverElement">The JSON element containing server configuration</param>
/// <returns>MCPServerConfig object or null if parsing fails</returns>
private MCPServerConfig? ParseServerConfigFromManifest(JsonElement serverElement)
{
try
{
string? name = null;
string? endpoint = null;
string? id = null;
string? scope = null;
string? audience = null;
string? publisher = null;
if (serverElement.TryGetProperty("mcpServerName", out var nameElement) &&
nameElement.ValueKind == JsonValueKind.String)
{
name = nameElement.GetString();
}
else if (serverElement.TryGetProperty("mcpServerUniqueName", out var mcpServerUniqueNameElement) &&
mcpServerUniqueNameElement.ValueKind == JsonValueKind.String)
{
name = mcpServerUniqueNameElement.GetString();
}
if (serverElement.TryGetProperty("url", out var urlElement) &&
urlElement.ValueKind == JsonValueKind.String)
{
endpoint = urlElement.GetString();
}
if (serverElement.TryGetProperty("id", out var idElement) &&
idElement.ValueKind == JsonValueKind.String)
{
id = idElement.GetString();
}
if (serverElement.TryGetProperty("scope", out var scopeElement) &&
scopeElement.ValueKind == JsonValueKind.String)
{
scope = scopeElement.GetString();
}
if (serverElement.TryGetProperty("audience", out var audienceElement) &&
audienceElement.ValueKind == JsonValueKind.String)
{
audience = audienceElement.GetString();
}
if (serverElement.TryGetProperty("publisher", out var publisherElement) &&
publisherElement.ValueKind == JsonValueKind.String)
{
publisher = publisherElement.GetString();
}
// Both Name and ServerName are required
if (string.IsNullOrWhiteSpace(name))
{
return null;
}
// Construct full URL if not provided in manifest
var fullUrl = endpoint ?? Utility.BuildMcpServerUrl(name, this._configuration);
return new MCPServerConfig
{
mcpServerName = name,
url = fullUrl,
id = id ?? string.Empty,
scope = scope,
audience = audience,
publisher = publisher
};
}
catch (Exception)
{
// Return null if parsing fails for this individual server
return null;
}
}
/// <summary>
/// Reads MCP server configurations from ToolingManifest.json in the application's content root.
/// The file should be located at: [ProjectRoot]/ToolingManifest.json
///
/// Example ToolingManifest.json:
/// {
/// "mcpServers": [
/// {
/// "mcpServerName": "mailMCPServer",
/// "url": "mcp_MailTools"
/// },
/// {
/// "mcpServerName": "sharePointMCPServer",
/// "url": "mcp_SharePointTools"
/// }
/// ]
/// }
/// </summary>
/// <returns>List of MCP server configurations</returns>
private List<MCPServerConfig> GetMCPServersFromManifest()
{
var mcpServers = new List<MCPServerConfig>();
try
{
// Look for ToolingManifest.json in the application's base directory
// This follows the pattern of how content files like appsettings.json are located
var baseDirectory = AppContext.BaseDirectory;
var manifestPath = Path.Combine(baseDirectory, "ToolingManifest.json");
// If not found in base directory, try the current working directory
if (!File.Exists(manifestPath))
{
manifestPath = Path.Combine(Directory.GetCurrentDirectory(), "ToolingManifest.json");
}
// If still not found, try looking in the entry assembly's directory
if (!File.Exists(manifestPath))
{
var entryAssembly = Assembly.GetEntryAssembly();
if (entryAssembly?.Location != null)
{
var assemblyDir = Path.GetDirectoryName(entryAssembly.Location);
if (!string.IsNullOrEmpty(assemblyDir))
{
manifestPath = Path.Combine(assemblyDir, "ToolingManifest.json");
}
}
}
if (File.Exists(manifestPath))
{
this._logger.LogInformation($"Loading MCP servers from: {manifestPath}");
var jsonContent = File.ReadAllText(manifestPath);
var manifestData = JsonSerializer.Deserialize<JsonElement>(jsonContent);
if (manifestData.TryGetProperty("mcpServers", out var serversElement))
{
this._logger.LogInformation("Found 'mcpServers' section in ToolingManifest.json");
if (serversElement.ValueKind == JsonValueKind.Array)
{
foreach (var serverElement in serversElement.EnumerateArray())
{
var serverConfig = ParseServerConfigFromManifest(serverElement);
if (serverConfig != null)
{
mcpServers.Add(serverConfig);
}
}
}
}
this._logger.LogInformation($"Loaded {mcpServers.Count} MCP server configurations");
}
else
{
this._logger.LogInformation($"ToolingManifest.json not found. Expected location: {manifestPath}");
this._logger.LogInformation("Please ensure ToolingManifest.json exists in your project's output directory and is set to 'Copy to Output Directory'.");
}
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to read MCP servers from ToolingManifest.json: {ex.Message}", ex);
}
return mcpServers;
}
/// <summary>
/// Creates an MCP client with authentication handlers similar to your reference implementation
/// </summary>
private async Task<IMcpClient> CreateMcpClientWithAuthHandlers(ITurnContext turnContext, Uri endpoint, string authToken, ToolOptions toolOptions)
{
// Create HTTP client handler chain for MCP service authentication
var httpClientHandler = new HttpClientHandler();
// WARNING: Only use this in development/testing - never in production!
// This bypasses SSL certificate validation
var isDevScenario = IsDevScenario();
if (isDevScenario)
{
httpClientHandler.ServerCertificateCustomValidationCallback =
HttpClientHandler.DangerousAcceptAnyServerCertificateValidator;
this._logger.LogInformation("WARNING: SSL certificate validation disabled for development!");
}
// Create a simple authentication handler that adds the bearer token
var authHandler = new BearerTokenHandler(authToken)
{
InnerHandler = httpClientHandler
};
this._logger.LogInformation($"Configured authentication handler for MCP endpoint {endpoint}");
var httpContextHeaderHandler = new HttpContextHeadersHandler(turnContext, this._logger, toolOptions, authToken)
{
InnerHandler = authHandler,
};
// Create logging handler (optional - for debugging HTTP requests)
var loggingHandler = new HttpLoggingHandler(this._logger)
{
InnerHandler = httpContextHeaderHandler
};
// Setup SSE client transport options without manual token management
var options = new SseClientTransportOptions
{
Endpoint = endpoint,
TransportMode = HttpTransportMode.AutoDetect,
};
// Validate and compute the initialization timeout once so HttpClient.Timeout
// and McpClientOptions.InitializationTimeout always agree.
var initializationTimeout = GetValidatedInitializationTimeout(toolOptions.McpClientInitializationTimeoutSeconds);
// Create HTTP client with the authentication handler chain
var httpClient = new HttpClient(loggingHandler);
// Apply custom timeout only when explicitly configured
if (initializationTimeout.HasValue)
{
httpClient.Timeout = initializationTimeout.Value;
}
var clientTransport = new SseClientTransport(options, httpClient);
try
{
// Only pass McpClientOptions when a custom timeout is set to preserve default SDK behavior
if (initializationTimeout.HasValue)
{
var clientOptions = new McpClientOptions
{
InitializationTimeout = initializationTimeout.Value,
};
return await McpClientFactory.CreateAsync(clientTransport, clientOptions, loggerFactory: this._loggerFactory);
}
return await McpClientFactory.CreateAsync(clientTransport, loggerFactory: this._loggerFactory);
}
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to create MCP client for endpoint '{endpoint}': {ex.Message}", ex);
}
}
/// <summary>
/// Validates <see cref="ToolOptions.McpClientInitializationTimeoutSeconds"/> and converts it
/// to a <see cref="TimeSpan"/>. Returns <c>null</c> when no custom timeout is configured so
/// callers can preserve the MCP SDK default behavior.
/// </summary>
/// <param name="timeoutSeconds">The configured timeout in seconds, or <c>null</c> to use the SDK default.</param>
/// <returns>The validated timeout as a <see cref="TimeSpan"/>, or <c>null</c> when not configured.</returns>
/// <exception cref="ArgumentOutOfRangeException">
/// Thrown when <paramref name="timeoutSeconds"/> is set but falls outside the supported range of 1 to 600 seconds.
/// </exception>
internal static TimeSpan? GetValidatedInitializationTimeout(int? timeoutSeconds)
{
if (!timeoutSeconds.HasValue)
{
return null;
}
var value = timeoutSeconds.Value;
if (value < 1 || value > 600)
{
throw new ArgumentOutOfRangeException(
nameof(ToolOptions.McpClientInitializationTimeoutSeconds),
value,
"McpClientInitializationTimeoutSeconds must be between 1 and 600 seconds.");
}
return TimeSpan.FromSeconds(value);
}
/// <summary>
/// Attaches a per-audience Bearer token to each server's
/// <see cref="MCPServerConfig.Headers"/> dictionary.
/// Tokens are deduped by resolved scope before calling the provider, so V1 servers
/// that share the ATG scope trigger exactly one exchange regardless of how many
/// V1 servers are present.
/// </summary>
private async Task AttachPerAudienceTokensAsync(
List<MCPServerConfig> servers,
IMcpTokenProvider tokenProvider,
CancellationToken cancellationToken)
{
// Pre-compute distinct scopes so we only call the provider once per unique scope.
// Sequential acquisition avoids throttling the OBO endpoint.
var tokenByScope = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
List<MCPServerConfig> failedToAcquireServers = new List<MCPServerConfig>();
foreach (var server in servers)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
var scope = Utils.Utility.ResolveTokenScopeForServer(server, _configuration);
if (!tokenByScope.TryGetValue(scope, out var token))
{
token = await tokenProvider.GetTokenAsync(server, cancellationToken).ConfigureAwait(false);
tokenByScope[scope] = token;
_logger.LogDebug(
"Acquired token for scope '{Scope}' (server '{ServerName}')",
scope, server.mcpServerName);
}
server.Headers ??= new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
server.Headers[Constants.Headers.Authorization] = $"{Constants.Headers.BearerPrefix} {token}";
}
catch (Exception ex)
{
failedToAcquireServers.Add(server);
_logger.LogError(ex, "Failed to acquire token for server '{ServerName}': {Message}", server.mcpServerName, ex.Message);
}
}
if (failedToAcquireServers.Count > 0)
{
_logger.LogWarning("Failed to acquire tokens for {Count} MCP servers: {ServerNames}",
failedToAcquireServers.Count, string.Join(", ", failedToAcquireServers.Select(s => s.mcpServerName)));
// remove servers we failed to acquire tokens for, since they'll likely fail authentication anyway
servers.RemoveAll(s => failedToAcquireServers.Contains(s));
failedToAcquireServers.Clear();
}
}
/// <summary>
/// Extracts the effective raw token for MCP client authentication.
/// If <paramref name="serverConfig"/> already carries an Authorization header
/// (set by <see cref="AttachPerAudienceTokensAsync"/>), the token from that header
/// is used. Otherwise the caller-supplied <paramref name="fallbackToken"/> is returned.
/// </summary>
internal static string ResolveEffectiveToken(MCPServerConfig serverConfig, string fallbackToken)
{
if (serverConfig.Headers is not null &&
serverConfig.Headers.TryGetValue(Constants.Headers.Authorization, out var headerValue) &&
!string.IsNullOrWhiteSpace(headerValue))
{
return headerValue.StartsWith($"{Constants.Headers.BearerPrefix} ", StringComparison.OrdinalIgnoreCase)
? headerValue.Substring(Constants.Headers.BearerPrefix.Length + 1)
: headerValue;
}
return fallbackToken;
}
private bool IsDevScenario() => Utility.IsDevScenario(_configuration);
}
}