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);