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 79% 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 b9455fe3..8f01ed2f 100644 --- a/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/DevMcpTokenProviderTests.cs +++ b/src/Tests/Microsoft.Agents.A365.Tooling.Core.Tests/EnvMcpTokenProviderTests.cs @@ -13,14 +13,14 @@ namespace Microsoft.Agents.A365.Tooling.Core.Tests; /// -/// Tests for and . +/// Tests for and . /// -public class DevMcpTokenProviderTests +public class EnvMcpTokenProviderTests { 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) @@ -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 ───────────────────────────────────────────────────────── @@ -214,7 +181,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/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/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/McpToolRegistrationServiceTests.cs b/src/Tests/Microsoft.Agents.A365.Tooling.Extensions.AzureAIFoundry.Tests/McpToolRegistrationServiceTests.cs index d3ef8ef6..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 @@ -93,12 +93,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 +114,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 +198,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 @@ + diff --git a/src/Tooling/Core/Services/DevMcpTokenProvider.cs b/src/Tooling/Core/Services/EnvMcpTokenProvider.cs similarity index 86% rename from src/Tooling/Core/Services/DevMcpTokenProvider.cs rename to src/Tooling/Core/Services/EnvMcpTokenProvider.cs index 01ba62db..cd7d63e8 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; @@ -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 fd0ea672..360c3c64 100644 --- a/src/Tooling/Core/Services/McpToolServerConfigurationService.cs +++ b/src/Tooling/Core/Services/McpToolServerConfigurationService.cs @@ -602,22 +602,39 @@ private async Task AttachPerAudienceTokensAsync( // Sequential acquisition avoids throttling the OBO endpoint. var tokenByScope = new Dictionary(StringComparer.OrdinalIgnoreCase); + List failedToAcquireServers = 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); + failedToAcquireServers.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 (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(); } } diff --git a/src/Tooling/Core/Services/TokenProviderCollection.cs b/src/Tooling/Core/Services/TokenProviderCollection.cs new file mode 100644 index 00000000..1463e9bf --- /dev/null +++ b/src/Tooling/Core/Services/TokenProviderCollection.cs @@ -0,0 +1,66 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Microsoft.Agents.A365.Tooling.Models; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.A365.Tooling.Services +{ + internal class TokenProviderCollection : IMcpTokenProvider + { + readonly SortedDictionary _providers; + readonly ILogger _logger; + + public TokenProviderCollection( + ILogger logger, + params IMcpTokenProvider[] providers) + { + _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) + { + try + { + var token = await provider.GetTokenAsync(server, cancellationToken); + if (!string.IsNullOrWhiteSpace(token)) + { + 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 95f49767..afafacb3 100644 --- a/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/AgentFramework/Services/McpToolRegistrationService.cs @@ -3,25 +3,22 @@ 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.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; 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; /// /// Service for registering and validating MCP tool servers for Agent Framework scenarios. @@ -64,10 +61,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 @@ -83,12 +76,14 @@ 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 = ToolingUtility.IsDevScenario(_configuration) - ? new DevMcpTokenProvider(_configuration, _logger) - : new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _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(agentUserId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); // Add all MCP tools from all servers @@ -125,11 +120,13 @@ public async Task> GetMcpToolsAsync( string? authToken = null) { try - { - 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 toolOptions = new ToolOptions @@ -137,12 +134,16 @@ 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 = + (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(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 diff --git a/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs b/src/Tooling/Extensions/AzureAIFoundry/Services/McpToolRegistrationService.cs index 4d3ae2db..ead4599a 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; @@ -106,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; @@ -169,34 +169,27 @@ public async Task AddToolServersToAgentAsync( }; List servers; - Dictionary> toolsByServer; - - // Select token provider: - // 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 DevMcpTokenProvider(_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); + 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); - (servers, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentInstanceId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false); - } - else - { - // Production V1 fallback: all servers share the single authToken. - (servers, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync( - agentInstanceId, - authToken, - turnContext, - toolOptions).ConfigureAwait(false); - } if (servers.Count == 0) { 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 5253be99..a3c23a15 100644 --- a/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs +++ b/src/Tooling/Extensions/SemanticKernel/Services/McpToolRegistrationService.cs @@ -3,22 +3,18 @@ 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 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; using Microsoft.Extensions.Configuration; - using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using Microsoft.SemanticKernel; using Microsoft.SemanticKernel.ChatCompletion; + using System; + using System.Linq; using RuntimeUtility = Microsoft.Agents.A365.Runtime.Utils.Utility; /// @@ -58,7 +54,9 @@ public async Task AddToolServersToAgentAsync(Kernel kernel, UserAuthorization us throw new ArgumentNullException(nameof(kernel)); } - if (authToken is null && !ToolingUtility.IsDevScenario(_configuration)) + // 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); } @@ -72,11 +70,13 @@ 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 = ToolingUtility.IsDevScenario(_configuration) - ? new DevMcpTokenProvider(_configuration, _logger) - : new AgenticMcpTokenProvider(userAuthorization, authHandlerName, turnContext, _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);