Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,14 @@
namespace Microsoft.Agents.A365.Tooling.Core.Tests;

/// <summary>
/// Tests for <see cref="DevMcpTokenProvider"/> and <see cref="Utility.IsDevScenario"/>.
/// Tests for <see cref="EnvMcpTokenProvider"/> and <see cref="Utility.IsDevScenario"/>.
/// </summary>
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<ILogger>());
Comment thread
MattB-msft marked this conversation as resolved.

private static IConfiguration Config(params (string key, string value)[] entries)
Expand Down Expand Up @@ -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();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,17 @@ namespace Microsoft.Agents.A365.Tooling.Services
/// </list>
/// Hyphens in the server name are normalised to underscores before the lookup.
/// </remarks>
internal sealed class DevMcpTokenProvider : IMcpTokenProvider
internal sealed class EnvMcpTokenProvider : IMcpTokenProvider
{
private readonly IConfiguration _configuration;
private readonly ILogger _logger;

/// <summary>
/// Initializes a new instance of <see cref="DevMcpTokenProvider"/>.
/// Initializes a new instance of <see cref="EnvMcpTokenProvider"/>.
/// </summary>
/// <param name="configuration">Application configuration (env vars, appsettings, etc.).</param>
/// <param name="logger">Logger for diagnostic output.</param>
public DevMcpTokenProvider(IConfiguration configuration, ILogger logger)
public EnvMcpTokenProvider(IConfiguration configuration, ILogger logger)
{
_configuration = configuration;
_logger = logger;
Expand All @@ -55,9 +55,13 @@ public Task<string> 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.
Expand Down
35 changes: 26 additions & 9 deletions src/Tooling/Core/Services/McpToolServerConfigurationService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -552,22 +552,39 @@ private async Task AttachPerAudienceTokensAsync(
// Sequential acquisition avoids throttling the OBO endpoint.
var tokenByScope = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);

List<MCPServerConfig> failedToAcquireServers = new List<MCPServerConfig>();
foreach (var server in servers)
{
cancellationToken.ThrowIfCancellationRequested();
try
{
Comment thread
MattB-msft marked this conversation as resolved.
var scope = Utils.Utility.ResolveTokenScopeForServer(server, _configuration);
if (!tokenByScope.TryGetValue(scope, out var token))
Comment on lines 608 to +612
{
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<string, string>(StringComparer.OrdinalIgnoreCase);
server.Headers[Constants.Headers.Authorization] = $"{Constants.Headers.BearerPrefix} {token}";
}
catch (Exception ex)
Comment thread
gwharris7 marked this conversation as resolved.
{
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);
Comment thread
MattB-msft marked this conversation as resolved.
}
Comment thread
MattB-msft marked this conversation as resolved.
Comment thread
MattB-msft marked this conversation as resolved.
}

server.Headers ??= new Dictionary<string, string>(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();
}
}

Expand Down
66 changes: 66 additions & 0 deletions src/Tooling/Core/Services/TokenProviderCollection.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
Comment thread
MattB-msft marked this conversation as resolved.

using Microsoft.Agents.A365.Tooling.Models;
using Microsoft.Extensions.Logging;

namespace Microsoft.Agents.A365.Tooling.Services
{
internal class TokenProviderCollection : IMcpTokenProvider
{
readonly SortedDictionary<int, IMcpTokenProvider> _providers;
readonly ILogger _logger;

public TokenProviderCollection(
ILogger logger,
params IMcpTokenProvider[] providers)
{
_logger = logger;
_providers = new SortedDictionary<int, IMcpTokenProvider>();
for (int i = 0; i < providers.Length; i++)
{
_providers.Add(i, providers[i]);
}

}

public async Task<string> 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<Exception> exceptions = new List<Exception>();
// 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)
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
MattB-msft marked this conversation as resolved.
Dismissed
{
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)
Comment thread
gwharris7 marked this conversation as resolved.
{
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);
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
MattB-msft marked this conversation as resolved.
Comment thread
MattB-msft marked this conversation as resolved.
Dismissed
}
throw new AggregateException("No valid token could be obtained from any provider.", exceptions);
Comment thread
MattB-msft marked this conversation as resolved.
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,21 @@

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;
Comment thread
MattB-msft marked this conversation as resolved.
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;

/// <summary>
/// Service for registering and validating MCP tool servers for Agent Framework scenarios.
Expand Down Expand Up @@ -64,10 +60,6 @@ public async Task<AIAgent> 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;

Comment thread
MattB-msft marked this conversation as resolved.
try
Expand All @@ -83,12 +75,19 @@ public async Task<AIAgent> 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;
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));
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed

var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(agentUserId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false);

// Add all MCP tools from all servers
Expand Down Expand Up @@ -126,20 +125,25 @@ public async Task<IList<AITool>> 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
{
UserAgentConfiguration = Agent365AgentFrameworkSdkUserAgentConfiguration.Instance
};

IMcpTokenProvider tokenProvider = ToolingUtility.IsDevScenario(_configuration)
? new DevMcpTokenProvider(_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));
}
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed
Comment thread
github-code-quality[bot] marked this conversation as resolved.
Fixed

var (_, toolsByServer) = await _mcpServerConfigurationService.EnumerateToolsFromServersAsync(
agentUserId, authToken, tokenProvider, turnContext, toolOptions).ConfigureAwait(false);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -172,28 +170,27 @@ public async Task AddToolServersToAgentAsync(
Dictionary<string, IList<McpClientTool>> 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.
Comment thread
gwharris7 marked this conversation as resolved.
Outdated
Comment thread
MattB-msft marked this conversation as resolved.
Outdated
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)
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(_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,
Comment thread
MattB-msft marked this conversation as resolved.
Outdated
authToken,
tokenProvider,
turnContext,
toolOptions).ConfigureAwait(false);
}
Expand Down
2 changes: 1 addition & 1 deletion src/Tooling/Extensions/SemanticKernel/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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_<SERVERNAME>` / `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_<SERVERNAME>` / `BEARER_TOKEN`) without requiring a working auth setup
Comment thread
MattB-msft marked this conversation as resolved.
Comment thread
MattB-msft marked this conversation as resolved.

### Added
- **Microsoft.Agents.A365.Tooling.Extensions.SemanticKernel** - Semantic Kernel integration tooling for MCP server management
Expand Down
Loading
Loading