From 1fe60116ce217af3eb965a10f280b2b5630bc9c2 Mon Sep 17 00:00:00 2001 From: MattB Date: Wed, 13 May 2026 17:20:05 -0700 Subject: [PATCH 01/10] Refactor MCP token provider selection and fallback logic Rename DevMcpTokenProvider to EnvMcpTokenProvider and update all usages. Introduce TokenProviderCollection to allow chaining multiple token providers, enabling fallback from environment-based to OBO-based authentication. Update token acquisition logic to handle failures more robustly by removing servers that fail token acquisition and logging warnings. Update tests and remove obsolete aliases. These changes improve flexibility, reliability, and diagnostics for MCP token handling. --- .../DevMcpTokenProviderTests.cs | 4 +- ...okenProvider.cs => EnvMcpTokenProvider.cs} | 6 +-- .../McpToolServerConfigurationService.cs | 35 +++++++++++---- .../Core/Services/TokenProviderCollection.cs | 44 +++++++++++++++++++ .../Services/McpToolRegistrationService.cs | 14 +++--- .../Services/McpToolRegistrationService.cs | 12 ++--- .../Services/McpToolRegistrationService.cs | 11 +++-- 7 files changed, 93 insertions(+), 33 deletions(-) rename src/Tooling/Core/Services/{DevMcpTokenProvider.cs => EnvMcpTokenProvider.cs} (95%) create mode 100644 src/Tooling/Core/Services/TokenProviderCollection.cs diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/DevMcpTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/DevMcpTokenProviderTests.cs index b9455fe3..192322b9 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/DevMcpTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/DevMcpTokenProviderTests.cs @@ -13,14 +13,14 @@ namespace Microsoft.Agents.A365.Tooling.Core.Tests; /// -/// Tests for and . +/// Tests for and . /// public class DevMcpTokenProviderTests { private static MCPServerConfig Server(string name) => new() { mcpServerName = name, id = $"id-{name}", url = "http://test" }; - private static DevMcpTokenProvider Provider(IConfiguration config) => + private static EnvMcpTokenProvider Provider(IConfiguration config) => new(config, Mock.Of()); private static IConfiguration Config(params (string key, string value)[] entries) diff --git a/src/Tooling/Core/Services/DevMcpTokenProvider.cs b/src/Tooling/Core/Services/EnvMcpTokenProvider.cs similarity index 95% rename from src/Tooling/Core/Services/DevMcpTokenProvider.cs rename to src/Tooling/Core/Services/EnvMcpTokenProvider.cs index 01ba62db..ce9e0fd1 100644 --- a/src/Tooling/Core/Services/DevMcpTokenProvider.cs +++ b/src/Tooling/Core/Services/EnvMcpTokenProvider.cs @@ -25,17 +25,17 @@ namespace Microsoft.Agents.A365.Tooling.Services /// /// Hyphens in the server name are normalised to underscores before the lookup. /// - internal sealed class DevMcpTokenProvider : IMcpTokenProvider + internal sealed class EnvMcpTokenProvider : IMcpTokenProvider { private readonly IConfiguration _configuration; private readonly ILogger _logger; /// - /// Initializes a new instance of . + /// Initializes a new instance of . /// /// Application configuration (env vars, appsettings, etc.). /// Logger for diagnostic output. - public DevMcpTokenProvider(IConfiguration configuration, ILogger logger) + public EnvMcpTokenProvider(IConfiguration configuration, ILogger logger) { _configuration = configuration; _logger = logger; diff --git a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs index f5179dab..d3470dc5 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -552,22 +552,39 @@ private async Task AttachPerAudienceTokensAsync( // Sequential acquisition avoids throttling the OBO endpoint. var tokenByScope = new Dictionary(StringComparer.OrdinalIgnoreCase); + List failedToAquireServers = new List(); 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); + } - var scope = Utils.Utility.ResolveTokenScopeForServer(server, _configuration); - if (!tokenByScope.TryGetValue(scope, out var token)) + server.Headers ??= new Dictionary(StringComparer.OrdinalIgnoreCase); + server.Headers[Constants.Headers.Authorization] = $"{Constants.Headers.BearerPrefix} {token}"; + } + catch (Exception ex) { - token = await tokenProvider.GetTokenAsync(server, cancellationToken).ConfigureAwait(false); - tokenByScope[scope] = token; - _logger.LogDebug( - "Acquired token for scope '{Scope}' (server '{ServerName}')", - scope, server.mcpServerName); + failedToAquireServers.Add(server); + _logger.LogError(ex, "Failed to acquire token for server '{ServerName}': {Message}", server.mcpServerName, ex.Message); } + } - server.Headers ??= new Dictionary(StringComparer.OrdinalIgnoreCase); - server.Headers[Constants.Headers.Authorization] = $"{Constants.Headers.BearerPrefix} {token}"; + if (failedToAquireServers.Count > 0) + { + _logger.LogWarning("Failed to acquire tokens for {Count} MCP servers: {ServerNames}", + failedToAquireServers.Count, string.Join(", ", failedToAquireServers.Select(s => s.mcpServerName))); + // remove servers we failed to acquire tokens for, since they'll likely fail authentication anyway + servers.RemoveAll(s => failedToAquireServers.Contains(s)); + failedToAquireServers.Clear(); } } diff --git a/src/Tooling/Core/Services/TokenProviderCollection.cs b/src/Tooling/Core/Services/TokenProviderCollection.cs new file mode 100644 index 00000000..4c9a150f --- /dev/null +++ b/src/Tooling/Core/Services/TokenProviderCollection.cs @@ -0,0 +1,44 @@ +using Microsoft.Agents.A365.Tooling.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Microsoft.Agents.A365.Tooling.Services +{ + internal class TokenProviderCollection : IMcpTokenProvider + { + SortedDictionary _providers; + public TokenProviderCollection(params IMcpTokenProvider[] providers) + { + _providers = new SortedDictionary(); + for (int i = 0; i < providers.Length; i++) + { + _providers.Add(i, providers[i]); + } + } + + public async Task GetTokenAsync(MCPServerConfig server, CancellationToken cancellationToken = default) + { + List exceptions = new List(); + // Try each provider in turn, then return the first successful token. If no providers can return a token, throw an exception. + foreach (var provider in _providers.Values) + { + try + { + var token = await provider.GetTokenAsync(server, cancellationToken); + if (!string.IsNullOrWhiteSpace(token)) + { + return token; + } + } + catch(Exception ex) + { + exceptions.Add(new Exception($"Provider {provider.GetType().Name} failed to obtain a token." , ex)); + } + } + throw new AggregateException("No valid token could be obtained from any provider.", exceptions); + } + } +} diff --git a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs index 95f49767..f8735f6d 100644 --- a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs @@ -12,8 +12,6 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; using Microsoft.Agents.A365.Runtime.Authentication; using Microsoft.Agents.A365.Tooling.Models; using Microsoft.Agents.A365.Tooling.Services; -using AgenticMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.AgenticMcpTokenProvider; -using DevMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.DevMcpTokenProvider; using IMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.IMcpTokenProvider; using ToolingUtility = Microsoft.Agents.A365.Tooling.Utils.Utility; using Microsoft.Agents.AI; @@ -85,9 +83,9 @@ public async Task AddToolServersToAgent( // Use per-audience token provider so V2 servers receive audience-scoped tokens. // In dev scenarios tokens come from environment variables; in production from OBO flow. - IMcpTokenProvider tokenProvider = ToolingUtility.IsDevScenario(_configuration) - ? new DevMcpTokenProvider(_configuration, _logger) - : new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger); + IMcpTokenProvider tokenProvider = new TokenProviderCollection( + new EnvMcpTokenProvider(_configuration, _logger), + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentUserId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); @@ -137,9 +135,9 @@ public async Task> GetMcpToolsAsync( UserAgentConfiguration = Agent365AgentFrameworkSdkUserAgentConfiguration.Instance }; - IMcpTokenProvider tokenProvider = ToolingUtility.IsDevScenario(_configuration) - ? new DevMcpTokenProvider(_configuration, _logger) - : new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger); + IMcpTokenProvider tokenProvider = new TokenProviderCollection( + new EnvMcpTokenProvider(_configuration, _logger), + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync( agentUserId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); diff --git a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs index 4d3ae2db..82c2317b 100644 --- a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs @@ -17,8 +17,6 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.AzureFoundry.Services; using System.Linq; using System.Threading; using System.Threading.Tasks; -using AgenticMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.AgenticMcpTokenProvider; -using DevMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.DevMcpTokenProvider; using IMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.IMcpTokenProvider; using ToolingUtility = Microsoft.Agents.A365.Tooling.Utils.Utility; using Constants = Microsoft.Agents.A365.Tooling.Utils.Constants; @@ -177,14 +175,18 @@ public async Task AddToolServersToAgentAsync( // falls back to the V1 shared-token path when they are absent. if (ToolingUtility.IsDevScenario(_configuration)) { - IMcpTokenProvider tokenProvider = new DevMcpTokenProvider(_configuration, _logger); + IMcpTokenProvider tokenProvider = new TokenProviderCollection( + new EnvMcpTokenProvider(_configuration, _logger), + new AgenticMcpTokenProvider(userAuthorization!, authHandlerName!, turnContext, _configuration, _logger)); + (servers, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentInstanceId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); } else if (userAuthorization is not null && authHandlerName is not null) { // Production V2-aware path: per-audience OBO tokens. - IMcpTokenProvider tokenProvider = new AgenticMcpTokenProvider( - userAuthorization, authHandlerName, turnContext, _configuration, _logger); + IMcpTokenProvider tokenProvider = new TokenProviderCollection( + new EnvMcpTokenProvider(_configuration, _logger), + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); (servers, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentInstanceId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); } diff --git a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs index 5253be99..4d2435bf 100644 --- a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs @@ -9,8 +9,6 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Services using Microsoft.Agents.A365.Runtime.Authentication; using Microsoft.Agents.A365.Tooling.Models; using Microsoft.Agents.A365.Tooling.Services; - using AgenticMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.AgenticMcpTokenProvider; - using DevMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.DevMcpTokenProvider; using ToolingUtility = Microsoft.Agents.A365.Tooling.Utils.Utility; using Microsoft.Agents.Builder; using Microsoft.Agents.Builder.App.UserAuth; @@ -74,10 +72,11 @@ public async Task AddToolServersToAgentAsync(Kernel kernel, UserAuthorization us // Use per-audience token provider so V2 servers receive audience-scoped tokens. // In dev scenarios tokens come from environment variables; in production from OBO flow. - IMcpTokenProvider tokenProvider = ToolingUtility.IsDevScenario(_configuration) - ? new DevMcpTokenProvider(_configuration, _logger) - : new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger); - + IMcpTokenProvider tokenProvider = + new TokenProviderCollection( + new EnvMcpTokenProvider(_configuration, _logger), + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); + var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agenticAppId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); foreach (var serverEntry in toolsByServer) From c98cefab6c0bff81e98b4410814bf67fa9a76cd3 Mon Sep 17 00:00:00 2001 From: MattB Date: Fri, 15 May 2026 10:01:16 -0700 Subject: [PATCH 02/10] Potential fix for pull request finding 'Missed 'readonly' opportunity' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> --- src/Tooling/Core/Services/TokenProviderCollection.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Tooling/Core/Services/TokenProviderCollection.cs b/src/Tooling/Core/Services/TokenProviderCollection.cs index 4c9a150f..46acb0f5 100644 --- a/src/Tooling/Core/Services/TokenProviderCollection.cs +++ b/src/Tooling/Core/Services/TokenProviderCollection.cs @@ -9,7 +9,7 @@ namespace Microsoft.Agents.A365.Tooling.Services { internal class TokenProviderCollection : IMcpTokenProvider { - SortedDictionary _providers; + readonly SortedDictionary _providers; public TokenProviderCollection(params IMcpTokenProvider[] providers) { _providers = new SortedDictionary(); From de28355003b81ae279be90d261fa740e4a7a9dce Mon Sep 17 00:00:00 2001 From: MattB Date: Fri, 15 May 2026 10:01:34 -0700 Subject: [PATCH 03/10] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/Tooling/Core/Services/TokenProviderCollection.cs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Tooling/Core/Services/TokenProviderCollection.cs b/src/Tooling/Core/Services/TokenProviderCollection.cs index 46acb0f5..b25f9c1b 100644 --- a/src/Tooling/Core/Services/TokenProviderCollection.cs +++ b/src/Tooling/Core/Services/TokenProviderCollection.cs @@ -1,4 +1,7 @@ -using Microsoft.Agents.A365.Tooling.Models; +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.Tooling.Models; using System; using System.Collections.Generic; using System.Linq; From 31170804a62612aff57463dabb1ca92e77132bec Mon Sep 17 00:00:00 2001 From: MattB Date: Tue, 2 Jun 2026 15:04:25 -0700 Subject: [PATCH 04/10] Refactor token handling and fix typos Corrected class name in tests to match the class being tested. Modified error handling in `EnvMcpTokenProvider` to log warnings instead of throwing exceptions in dev scenarios. Fixed typos in `McpToolServerConfigurationService`. Added logging to `TokenProviderCollection` and ensured token providers are configured and not null. Refactored token provider selection logic in `McpToolRegistrationService` to differentiate between production and development scenarios. Reorganized imports and updated token handling in `SemanticKernel` service. --- ...erTests.cs => EnvMcpTokenProviderTests.cs} | 2 +- .../Core/Services/EnvMcpTokenProvider.cs | 10 +++- .../McpToolServerConfigurationService.cs | 12 ++-- .../Core/Services/TokenProviderCollection.cs | 36 ++++++++---- .../Services/McpToolRegistrationService.cs | 56 +++++++++++-------- .../Services/McpToolRegistrationService.cs | 17 ++---- .../Services/McpToolRegistrationService.cs | 24 ++++---- 7 files changed, 91 insertions(+), 66 deletions(-) rename src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/{DevMcpTokenProviderTests.cs => EnvMcpTokenProviderTests.cs} (99%) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/DevMcpTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs similarity index 99% rename from src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/DevMcpTokenProviderTests.cs rename to src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs index 192322b9..00e67ac6 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/DevMcpTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs @@ -15,7 +15,7 @@ namespace Microsoft.Agents.A365.Tooling.Core.Tests; /// /// Tests for and . /// -public class DevMcpTokenProviderTests +public class EnvMcpTokenProviderTests { private static MCPServerConfig Server(string name) => new() { mcpServerName = name, id = $"id-{name}", url = "http://test" }; diff --git a/src/Tooling/Core/Services/EnvMcpTokenProvider.cs b/src/Tooling/Core/Services/EnvMcpTokenProvider.cs index ce9e0fd1..cd7d63e8 100644 --- a/src/Tooling/Core/Services/EnvMcpTokenProvider.cs +++ b/src/Tooling/Core/Services/EnvMcpTokenProvider.cs @@ -55,9 +55,13 @@ public Task GetTokenAsync(MCPServerConfig server, CancellationToken canc if (string.IsNullOrWhiteSpace(token)) { - throw new InvalidOperationException( - $"No dev token found for MCP server '{server.mcpServerName}'. " + - $"Set environment variable '{perServerKey}' or 'BEARER_TOKEN'."); + if (Utility.IsDevScenario(_configuration)) // Only log warnings in dev scenarios, to avoid noise in prod if env vars are not set + { + _logger.LogWarning( + $"No Environment token found for MCP server '{server.mcpServerName}'. " + + $"Set environment variable '{perServerKey}' or 'BEARER_TOKEN'."); + } + return Task.FromResult(string.Empty); } // Warn when a V2 server (distinct audience) falls back to the shared BEARER_TOKEN. diff --git a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs index d3470dc5..51f22a04 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -552,7 +552,7 @@ private async Task AttachPerAudienceTokensAsync( // Sequential acquisition avoids throttling the OBO endpoint. var tokenByScope = new Dictionary(StringComparer.OrdinalIgnoreCase); - List failedToAquireServers = new List(); + List failedToAcquireServers = new List(); foreach (var server in servers) { cancellationToken.ThrowIfCancellationRequested(); @@ -573,18 +573,18 @@ private async Task AttachPerAudienceTokensAsync( } catch (Exception ex) { - failedToAquireServers.Add(server); + failedToAcquireServers.Add(server); _logger.LogError(ex, "Failed to acquire token for server '{ServerName}': {Message}", server.mcpServerName, ex.Message); } } - if (failedToAquireServers.Count > 0) + if (failedToAcquireServers.Count > 0) { _logger.LogWarning("Failed to acquire tokens for {Count} MCP servers: {ServerNames}", - failedToAquireServers.Count, string.Join(", ", failedToAquireServers.Select(s => s.mcpServerName))); + 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 => failedToAquireServers.Contains(s)); - failedToAquireServers.Clear(); + servers.RemoveAll(s => failedToAcquireServers.Contains(s)); + failedToAcquireServers.Clear(); } } diff --git a/src/Tooling/Core/Services/TokenProviderCollection.cs b/src/Tooling/Core/Services/TokenProviderCollection.cs index b25f9c1b..4b862a37 100644 --- a/src/Tooling/Core/Services/TokenProviderCollection.cs +++ b/src/Tooling/Core/Services/TokenProviderCollection.cs @@ -2,28 +2,44 @@ // Licensed under the MIT License. using Microsoft.Agents.A365.Tooling.Models; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; using System; -using System.Collections.Generic; -using System.Linq; -using System.Text; -using System.Threading.Tasks; namespace Microsoft.Agents.A365.Tooling.Services { internal class TokenProviderCollection : IMcpTokenProvider { readonly SortedDictionary _providers; - public TokenProviderCollection(params IMcpTokenProvider[] providers) + readonly ILogger _logger; + + public TokenProviderCollection( + ILogger logger, + params IMcpTokenProvider[] providers) { - _providers = new SortedDictionary(); - for (int i = 0; i < providers.Length; i++) - { - _providers.Add(i, providers[i]); - } + _logger = logger; + _providers = new SortedDictionary(); + for (int i = 0; i < providers.Length; i++) + { + _providers.Add(i, providers[i]); + } + } public async Task GetTokenAsync(MCPServerConfig server, CancellationToken cancellationToken = default) { + cancellationToken.ThrowIfCancellationRequested(); + + if ( _providers != null && _providers.Count == 0) + { + throw new InvalidOperationException("No token providers are configured."); + } + + if (_providers!.Values.Any(p => p == null)) + { + throw new InvalidOperationException("One or more token providers are null."); + } + List exceptions = new List(); // Try each provider in turn, then return the first successful token. If no providers can return a token, throw an exception. foreach (var provider in _providers.Values) diff --git a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs index f8735f6d..bcdefa63 100644 --- a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs @@ -3,23 +3,23 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; -using System; -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; using Microsoft.Agents.A365.Runtime; using Microsoft.Agents.A365.Runtime.Authentication; using Microsoft.Agents.A365.Tooling.Models; using Microsoft.Agents.A365.Tooling.Services; -using IMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.IMcpTokenProvider; -using ToolingUtility = Microsoft.Agents.A365.Tooling.Utils.Utility; using Microsoft.Agents.AI; using Microsoft.Agents.Builder; using Microsoft.Agents.Builder.App.UserAuth; using Microsoft.Extensions.AI; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using IMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.IMcpTokenProvider; +using ToolingUtility = Microsoft.Agents.A365.Tooling.Utils.Utility; /// /// Service for registering and validating MCP tool servers for Agent Framework scenarios. @@ -62,10 +62,6 @@ public async Task AddToolServersToAgent( throw new ArgumentNullException(nameof(chatClient)); } - if (authToken is null && !ToolingUtility.IsDevScenario(_configuration)) - { - authToken = await AgenticAuthenticationService.GetAgenticUserTokenAsync(userAuthorization, authHandlerName, turnContext, _configuration).ConfigureAwait(false); - } authToken ??= string.Empty; try @@ -81,12 +77,19 @@ public async Task AddToolServersToAgent( UserAgentConfiguration = Agent365AgentFrameworkSdkUserAgentConfiguration.Instance }; - // Use per-audience token provider so V2 servers receive audience-scoped tokens. - // In dev scenarios tokens come from environment variables; in production from OBO flow. - IMcpTokenProvider tokenProvider = new TokenProviderCollection( - new EnvMcpTokenProvider(_configuration, _logger), - new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); - + IMcpTokenProvider tokenProvider; + if (userAuthorization is not null && authHandlerName is not null) + { + // Production V2-aware path: per-audience OBO tokens. + tokenProvider = new TokenProviderCollection(_logger, + new EnvMcpTokenProvider(_configuration, _logger), + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); + } + else + { + tokenProvider = new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); + } + var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentUserId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); // Add all MCP tools from all servers @@ -124,10 +127,6 @@ public async Task> GetMcpToolsAsync( { try { - if (authToken is null && !ToolingUtility.IsDevScenario(_configuration)) - { - authToken = await AgenticAuthenticationService.GetAgenticUserTokenAsync(userAuthorization, authHandlerName, turnContext, _configuration).ConfigureAwait(false); - } authToken ??= string.Empty; var toolOptions = new ToolOptions @@ -135,9 +134,18 @@ public async Task> GetMcpToolsAsync( UserAgentConfiguration = Agent365AgentFrameworkSdkUserAgentConfiguration.Instance }; - IMcpTokenProvider tokenProvider = new TokenProviderCollection( - new EnvMcpTokenProvider(_configuration, _logger), - new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); + IMcpTokenProvider tokenProvider; + if (userAuthorization is not null && authHandlerName is not null) + { + // Production V2-aware path: per-audience OBO tokens. + tokenProvider = new TokenProviderCollection(_logger, + new EnvMcpTokenProvider(_configuration, _logger), + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); + } + else + { + tokenProvider = new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); + } var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync( agentUserId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); diff --git a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs index 82c2317b..cd07d55c 100644 --- a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs @@ -173,29 +173,24 @@ public async Task AddToolServersToAgentAsync( // Dev scenario → DevMcpTokenProvider reads per-server env vars (no OBO flow needed). // Production → AgenticMcpTokenProvider performs OBO when auth objects are supplied; // falls back to the V1 shared-token path when they are absent. - if (ToolingUtility.IsDevScenario(_configuration)) - { - IMcpTokenProvider tokenProvider = new TokenProviderCollection( - new EnvMcpTokenProvider(_configuration, _logger), - new AgenticMcpTokenProvider(userAuthorization!, authHandlerName!, turnContext, _configuration, _logger)); - - (servers, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentInstanceId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); - } - else if (userAuthorization is not null && authHandlerName is not null) + if (userAuthorization is not null && authHandlerName is not null) { // Production V2-aware path: per-audience OBO tokens. - IMcpTokenProvider tokenProvider = new TokenProviderCollection( + IMcpTokenProvider tokenProvider = new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger), new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); (servers, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentInstanceId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); } else - { + { + IMcpTokenProvider tokenProvider = new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); + // Production V1 fallback: all servers share the single authToken. (servers, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync( agentInstanceId, authToken, + tokenProvider, turnContext, toolOptions).ConfigureAwait(false); } diff --git a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs index 4d2435bf..1a03c704 100644 --- a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs @@ -56,10 +56,6 @@ public async Task AddToolServersToAgentAsync(Kernel kernel, UserAuthorization us throw new ArgumentNullException(nameof(kernel)); } - if (authToken is null && !ToolingUtility.IsDevScenario(_configuration)) - { - authToken = await AgenticAuthenticationService.GetAgenticUserTokenAsync(userAuthorization, authHandlerName, turnContext, _configuration).ConfigureAwait(false); - } authToken ??= string.Empty; // resolve agent identity from context or token. @@ -70,13 +66,19 @@ public async Task AddToolServersToAgentAsync(Kernel kernel, UserAuthorization us UserAgentConfiguration = Agent365SemanticKernelSdkUserAgentConfiguration.Instance }; - // Use per-audience token provider so V2 servers receive audience-scoped tokens. - // In dev scenarios tokens come from environment variables; in production from OBO flow. - IMcpTokenProvider tokenProvider = - new TokenProviderCollection( - new EnvMcpTokenProvider(_configuration, _logger), - new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); - + IMcpTokenProvider tokenProvider; + if (userAuthorization is not null && authHandlerName is not null) + { + // Production V2-aware path: per-audience OBO tokens. + tokenProvider = new TokenProviderCollection(_logger, + new EnvMcpTokenProvider(_configuration, _logger), + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); + } + else + { + tokenProvider = new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); + } + var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agenticAppId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); foreach (var serverEntry in toolsByServer) From e7dfdea85223193fb02a5391f7cd9283ec46a1d8 Mon Sep 17 00:00:00 2001 From: MattB Date: Tue, 2 Jun 2026 15:43:26 -0700 Subject: [PATCH 05/10] Addressing Per changes requested Rename DevMcpTokenProvider to EnvMcpTokenProvider Renamed `DevMcpTokenProvider` to `EnvMcpTokenProvider` across multiple files to reflect the use of environment variables for token provision in development scenarios. Updated comments in `EnvMcpTokenProviderTests.cs` to match the new naming. Added debug logging in `TokenProviderCollection.cs` to track when token providers return empty tokens or fail to obtain tokens, aiding in diagnostics. Removed unnecessary `using` directives in `McpToolRegistrationService.cs` and updated comments to reflect the renaming. Updated `CHANGELOG.md` to ensure documentation consistency with the code changes. These updates enhance code clarity, maintainability, and debugging capabilities. --- .../EnvMcpTokenProviderTests.cs | 2 +- src/Tooling/Core/Services/TokenProviderCollection.cs | 7 +++++-- .../AgentFramework/Services/McpToolRegistrationService.cs | 2 -- .../AzureAIFoundry/Services/McpToolRegistrationService.cs | 2 +- src/Tooling/Extensions/SemanticKernel/CHANGELOG.md | 2 +- .../SemanticKernel/Services/McpToolRegistrationService.cs | 3 --- 6 files changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs index 00e67ac6..7ce9a5c6 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs @@ -214,7 +214,7 @@ public void IsDevScenario_NoEnvironmentSet_ReturnsFalse() { // Unset environment must NOT default to Development so that hosts without an // explicit ASPNETCORE_ENVIRONMENT / DOTNET_ENVIRONMENT are not silently treated - // as dev (which would enable manifest discovery, DevMcpTokenProvider, and relaxed TLS). + // as dev (which would enable manifest discovery, EnvMcpTokenProvider, and relaxed TLS). var config = Config(); // nothing set Utility.IsDevScenario(config).Should().BeFalse(); } diff --git a/src/Tooling/Core/Services/TokenProviderCollection.cs b/src/Tooling/Core/Services/TokenProviderCollection.cs index 4b862a37..1463e9bf 100644 --- a/src/Tooling/Core/Services/TokenProviderCollection.cs +++ b/src/Tooling/Core/Services/TokenProviderCollection.cs @@ -3,8 +3,6 @@ using Microsoft.Agents.A365.Tooling.Models; using Microsoft.Extensions.Logging; -using Microsoft.Extensions.Logging.Abstractions; -using System; namespace Microsoft.Agents.A365.Tooling.Services { @@ -51,10 +49,15 @@ public async Task GetTokenAsync(MCPServerConfig server, CancellationToke { return token; } + else + { + _logger.LogDebug("Provider {ProviderName} returned an empty token.", provider.GetType().Name); + } } catch(Exception ex) { exceptions.Add(new Exception($"Provider {provider.GetType().Name} failed to obtain a token." , ex)); + _logger.LogDebug(ex, "Token provider {ProviderType} failed to obtain a token for server '{ServerName}'.", provider.GetType().Name, server.mcpServerName); } } throw new AggregateException("No valid token could be obtained from any provider.", exceptions); diff --git a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs index bcdefa63..aa8b5caa 100644 --- a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs @@ -4,7 +4,6 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; using Microsoft.Agents.A365.Runtime; -using Microsoft.Agents.A365.Runtime.Authentication; using Microsoft.Agents.A365.Tooling.Models; using Microsoft.Agents.A365.Tooling.Services; using Microsoft.Agents.AI; @@ -19,7 +18,6 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; using System.Threading; using System.Threading.Tasks; using IMcpTokenProvider = Microsoft.Agents.A365.Tooling.Services.IMcpTokenProvider; -using ToolingUtility = Microsoft.Agents.A365.Tooling.Utils.Utility; /// /// Service for registering and validating MCP tool servers for Agent Framework scenarios. diff --git a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs index cd07d55c..804adbcb 100644 --- a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs @@ -170,7 +170,7 @@ public async Task AddToolServersToAgentAsync( Dictionary> toolsByServer; // Select token provider: - // Dev scenario → DevMcpTokenProvider reads per-server env vars (no OBO flow needed). + // Dev scenario → EnvMcpTokenProvider reads per-server env vars (no OBO flow needed). // Production → AgenticMcpTokenProvider performs OBO when auth objects are supplied; // falls back to the V1 shared-token path when they are absent. if (userAuthorization is not null && authHandlerName is not null) diff --git a/src/Tooling/Extensions/SemanticKernel/CHANGELOG.md b/src/Tooling/Extensions/SemanticKernel/CHANGELOG.md index 852970b3..82b42640 100644 --- a/src/Tooling/Extensions/SemanticKernel/CHANGELOG.md +++ b/src/Tooling/Extensions/SemanticKernel/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - **Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel** - V2 per-audience token support - `McpToolRegistrationService.AddToolServersToAgentAsync` now instantiates `AgenticMcpTokenProvider` and uses the V2-aware `EnumerateToolsFromServersAsync` overload, so each V2 MCP server receives its own audience-scoped Bearer token instead of the shared ATG token - - OBO token acquisition is deferred until after the dev-mode check; in `Development` environments the `DevMcpTokenProvider` supplies tokens from environment variables (`BEARER_TOKEN_` / `BEARER_TOKEN`) without requiring a working auth setup + - OBO token acquisition is deferred until after the dev-mode check; in `Development` environments the `EnvMcpTokenProvider` supplies tokens from environment variables (`BEARER_TOKEN_` / `BEARER_TOKEN`) without requiring a working auth setup ### Added - **Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel** - Semantic Kernel integration tooling for MCP server management diff --git a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs index 1a03c704..7ae2e8dd 100644 --- a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs @@ -6,14 +6,11 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Services using System; using System.Linq; using Microsoft.Agents.A365.Runtime; - using Microsoft.Agents.A365.Runtime.Authentication; using Microsoft.Agents.A365.Tooling.Models; using Microsoft.Agents.A365.Tooling.Services; - using ToolingUtility = Microsoft.Agents.A365.Tooling.Utils.Utility; using Microsoft.Agents.Builder; using Microsoft.Agents.Builder.App.UserAuth; using Microsoft.Extensions.Configuration; - using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; From daa293578e2841a619b39cb6a1e6483f92344b44 Mon Sep 17 00:00:00 2001 From: MattB Date: Tue, 2 Jun 2026 17:45:08 -0700 Subject: [PATCH 06/10] Refactor tests and update project visibility Removed redundant test cases from EnvMcpTokenProviderTests that checked for exceptions with missing or whitespace-only token environment variables. Added a new mock field for TokenProviderCollection in McpToolRegistrationServiceTests and updated method signatures to include it. Updated the project file to grant internal access to a new test assembly for better testing coverage. --- .../EnvMcpTokenProviderTests.cs | 33 ------------------- .../McpToolRegistrationServiceTests.cs | 7 +++- .../Core/Microsoft.Agents.A365.Tooling.csproj | 1 + 3 files changed, 7 insertions(+), 34 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs index 7ce9a5c6..8f01ed2f 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs @@ -91,16 +91,6 @@ public async Task GetTokenAsync_MultipleServers_SharedFallbackUsedForAll() t2.Should().Be("shared-token"); } - // ─── Missing token → exception ──────────────────────────────────────────── - - [Fact] - public async Task GetTokenAsync_NeitherEnvVarSet_ThrowsInvalidOperationException() - { - var config = Config(); // empty — no BEARER_TOKEN_* or BEARER_TOKEN - Func act = () => Provider(config).GetTokenAsync(Server("mcp_MailTools")); - await act.Should().ThrowAsync() - .WithMessage("*mcp_MailTools*"); - } [Fact] public async Task GetTokenAsync_PerServerVarWhitespaceOnly_FallsBackToSharedToken() @@ -114,29 +104,6 @@ public async Task GetTokenAsync_PerServerVarWhitespaceOnly_FallsBackToSharedToke token.Should().Be("shared-token"); } - [Fact] - public async Task GetTokenAsync_BothVarsWhitespaceOnly_ThrowsInvalidOperationException() - { - var config = Config( - ("BEARER_TOKEN_MCP_MAILTOOLS", " "), - ("BEARER_TOKEN", " ")); - - Func act = () => Provider(config).GetTokenAsync(Server("mcp_MailTools")); - await act.Should().ThrowAsync(); - } - - // ─── Error message quality ──────────────────────────────────────────────── - - [Fact] - public async Task GetTokenAsync_MissingToken_ErrorMessageContainsNormalizedKey() - { - var config = Config(); - Func act = () => Provider(config).GetTokenAsync(Server("mcp_MailTools")); - - var ex = await act.Should().ThrowAsync(); - ex.Which.Message.Should().Contain("BEARER_TOKEN_MCP_MAILTOOLS"); - ex.Which.Message.Should().Contain("BEARER_TOKEN"); - } // ─── Cancellation ───────────────────────────────────────────────────────── diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/McpToolRegistrationServiceTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/McpToolRegistrationServiceTests.cs index d3ef8ef6..d026c565 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/McpToolRegistrationServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/McpToolRegistrationServiceTests.cs @@ -26,12 +26,14 @@ public class McpToolRegistrationServiceTests private readonly Mock _mockConfigService; private readonly Mock _mockConfiguration; private readonly Mock _mockTurnContext; + private readonly TokenProviderCollection _mockTokenProviderCollection; private readonly McpToolRegistrationService _service; public McpToolRegistrationServiceTests() { _mockLogger = new Mock>(); _mockServiceProvider = new Mock(); + _mockTokenProviderCollection = new TokenProviderCollection(_mockLogger.Object); // Create mock for IMcpToolServerConfigurationService interface _mockConfigService = new Mock(); @@ -93,12 +95,13 @@ private void SetupMocksForEmptyToolEnumeration(Action? captureToolO .Setup(x => x.EnumerateToolsFromServersAsync( It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())); if (captureToolOptions != null) { - setup.Callback((_, _, _, options) => captureToolOptions(options)); + setup.Callback((_, _, _, _, options) => captureToolOptions(options)); } setup.ReturnsAsync((new List(), new Dictionary>())); @@ -113,6 +116,7 @@ private void SetupMocksForToolEnumeration(List servers, Diction .Setup(x => x.EnumerateToolsFromServersAsync( It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny(), It.IsAny())) .ReturnsAsync((servers, toolsByServer)); @@ -196,6 +200,7 @@ await _service.GetMcpToolDefinitionsAndResourcesAsync( x => x.EnumerateToolsFromServersAsync( TestAgentInstanceId, TestAuthToken, + It.IsAny(), _mockTurnContext.Object, It.Is(o => o.UserAgentConfiguration == Agent365AzureAIFoundrySdkUserAgentConfiguration.Instance)), Times.Once); diff --git a/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj b/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj index 78fc9d3a..bdcbe152 100644 --- a/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj +++ b/src/Tooling/Core/Microsoft.Agents.A365.Tooling.csproj @@ -20,6 +20,7 @@ + From 21a15ffb3940c2d19734a783e255eab7563761ee Mon Sep 17 00:00:00 2001 From: MattB Date: Wed, 3 Jun 2026 09:15:09 -0700 Subject: [PATCH 07/10] Refactor token provider usage in tests Replaced `TokenProviderCollection` with `IMcpTokenProvider` in `McpToolRegistrationServiceTests.cs`. Removed `_mockTokenProviderCollection` field and its initialization. Updated `EnumerateToolsFromServersAsync` call to use `IMcpTokenProvider`, aligning with changes in the underlying service interface. --- .../McpToolRegistrationServiceTests.cs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/McpToolRegistrationServiceTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/McpToolRegistrationServiceTests.cs index d026c565..caa44f13 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/McpToolRegistrationServiceTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/McpToolRegistrationServiceTests.cs @@ -26,14 +26,12 @@ public class McpToolRegistrationServiceTests private readonly Mock _mockConfigService; private readonly Mock _mockConfiguration; private readonly Mock _mockTurnContext; - private readonly TokenProviderCollection _mockTokenProviderCollection; private readonly McpToolRegistrationService _service; public McpToolRegistrationServiceTests() { _mockLogger = new Mock>(); _mockServiceProvider = new Mock(); - _mockTokenProviderCollection = new TokenProviderCollection(_mockLogger.Object); // Create mock for IMcpToolServerConfigurationService interface _mockConfigService = new Mock(); @@ -200,7 +198,7 @@ await _service.GetMcpToolDefinitionsAndResourcesAsync( x => x.EnumerateToolsFromServersAsync( TestAgentInstanceId, TestAuthToken, - It.IsAny(), + It.IsAny(), _mockTurnContext.Object, It.Is(o => o.UserAgentConfiguration == Agent365AzureAIFoundrySdkUserAgentConfiguration.Instance)), Times.Once); From 399bda189079df1fdd56050711dd7335b9af10da Mon Sep 17 00:00:00 2001 From: MattB Date: Thu, 4 Jun 2026 18:06:40 -0700 Subject: [PATCH 08/10] Resolve auth token for tool discovery in all scenarios Restored logic to obtain authToken using AgenticAuthenticationService.GetAgenticUserTokenAsync when not provided but userAuthorization and authHandlerName are available. This supports both V1 and V2 authentication and provides fallback for non-Agent SDK contexts. Also, reorder using directives for consistency. --- .../Services/McpToolRegistrationService.cs | 9 ++++++++- .../Services/McpToolRegistrationService.cs | 14 ++++++++------ .../Services/McpToolRegistrationService.cs | 11 +++++++++-- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs index aa8b5caa..3caf65e8 100644 --- a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs @@ -4,6 +4,7 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.AgentFramework.Services; using Microsoft.Agents.A365.Runtime; +using Microsoft.Agents.A365.Runtime.Authentication; using Microsoft.Agents.A365.Tooling.Models; using Microsoft.Agents.A365.Tooling.Services; using Microsoft.Agents.AI; @@ -124,7 +125,13 @@ public async Task> GetMcpToolsAsync( string? authToken = null) { try - { + { + // Try to resolve the auth token for the tool discovery service directly, which will handle both V1 and V2 auth scenarios + // If this code is used outside of the Agent SDK context, the auth token may need to be provided directly, so we fall back to that if resolution fails. + if (authToken is null && userAuthorization is not null && authHandlerName is not null) + { + authToken = await AgenticAuthenticationService.GetAgenticUserTokenAsync(userAuthorization, authHandlerName, turnContext, _configuration).ConfigureAwait(false); + } authToken ??= string.Empty; var toolOptions = new ToolOptions diff --git a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs index 804adbcb..84207e33 100644 --- a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs @@ -104,12 +104,14 @@ public async Task AddToolServersToAgentAsync( if (agentClient == null) { throw new ArgumentNullException(nameof(agentClient)); - } - - if (authToken is null && !ToolingUtility.IsDevScenario(_configuration)) - { - authToken = await AgenticAuthenticationService.GetAgenticUserTokenAsync(userAuthorization, authHandlerName, turnContext, _configuration).ConfigureAwait(false); - } + } + + // Try to resolve the auth token for the tool discovery service directly, which will handle both V1 and V2 auth scenarios + // If this code is used outside of the Agent SDK context, the auth token may need to be provided directly, so we fall back to that if resolution fails. + if (authToken is null && userAuthorization is not null && authHandlerName is not null) + { + authToken = await AgenticAuthenticationService.GetAgenticUserTokenAsync(userAuthorization, authHandlerName, turnContext, _configuration).ConfigureAwait(false); + } authToken ??= string.Empty; var agenticAppId = turnContext.Activity.Recipient.AgenticAppId; diff --git a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs index 7ae2e8dd..df036005 100644 --- a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs @@ -3,9 +3,8 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Services { - using System; - using System.Linq; using Microsoft.Agents.A365.Runtime; + using Microsoft.Agents.A365.Runtime.Authentication; using Microsoft.Agents.A365.Tooling.Models; using Microsoft.Agents.A365.Tooling.Services; using Microsoft.Agents.Builder; @@ -14,6 +13,8 @@ namespace Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel.Services using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; + using System; + using System.Linq; using RuntimeUtility = Microsoft.Agents.A365.Runtime.Utils.Utility; /// @@ -53,6 +54,12 @@ public async Task AddToolServersToAgentAsync(Kernel kernel, UserAuthorization us throw new ArgumentNullException(nameof(kernel)); } + // Try to resolve the auth token for the tool discovery service directly, which will handle both V1 and V2 auth scenarios + // If this code is used outside of the Agent SDK context, the auth token may need to be provided directly, so we fall back to that if resolution fails. + if (authToken is null && userAuthorization is not null && authHandlerName is not null) + { + authToken = await AgenticAuthenticationService.GetAgenticUserTokenAsync(userAuthorization, authHandlerName, turnContext, _configuration).ConfigureAwait(false); + } authToken ??= string.Empty; // resolve agent identity from context or token. From d1317979f6898782ecc3f0a52730e0617d7652b6 Mon Sep 17 00:00:00 2001 From: MattB Date: Thu, 4 Jun 2026 18:36:07 -0700 Subject: [PATCH 09/10] Refactor token provider selection with ternary logic Refactored the selection of IMcpTokenProvider to use a single conditional (ternary) expression instead of if-else statements. This change reduces code duplication and improves readability while preserving the original logic for both development and production scenarios. --- .../Services/McpToolRegistrationService.cs | 17 +++---- .../Services/McpToolRegistrationService.cs | 46 ++++++++----------- .../Services/McpToolRegistrationService.cs | 19 +++----- 3 files changed, 33 insertions(+), 49 deletions(-) diff --git a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs index 3caf65e8..5f88cdc9 100644 --- a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs @@ -76,18 +76,13 @@ public async Task AddToolServersToAgent( UserAgentConfiguration = Agent365AgentFrameworkSdkUserAgentConfiguration.Instance }; - IMcpTokenProvider tokenProvider; - if (userAuthorization is not null && authHandlerName is not null) - { - // Production V2-aware path: per-audience OBO tokens. - tokenProvider = new TokenProviderCollection(_logger, + IMcpTokenProvider tokenProvider = + (userAuthorization is not null && authHandlerName is not null) + ? new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger), - new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); - } - else - { - tokenProvider = new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); - } + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)) + : + new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentUserId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); diff --git a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs index 84207e33..ead4599a 100644 --- a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs @@ -169,33 +169,27 @@ public async Task AddToolServersToAgentAsync( }; List servers; - Dictionary> toolsByServer; - - // Select token provider: - // Dev scenario → EnvMcpTokenProvider reads per-server env vars (no OBO flow needed). - // Production → AgenticMcpTokenProvider performs OBO when auth objects are supplied; - // falls back to the V1 shared-token path when they are absent. - if (userAuthorization is not null && authHandlerName is not null) - { - // Production V2-aware path: per-audience OBO tokens. - IMcpTokenProvider tokenProvider = new TokenProviderCollection(_logger, - new EnvMcpTokenProvider(_configuration, _logger), - new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); - - (servers, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentInstanceId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); - } - else - { - IMcpTokenProvider tokenProvider = new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); + Dictionary> toolsByServer; + + // Select token provider: + // Dev scenario → EnvMcpTokenProvider reads per-server env vars (no OBO flow needed). + // Production → AgenticMcpTokenProvider performs OBO when auth objects are supplied; + // falls back to the V1 shared-token path when they are absent. + IMcpTokenProvider tokenProvider = + (userAuthorization is not null && authHandlerName is not null) + ? new TokenProviderCollection(_logger, + new EnvMcpTokenProvider(_configuration, _logger), + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)) + : + new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); + + (servers, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync( + agentInstanceId, + authToken, + tokenProvider, + turnContext, + toolOptions).ConfigureAwait(false); - // Production V1 fallback: all servers share the single authToken. - (servers, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync( - agentInstanceId, - authToken, - tokenProvider, - turnContext, - toolOptions).ConfigureAwait(false); - } if (servers.Count == 0) { diff --git a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs index df036005..a3c23a15 100644 --- a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs @@ -70,18 +70,13 @@ public async Task AddToolServersToAgentAsync(Kernel kernel, UserAuthorization us UserAgentConfiguration = Agent365SemanticKernelSdkUserAgentConfiguration.Instance }; - IMcpTokenProvider tokenProvider; - if (userAuthorization is not null && authHandlerName is not null) - { - // Production V2-aware path: per-audience OBO tokens. - tokenProvider = new TokenProviderCollection(_logger, - new EnvMcpTokenProvider(_configuration, _logger), - new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); - } - else - { - tokenProvider = new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); - } + IMcpTokenProvider tokenProvider = + (userAuthorization is not null && authHandlerName is not null) + ? new TokenProviderCollection(_logger, + new EnvMcpTokenProvider(_configuration, _logger), + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)) + : + new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agenticAppId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); From 323a78c30a297d4f5e909f2dd58ff2d09eac179c Mon Sep 17 00:00:00 2001 From: MattB Date: Fri, 5 Jun 2026 12:19:36 -0700 Subject: [PATCH 10/10] Refactor token provider initialization logic Simplified IMcpTokenProvider setup in McpToolRegistrationService by replacing the if/else block with a conditional expression. This reduces code duplication and clarifies provider selection based on userAuthorization and authHandlerName presence. --- .../TokenProviderCollectionTests.cs | 484 ++++++++++++++++++ .../Services/McpToolRegistrationService.cs | 21 +- 2 files changed, 492 insertions(+), 13 deletions(-) create mode 100644 src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/TokenProviderCollectionTests.cs diff --git a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/TokenProviderCollectionTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/TokenProviderCollectionTests.cs new file mode 100644 index 00000000..75f559bc --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/TokenProviderCollectionTests.cs @@ -0,0 +1,484 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.Tooling.Models; +using Microsoft.Agents.A365.Tooling.Services; +using Microsoft.Extensions.Logging; +using Moq; +using Xunit; + +namespace Microsoft.Agents.A365.Tooling.Core.Tests; + +/// +/// Tests for which chains multiple token providers +/// and returns the first successful token. +/// +public class TokenProviderCollectionTests +{ + private static MCPServerConfig TestServer(string name = "test-server") => + new() { mcpServerName = name, id = $"id-{name}", url = "http://test" }; + + private static Mock CreateMockLogger() => new Mock(); + + // ─── Constructor validation ────────────────────────────────────────────── + + [Fact] + public void Constructor_WithValidProviders_Succeeds() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + + // Act + var act = () => new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object); + + // Assert + act.Should().NotThrow(); + } + + [Fact] + public void Constructor_WithNoProviders_Succeeds() + { + // Arrange + var logger = CreateMockLogger(); + + // Act + var act = () => new TokenProviderCollection(logger.Object); + + // Assert - Constructor succeeds; validation happens on GetTokenAsync + act.Should().NotThrow(); + } + + // ─── Provider ordering and priority ────────────────────────────────────── + + [Fact] + public async Task GetTokenAsync_FirstProviderReturnsToken_ReturnsFirstToken() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + + provider1.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("token-from-provider1"); + provider2.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("token-from-provider2"); + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object); + + // Act + var token = await collection.GetTokenAsync(TestServer()); + + // Assert + token.Should().Be("token-from-provider1"); + provider1.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + provider2.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetTokenAsync_FirstProviderFails_TriesSecondProvider() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + + provider1.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Provider 1 failed")); + provider2.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("token-from-provider2"); + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object); + + // Act + var token = await collection.GetTokenAsync(TestServer()); + + // Assert + token.Should().Be("token-from-provider2"); + provider1.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + provider2.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetTokenAsync_FirstProviderReturnsEmpty_TriesSecondProvider() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + + provider1.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(string.Empty); + provider2.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("token-from-provider2"); + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object); + + // Act + var token = await collection.GetTokenAsync(TestServer()); + + // Assert + token.Should().Be("token-from-provider2"); + provider1.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + provider2.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetTokenAsync_FirstProviderReturnsWhitespace_TriesSecondProvider() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + + provider1.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(" "); + provider2.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("token-from-provider2"); + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object); + + // Act + var token = await collection.GetTokenAsync(TestServer()); + + // Assert + token.Should().Be("token-from-provider2"); + } + + [Fact] + public async Task GetTokenAsync_MultipleProviders_TriesInOrder() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + var provider3 = new Mock(); + + provider1.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new Exception("Failed 1")); + provider2.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(string.Empty); + provider3.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("token-from-provider3"); + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object, provider3.Object); + + // Act + var token = await collection.GetTokenAsync(TestServer()); + + // Assert + token.Should().Be("token-from-provider3"); + provider1.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + provider2.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + provider3.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + // ─── Error handling and AggregateException ─────────────────────────────── + + [Fact] + public async Task GetTokenAsync_AllProvidersFail_ThrowsAggregateException() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + + provider1.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Provider 1 failed")); + provider2.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new UnauthorizedAccessException("Provider 2 failed")); + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object); + + // Act + var act = () => collection.GetTokenAsync(TestServer()); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*No valid token could be obtained from any provider*"); + } + + [Fact] + public async Task GetTokenAsync_AllProvidersFail_AggregateExceptionContainsAllInnerExceptions() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + + var exception1 = new InvalidOperationException("Provider 1 error"); + var exception2 = new UnauthorizedAccessException("Provider 2 error"); + + provider1.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(exception1); + provider2.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(exception2); + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object); + + // Act & Assert + var aggregateEx = await Assert.ThrowsAsync( + () => collection.GetTokenAsync(TestServer())); + + aggregateEx.InnerExceptions.Should().HaveCount(2); + aggregateEx.InnerExceptions[0].InnerException.Should().BeSameAs(exception1); + aggregateEx.InnerExceptions[1].InnerException.Should().BeSameAs(exception2); + } + + [Fact] + public async Task GetTokenAsync_AllProvidersReturnEmpty_ThrowsAggregateException() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + + provider1.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(string.Empty); + provider2.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(" "); + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object); + + // Act + var act = () => collection.GetTokenAsync(TestServer()); + + // Assert - When all providers return empty, no exceptions are collected, but still throws AggregateException + await act.Should().ThrowAsync() + .WithMessage("*No valid token could be obtained from any provider*"); + } + + // ─── Validation ────────────────────────────────────────────────────────── + + [Fact] + public async Task GetTokenAsync_NoProviders_ThrowsInvalidOperationException() + { + // Arrange + var logger = CreateMockLogger(); + var collection = new TokenProviderCollection(logger.Object); + + // Act + var act = () => collection.GetTokenAsync(TestServer()); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*No token providers are configured*"); + } + + [Fact] + public async Task GetTokenAsync_NullProviderInArray_ThrowsInvalidOperationException() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + IMcpTokenProvider? nullProvider = null; + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, nullProvider!); + + // Act + var act = () => collection.GetTokenAsync(TestServer()); + + // Assert + await act.Should().ThrowAsync() + .WithMessage("*One or more token providers are null*"); + } + + // ─── Cancellation ──────────────────────────────────────────────────────── + + [Fact] + public async Task GetTokenAsync_CancelledBeforeCall_ThrowsOperationCancelledException() + { + // Arrange + var logger = CreateMockLogger(); + var provider = new Mock(); + var collection = new TokenProviderCollection(logger.Object, provider.Object); + var cts = new CancellationTokenSource(); + cts.Cancel(); + + // Act + var act = () => collection.GetTokenAsync(TestServer(), cts.Token); + + // Assert + await act.Should().ThrowAsync(); + provider.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetTokenAsync_ProviderRespectsCancellation_PropagatesException() + { + // Arrange + var logger = CreateMockLogger(); + var provider = new Mock(); + var cts = new CancellationTokenSource(); + + provider.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new OperationCanceledException()); + + var collection = new TokenProviderCollection(logger.Object, provider.Object); + + // Act + var act = () => collection.GetTokenAsync(TestServer(), cts.Token); + + // Assert + await act.Should().ThrowAsync(); + } + + // ─── Logging behavior ──────────────────────────────────────────────────── + + [Fact] + public async Task GetTokenAsync_ProviderReturnsEmpty_LogsDebugMessage() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + + provider1.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(string.Empty); + provider2.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("valid-token"); + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object); + + // Act + await collection.GetTokenAsync(TestServer()); + + // Assert - Verify that LogDebug was called (checking the log level) + logger.Verify( + l => l.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("returned an empty token")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + [Fact] + public async Task GetTokenAsync_ProviderFails_LogsDebugMessage() + { + // Arrange + var logger = CreateMockLogger(); + var provider1 = new Mock(); + var provider2 = new Mock(); + + provider1.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Test failure")); + provider2.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("valid-token"); + + var collection = new TokenProviderCollection(logger.Object, provider1.Object, provider2.Object); + + // Act + await collection.GetTokenAsync(TestServer()); + + // Assert - Verify that LogDebug was called for the failure + logger.Verify( + l => l.Log( + LogLevel.Debug, + It.IsAny(), + It.Is((v, t) => v.ToString()!.Contains("failed to obtain a token")), + It.IsAny(), + It.IsAny>()), + Times.Once); + } + + // ─── Integration scenarios ─────────────────────────────────────────────── + + [Fact] + public async Task GetTokenAsync_RealWorldScenario_EnvThenAgentic() + { + // Simulate the common pattern: EnvMcpTokenProvider (dev) fallback to AgenticMcpTokenProvider (prod) + // Arrange + var logger = CreateMockLogger(); + var envProvider = new Mock(); + var agenticProvider = new Mock(); + + // EnvProvider returns empty (not in dev scenario or no env var set) + envProvider.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(string.Empty); + + // AgenticProvider performs OBO and returns token + agenticProvider.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("agentic-obo-token"); + + var collection = new TokenProviderCollection(logger.Object, envProvider.Object, agenticProvider.Object); + + // Act + var token = await collection.GetTokenAsync(TestServer()); + + // Assert + token.Should().Be("agentic-obo-token"); + envProvider.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + agenticProvider.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetTokenAsync_RealWorldScenario_EnvProviderSucceedsInDev() + { + // Simulate dev scenario where EnvProvider finds token + // Arrange + var logger = CreateMockLogger(); + var envProvider = new Mock(); + var agenticProvider = new Mock(); + + // EnvProvider finds token from environment variable + envProvider.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("dev-env-token"); + + // AgenticProvider should never be called + agenticProvider.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("should-not-be-used"); + + var collection = new TokenProviderCollection(logger.Object, envProvider.Object, agenticProvider.Object); + + // Act + var token = await collection.GetTokenAsync(TestServer()); + + // Assert + token.Should().Be("dev-env-token"); + envProvider.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Once); + agenticProvider.Verify(p => p.GetTokenAsync(It.IsAny(), It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetTokenAsync_SingleProvider_ReturnsToken() + { + // Arrange + var logger = CreateMockLogger(); + var provider = new Mock(); + + provider.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync("single-provider-token"); + + var collection = new TokenProviderCollection(logger.Object, provider.Object); + + // Act + var token = await collection.GetTokenAsync(TestServer()); + + // Assert + token.Should().Be("single-provider-token"); + } + + [Fact] + public async Task GetTokenAsync_SingleProviderFails_ThrowsAggregateException() + { + // Arrange + var logger = CreateMockLogger(); + var provider = new Mock(); + + provider.Setup(p => p.GetTokenAsync(It.IsAny(), It.IsAny())) + .ThrowsAsync(new InvalidOperationException("Single provider failed")); + + var collection = new TokenProviderCollection(logger.Object, provider.Object); + + // Act + var act = () => collection.GetTokenAsync(TestServer()); + + // Assert + var aggregateEx = await act.Should().ThrowAsync(); + aggregateEx.Which.InnerExceptions.Should().HaveCount(1); + } +} diff --git a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs index 5f88cdc9..afafacb3 100644 --- a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs @@ -134,21 +134,16 @@ public async Task> GetMcpToolsAsync( UserAgentConfiguration = Agent365AgentFrameworkSdkUserAgentConfiguration.Instance }; - IMcpTokenProvider tokenProvider; - if (userAuthorization is not null && authHandlerName is not null) - { - // Production V2-aware path: per-audience OBO tokens. - tokenProvider = new TokenProviderCollection(_logger, + IMcpTokenProvider tokenProvider = + (userAuthorization is not null && authHandlerName is not null) + ? new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger), - new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)); - } - else - { - tokenProvider = new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); - } + new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _configuration, _logger)) + : + new TokenProviderCollection(_logger, new EnvMcpTokenProvider(_configuration, _logger)); + + var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentUserId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); - var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync( - agentUserId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); IList mcpTools = toolsByServer.Values.SelectMany(t => t).ToList(); // Convert to AITool list