From ad15b96f51b1b55db5b54d4486b20fb9977aadd4 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Wed, 14 Jan 2026 16:25:53 -0800 Subject: [PATCH 1/9] Support OAuth protected registry server --- .../Discovery/RegistryDiscoveryStrategy.cs | 8 ++- .../Discovery/RegistryServerProvider.cs | 36 +++++++++-- .../Areas/Server/Models/RegistryServerInfo.cs | 11 +++- .../src/Areas/Server/Resources/registry.json | 6 ++ .../src/Services/Http/HttpClientService.cs | 59 +++++++++++++++++++ .../src/Services/Http/IHttpClientService.cs | 8 +++ .../RegistryDiscoveryStrategyTests.cs | 6 +- .../Discovery/RegistryServerProviderTests.cs | 31 ++++++---- .../ServiceCollectionExtensionsTests.cs | 2 + .../ToolLoading/ServerToolLoaderTests.cs | 14 ++++- .../ToolLoading/SingleProxyToolLoaderTests.cs | 12 +++- 11 files changed, 172 insertions(+), 21 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs index f660392048..36828f64fa 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs @@ -4,6 +4,8 @@ using System.Reflection; using Azure.Mcp.Core.Areas.Server.Models; using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -15,9 +17,11 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; /// /// Options for configuring the service behavior. /// Logger instance for this discovery strategy. -public sealed class RegistryDiscoveryStrategy(IOptions options, ILogger logger) : BaseDiscoveryStrategy(logger) +public sealed class RegistryDiscoveryStrategy(IOptions options, ILogger logger, IHttpClientService httpClientService, IAzureTokenCredentialProvider tokenCredentialProvider) : BaseDiscoveryStrategy(logger) { private readonly IOptions _options = options; + private readonly IHttpClientService _httpClientService = httpClientService; + private readonly IAzureTokenCredentialProvider _tokenCredentialProvider = tokenCredentialProvider; /// public override async Task> DiscoverServersAsync(CancellationToken cancellationToken) @@ -33,7 +37,7 @@ public override async Task> DiscoverServersAsync .Where(s => _options.Value.Namespace == null || _options.Value.Namespace.Length == 0 || _options.Value.Namespace.Contains(s.Key, StringComparer.OrdinalIgnoreCase)) - .Select(s => new RegistryServerProvider(s.Key, s.Value)) + .Select(s => new RegistryServerProvider(s.Key, s.Value, _httpClientService, _tokenCredentialProvider)) .Cast(); } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs index dac744e633..3e07830483 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs @@ -2,6 +2,8 @@ // Licensed under the MIT License. using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Http; using ModelContextProtocol.Client; namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; @@ -12,10 +14,14 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; /// /// The unique identifier for the server. /// Configuration information for the server. -public sealed class RegistryServerProvider(string id, RegistryServerInfo serverInfo) : IMcpServerProvider +/// The HTTP client service for creating HTTP clients. +/// The token credential provider for OAuth authentication. +public sealed class RegistryServerProvider(string id, RegistryServerInfo serverInfo, IHttpClientService httpClientService, IAzureTokenCredentialProvider tokenCredentialProvider) : IMcpServerProvider { private readonly string _id = id; private readonly RegistryServerInfo _serverInfo = serverInfo; + private readonly IHttpClientService _httpClientService = httpClientService; + private readonly IAzureTokenCredentialProvider _tokenCredentialProvider = tokenCredentialProvider; /// /// Creates metadata that describes this registry-based server. @@ -36,8 +42,7 @@ public McpServerMetadata CreateMetadata() public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) { Func>? clientFactory = null; - - // Determine which factory function to use based on configuration + if (!string.IsNullOrWhiteSpace(_serverInfo.Url)) { clientFactory = CreateHttpClientAsync; @@ -88,8 +93,31 @@ private async Task CreateHttpClientAsync(McpClientOptions clientOptio Name = _id, Endpoint = new Uri(_serverInfo.Url!), TransportMode = HttpTransportMode.AutoDetect, + // HttpClientTransportOptions offers an OAuth property to configure client side OAuth parameters, such as RedirectUri and ClientId. + // When OAuth property is set, the MCP client will attempt to complete the Auth flow following the MCP protocol. + // However, there is a gap between what MCP protocol requires the OAuth provider to implement and what Entra supports. This MCP client will always send a resource parameter to the token endpoint because it is required by the MCP protocol but Entra doesn't support it. More details in issue #939 and related discussions in modelcontextprotocol/csharp-sdk GitHub repo. + // See DefaultAuthorizationUrlHandler in ClientOAuthProvider.cs in modelcontextprotocol/csharp-sdk GitHub repo. }; - var clientTransport = new HttpClientTransport(transportOptions); + + HttpClientTransport clientTransport; + if (_serverInfo.OAuthScopes is not null) + { + var FetchAccessToken = async (CancellationToken cancellationToken) => + { + var credential = await _tokenCredentialProvider.GetTokenCredentialAsync(tenantId: null, cancellationToken); + var tokenContext = new Azure.Core.TokenRequestContext(_serverInfo.OAuthScopes); + var token = await credential.GetTokenAsync(tokenContext, cancellationToken); + return token.Token; + }; + + var client = _httpClientService.CreateClientWithAccessToken(FetchAccessToken); + clientTransport = new HttpClientTransport(transportOptions, client, ownsHttpClient: true); + } + else + { + clientTransport = new HttpClientTransport(transportOptions); + } + return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken); } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs index c17519da55..1076c62c7e 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs @@ -19,11 +19,19 @@ public sealed class RegistryServerInfo public string? Name { get; set; } /// - /// Gets the URL endpoint (deprecated - no longer used). + /// URL of the remote server. + /// This should be undefined if the transport type is "stdio". /// [JsonPropertyName("url")] public string? Url { get; init; } + /// + /// OAuth scopes to request in the access token. + /// Used for remote MCP servers protected by OAuth. + /// + [JsonPropertyName("oauthScopes")] + public string[]? OAuthScopes { get; init; } + /// /// Gets a description of the server's purpose or capabilities. /// @@ -38,6 +46,7 @@ public sealed class RegistryServerInfo /// /// Gets the transport type, e.g., "stdio". + /// This should be undefined if url is defined. /// [JsonPropertyName("type")] public string? Type { get; init; } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json b/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json index 27f63ab727..5804265771 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json +++ b/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json @@ -12,6 +12,12 @@ "title": "Azure Developer CLI", "description": "Azure Developer CLI (azd) includes a suite of tools to help build, modernize, and manage applications on Azure. It simplifies the process of developing cloud applications by providing commands for project initialization, resource provisioning, deployment, and monitoring. Use this tool to streamline your Azure development workflow and manage your cloud resources efficiently.", "installInstructions": "The Azure Developer CLI (azd) is either not installed or requires an update. The minimum required version that works with MCP tools is 1.20.0.\n\nTo install or upgrade, follow the instructions at https://aka.ms/azd/install\n\nAfter installation you may need to restart the Azure MCP server and your IDE." + }, + "arm": { + "url": "https://tbd.com", + "title": "Azure Resource Manager", + "description": "Query Azure Resource Graph for Azure Resource information", + "oauthScopes": [ "api://tbd_app_id/.default" ] } } } diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs index e2f3184edf..dc7675be14 100644 --- a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Net; +using System.Net.Http.Headers; using System.Reflection; using System.Runtime.Versioning; using Azure.Mcp.Core.Areas.Server.Options; @@ -62,6 +63,41 @@ public HttpClient CreateClient(Uri? baseAddress, Action configureCli return client; } + /// + /// Creates a new HttpClient instance with a customized function to acquire an access token for its outgoing requests. + /// + /// A function to acquire access token. + /// The base address for the HttpClient + /// A new HttpClient instance. + public HttpClient CreateClientWithAccessToken(Func> accessTokenProvider, Uri? baseAddress) + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(HttpClientService)); + } + + ArgumentNullException.ThrowIfNull(accessTokenProvider); + + var handler = CreateHttpClientHandler(); + + var accessTokenHandler = new AccessTokenHandler(accessTokenProvider) + { + InnerHandler = handler + }; + + var client = new HttpClient(accessTokenHandler, disposeHandler: true); + + client.Timeout = _options.DefaultTimeout; + client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent); + + if (baseAddress != null) + { + client.BaseAddress = baseAddress; + } + + return client; + } + private HttpClient CreateClientInternal() { var handler = CreateHttpClientHandler(); @@ -191,4 +227,27 @@ public void Dispose() _disposed = true; } } + + /// + /// DelegatingHandler that adds a Bearer access token to each outgoing request. + /// + private sealed class AccessTokenHandler : DelegatingHandler + { + private readonly Func> _accessTokenProvider; + + public AccessTokenHandler(Func> accessTokenProvider) + { + _accessTokenProvider = accessTokenProvider ?? throw new ArgumentNullException(nameof(accessTokenProvider)); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var token = await _accessTokenProvider(cancellationToken); + if (!string.IsNullOrEmpty(token)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + return await base.SendAsync(request, cancellationToken); + } + } } diff --git a/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs b/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs index 5c4d478f36..3fcac965ab 100644 --- a/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs +++ b/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs @@ -27,4 +27,12 @@ public interface IHttpClientService /// Additional configuration for the HttpClient. /// A new HttpClient instance. HttpClient CreateClient(Uri? baseAddress, Action configureClient); + + /// + /// Creates a new HttpClient instance with a customized function to acquire an access token for its outgoing requests. + /// + /// A function to acquire access token. + /// The base address for the HttpClient + /// A new HttpClient instance. + HttpClient CreateClientWithAccessToken(Func> accessTokenProvider, Uri? baseAddress = null); } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs index ab622f9de8..e1e35a215a 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs @@ -3,6 +3,8 @@ using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Http; using Xunit; namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; @@ -13,7 +15,9 @@ private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions? opt { var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); var logger = NSubstitute.Substitute.For>(); - return new RegistryDiscoveryStrategy(serviceOptions, logger); + var httpClientService = NSubstitute.Substitute.For(); + var tokenCredentialProvider = NSubstitute.Substitute.For(); + return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientService, tokenCredentialProvider); } [Fact] diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs index ec3129e792..f497aa8322 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs @@ -5,13 +5,24 @@ using System.Net.Sockets; using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Http; using ModelContextProtocol.Client; +using NSubstitute; using Xunit; namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; public class RegistryServerProviderTests { + private static RegistryServerProvider CreateServerProvider(string id, RegistryServerInfo serverInfo) + { + var httpClientService = NSubstitute.Substitute.For(); + httpClientService.CreateClientWithAccessToken(Arg.Any>>(), Arg.Any()) + .Returns(new HttpClient()); + var tokenCredentialProvider = NSubstitute.Substitute.For(); + return new RegistryServerProvider(id, serverInfo, httpClientService, tokenCredentialProvider); + } [Fact] public void Constructor_InitializesCorrectly() { @@ -23,7 +34,7 @@ public void Constructor_InitializesCorrectly() }; // Act - var provider = new RegistryServerProvider(testId, serverInfo); + var provider = CreateServerProvider(testId, serverInfo); // Assert Assert.NotNull(provider); @@ -39,7 +50,7 @@ public void CreateMetadata_ReturnsExpectedMetadata() { Description = "Test Description" }; - var provider = new RegistryServerProvider(testId, serverInfo); + var provider = CreateServerProvider(testId, serverInfo); // Act var metadata = provider.CreateMetadata(); @@ -61,7 +72,7 @@ public void CreateMetadata_EmptyDescription_ReturnsEmptyString() { Description = null }; - var provider = new RegistryServerProvider(testId, serverInfo); + var provider = CreateServerProvider(testId, serverInfo); // Act var metadata = provider.CreateMetadata(); @@ -85,7 +96,7 @@ public void CreateMetadata_WithTitle_ReturnsTitleInMetadata() Title = testTitle, Description = "Test Description" }; - var provider = new RegistryServerProvider(testId, serverInfo); + var provider = CreateServerProvider(testId, serverInfo); // Act var metadata = provider.CreateMetadata(); @@ -109,7 +120,7 @@ public void CreateMetadata_WithTitle_ReturnsTitleInMetadata() // Description = "Test SSE Provider", // Url = $"{server.Endpoint}/mcp" // }; - // var provider = new RegistryServerProvider(testId, serverInfo); + // var provider = CreateServerProvider(testId, serverInfo); // // Act & Assert // var exception = await Assert.ThrowsAsync( @@ -130,7 +141,7 @@ public async Task CreateClientAsync_WithStdioType_CreatesStdioClient() Command = "echo", Args = ["hello world"] }; - var provider = new RegistryServerProvider(testId, serverInfo); + var provider = CreateServerProvider(testId, serverInfo); // Act & Assert - Should throw InvalidOperationException for subprocess startup failure // since configuration is valid but external process fails to start properly @@ -156,7 +167,7 @@ public async Task CreateClientAsync_WithEnvVariables_MergesWithSystemEnvironment { "TEST_VAR", "test value" } } }; - var provider = new RegistryServerProvider(testId, serverInfo); + var provider = CreateServerProvider(testId, serverInfo); // Act & Assert - Should throw InvalidOperationException for subprocess startup failure // since configuration is valid but external process fails to start properly @@ -176,7 +187,7 @@ public async Task CreateClientAsync_NoUrlOrType_ThrowsArgumentException() Description = "Invalid Provider - No Transport" // No Url or Type specified }; - var provider = new RegistryServerProvider(testId, serverInfo); + var provider = CreateServerProvider(testId, serverInfo); // Act & Assert var exception = await Assert.ThrowsAsync( @@ -197,7 +208,7 @@ public async Task CreateClientAsync_StdioWithoutCommand_ThrowsInvalidOperationEx Type = "stdio" // No Command specified }; - var provider = new RegistryServerProvider(testId, serverInfo); + var provider = CreateServerProvider(testId, serverInfo); // Act & Assert var exception = await Assert.ThrowsAsync( @@ -221,7 +232,7 @@ public async Task CreateClientAsync_WithInstallInstructions_IncludesInstructions Args = ["--serve"], InstallInstructions = installInstructions }; - var provider = new RegistryServerProvider(testId, serverInfo); + var provider = CreateServerProvider(testId, serverInfo); // Act & Assert - Should throw InvalidOperationException with install instructions var exception = await Assert.ThrowsAsync( diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs index ad16dcdf40..02fe5da704 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs @@ -7,6 +7,7 @@ using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Commands; +using Azure.Mcp.Core.Services.Azure.Authentication; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Options; using ModelContextProtocol.Server; @@ -22,6 +23,7 @@ private IServiceCollection SetupBaseServices() { var services = CommandFactoryHelpers.SetupCommonServices(); services.AddSingleton(sp => CommandFactoryHelpers.CreateCommandFactory(sp)); + services.AddSingleIdentityTokenCredentialProvider(); return services; } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs index 027ea726cc..3d12a42172 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs @@ -5,6 +5,8 @@ using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -49,6 +51,14 @@ private static ModelContextProtocol.Server.RequestContext }; } + private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions options, ILogger logger) + { + var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); + var httpClientService = Substitute.For(); + var tokenCredentialProvider = Substitute.For(); + return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientService, tokenCredentialProvider); + } + [Fact] public async Task CallToolHandler_WithoutListToolsFirst_ShouldSucceed() { @@ -58,7 +68,7 @@ public async Task CallToolHandler_WithoutListToolsFirst_ShouldSucceed() var serviceStartOptions = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions()); var discoveryLogger = loggerFactory.CreateLogger(); - var discoveryStrategy = new RegistryDiscoveryStrategy(serviceStartOptions, discoveryLogger); + var discoveryStrategy = CreateStrategy(serviceStartOptions.Value, discoveryLogger); var logger = loggerFactory.CreateLogger(); var toolLoader = new ServerToolLoader(discoveryStrategy, toolLoaderOptions, logger); @@ -112,7 +122,7 @@ public async Task ListToolsHandler_WithRealRegistryDiscovery_ReturnsExpectedStru var serviceStartOptions = Microsoft.Extensions.Options.Options.Create(new ServiceStartOptions()); var toolLoaderOptions = Microsoft.Extensions.Options.Options.Create(new ToolLoaderOptions()); var discoveryLogger = loggerFactory.CreateLogger(); - var discoveryStrategy = new RegistryDiscoveryStrategy(serviceStartOptions, discoveryLogger); + var discoveryStrategy = CreateStrategy(serviceStartOptions.Value, discoveryLogger); var logger = loggerFactory.CreateLogger(); var toolLoader = new ServerToolLoader(discoveryStrategy, toolLoaderOptions, logger); diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs index 8d69dbe4ed..bd0f810f7f 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs @@ -5,6 +5,8 @@ using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; using Azure.Mcp.Core.Areas.Server.Options; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Services.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -15,6 +17,14 @@ namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.ToolLoading; public class SingleProxyToolLoaderTests { + private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions options, ILogger logger) + { + var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); + var httpClientService = Substitute.For(); + var tokenCredentialProvider = Substitute.For(); + return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientService, tokenCredentialProvider); + } + private static (SingleProxyToolLoader toolLoader, IMcpDiscoveryStrategy discoveryStrategy) CreateToolLoader(bool useRealDiscovery = true) { var serviceProvider = CommandFactoryHelpers.CreateDefaultServiceProvider(); @@ -31,7 +41,7 @@ private static (SingleProxyToolLoader toolLoader, IMcpDiscoveryStrategy discover commandGroupLogger ); var registryLogger = serviceProvider.GetRequiredService>(); - var registryDiscoveryStrategy = new RegistryDiscoveryStrategy(options, registryLogger); + var registryDiscoveryStrategy = CreateStrategy(options.Value, registryLogger); var compositeLogger = serviceProvider.GetRequiredService>(); var compositeDiscoveryStrategy = new CompositeDiscoveryStrategy([ commandGroupDiscoveryStrategy, From fffe0558550ecb5c85918d902c87377230e2ad02 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Wed, 14 Jan 2026 16:29:44 -0800 Subject: [PATCH 2/9] Remove unneeded comment --- .../Areas/Server/Commands/Discovery/RegistryServerProvider.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs index 3e07830483..765becabfa 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs @@ -96,7 +96,6 @@ private async Task CreateHttpClientAsync(McpClientOptions clientOptio // HttpClientTransportOptions offers an OAuth property to configure client side OAuth parameters, such as RedirectUri and ClientId. // When OAuth property is set, the MCP client will attempt to complete the Auth flow following the MCP protocol. // However, there is a gap between what MCP protocol requires the OAuth provider to implement and what Entra supports. This MCP client will always send a resource parameter to the token endpoint because it is required by the MCP protocol but Entra doesn't support it. More details in issue #939 and related discussions in modelcontextprotocol/csharp-sdk GitHub repo. - // See DefaultAuthorizationUrlHandler in ClientOAuthProvider.cs in modelcontextprotocol/csharp-sdk GitHub repo. }; HttpClientTransport clientTransport; From 4cb4e01385ae2f76b0cb81366c4a60dcc5d5f219 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Thu, 15 Jan 2026 16:08:37 -0800 Subject: [PATCH 3/9] Remove extra whitespace --- .../Areas/Server/Commands/Discovery/RegistryServerProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs index 765becabfa..f50f26d838 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs @@ -42,7 +42,7 @@ public McpServerMetadata CreateMetadata() public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken) { Func>? clientFactory = null; - + if (!string.IsNullOrWhiteSpace(_serverInfo.Url)) { clientFactory = CreateHttpClientAsync; From c33e3843b49e86571979a5ab40ed4769d2ad0769 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Fri, 16 Jan 2026 13:41:27 -0800 Subject: [PATCH 4/9] Rename local function --- .../Areas/Server/Commands/Discovery/RegistryServerProvider.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs index f50f26d838..dd0bc6ccc7 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs @@ -101,7 +101,7 @@ private async Task CreateHttpClientAsync(McpClientOptions clientOptio HttpClientTransport clientTransport; if (_serverInfo.OAuthScopes is not null) { - var FetchAccessToken = async (CancellationToken cancellationToken) => + var fetchAccessToken = async (CancellationToken cancellationToken) => { var credential = await _tokenCredentialProvider.GetTokenCredentialAsync(tenantId: null, cancellationToken); var tokenContext = new Azure.Core.TokenRequestContext(_serverInfo.OAuthScopes); @@ -109,7 +109,7 @@ private async Task CreateHttpClientAsync(McpClientOptions clientOptio return token.Token; }; - var client = _httpClientService.CreateClientWithAccessToken(FetchAccessToken); + var client = _httpClientService.CreateClientWithAccessToken(fetchAccessToken); clientTransport = new HttpClientTransport(transportOptions, client, ownsHttpClient: true); } else From c8b6afa2a5305fae8637a5ae6f6dde9d7966af91 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Fri, 16 Jan 2026 14:23:15 -0800 Subject: [PATCH 5/9] Add testcases for HttpClientService.CreateClientWithAccessToken --- .../src/Services/Http/HttpClientService.cs | 2 +- .../Discovery/RegistryServerProviderTests.cs | 2 +- .../Services/Http/HttpClientServiceTests.cs | 93 +++++++++++++++++++ 3 files changed, 95 insertions(+), 2 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs index dc7675be14..879f175c01 100644 --- a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs @@ -69,7 +69,7 @@ public HttpClient CreateClient(Uri? baseAddress, Action configureCli /// A function to acquire access token. /// The base address for the HttpClient /// A new HttpClient instance. - public HttpClient CreateClientWithAccessToken(Func> accessTokenProvider, Uri? baseAddress) + public HttpClient CreateClientWithAccessToken(Func> accessTokenProvider, Uri? baseAddress = null) { if (_disposed) { diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs index f497aa8322..6e1f221b55 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs @@ -19,7 +19,7 @@ private static RegistryServerProvider CreateServerProvider(string id, RegistrySe { var httpClientService = NSubstitute.Substitute.For(); httpClientService.CreateClientWithAccessToken(Arg.Any>>(), Arg.Any()) - .Returns(new HttpClient()); + .Returns(Substitute.For()); var tokenCredentialProvider = NSubstitute.Substitute.For(); return new RegistryServerProvider(id, serverInfo, httpClientService, tokenCredentialProvider); } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs index d5ed9f40e1..e47a6c6187 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs @@ -153,4 +153,97 @@ public void UserAgent_UserAgentFromHttpClientOptionsIsIgnored() Assert.Contains("azmcp-http/", userAgent.ToString()); } + [Fact] + public void CreateClientWithAccessToken_WithProxyConfiguration_CreatesProxyEnabledClient() + { + // Arrange + var options = new HttpClientOptions + { + AllProxy = "http://proxy.example.com:8080", + NoProxy = "localhost,127.0.0.1" + }; + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + using var service = new HttpClientService(optionsWrapper, null!); + + // Act + async Task fetchAccessToken(CancellationToken cancellationToken) + { + return ""; + } + using var client = service.CreateClientWithAccessToken(fetchAccessToken); + + // Assert + Assert.NotNull(client); + // Note: We can't easily test the proxy configuration without reflection + // or making the handler accessible, but this verifies the client is created + } + + [Fact] + public void CreateClientWithAccessToken_ClientHasCorrectUserAgent() + { + // Arrange + var options = new HttpClientOptions(); + options.DefaultUserAgent = "CustomAgent/1.0"; + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + var serviceStartOptions = new ServiceStartOptions + { + Transport = "http" + }; + var serviceStartOptionsWrapper = Microsoft.Extensions.Options.Options.Create(serviceStartOptions); + var service = new HttpClientService(optionsWrapper, serviceStartOptionsWrapper); + + async Task fetchAccessToken(CancellationToken cancellationToken) + { + return ""; + } + var client = service.CreateClientWithAccessToken(fetchAccessToken); + + // Act + var userAgent = client.DefaultRequestHeaders.UserAgent; + + // Assert + Assert.Contains("azmcp-http/", userAgent.ToString()); + } + + [Fact] + public async Task CreateClientWithAccessToken_ClientFetchesAccessTokenForEachRequest() + { + // Arrange + var options = new HttpClientOptions(); + var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); + var serviceStartOptions = new ServiceStartOptions + { + Transport = "http" + }; + var serviceStartOptionsWrapper = Microsoft.Extensions.Options.Options.Create(serviceStartOptions); + var service = new HttpClientService(optionsWrapper, serviceStartOptionsWrapper); + + using CancellationTokenSource cts = new(); + var upstreamCancellationToken = cts.Token; + cts.Cancel(); + var hasFetchedAccessToken = false; + var isCancelRequested = false; + async Task fetchAccessToken(CancellationToken cancellationToken) + { + hasFetchedAccessToken = true; + isCancelRequested = cancellationToken.IsCancellationRequested; + return ""; + } + var client = service.CreateClientWithAccessToken(fetchAccessToken); + + // Act + try + { + cts.Cancel(); + using var _ = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://127.0.0.1"), upstreamCancellationToken); + } + catch (Exception) + { + // Ignore exceptions as we are only interested in whether the access token was fetched + } + + // Assert + Assert.True(hasFetchedAccessToken); + Assert.True(isCancelRequested); + } } From ad4a3080ce755ee33be73af8e6061e156da35f9d Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Tue, 20 Jan 2026 12:40:17 -0800 Subject: [PATCH 6/9] Remove placeholder registry entry --- .../Azure.Mcp.Core/src/Areas/Server/Resources/registry.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json b/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json index 5804265771..27f63ab727 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json +++ b/core/Azure.Mcp.Core/src/Areas/Server/Resources/registry.json @@ -12,12 +12,6 @@ "title": "Azure Developer CLI", "description": "Azure Developer CLI (azd) includes a suite of tools to help build, modernize, and manage applications on Azure. It simplifies the process of developing cloud applications by providing commands for project initialization, resource provisioning, deployment, and monitoring. Use this tool to streamline your Azure development workflow and manage your cloud resources efficiently.", "installInstructions": "The Azure Developer CLI (azd) is either not installed or requires an update. The minimum required version that works with MCP tools is 1.20.0.\n\nTo install or upgrade, follow the instructions at https://aka.ms/azd/install\n\nAfter installation you may need to restart the Azure MCP server and your IDE." - }, - "arm": { - "url": "https://tbd.com", - "title": "Azure Resource Manager", - "description": "Query Azure Resource Graph for Azure Resource information", - "oauthScopes": [ "api://tbd_app_id/.default" ] } } } From b07ec054f033d76dc0752310506893be0ae414d1 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Thu, 22 Jan 2026 14:13:04 -0800 Subject: [PATCH 7/9] Use IHttpClientFactory to create custom HttpClient objects --- .../Discovery/RegistryDiscoveryStrategy.cs | 7 +- .../Discovery/RegistryServerProvider.cs | 16 +--- .../src/Helpers/RegistryServerHelper.cs | 39 ++++++++ .../TenantServiceCollectionExtensions.cs | 63 +++++++++++++ .../Http/HttpClientFactoryConfigurator.cs | 2 - .../src/Services/Http/HttpClientService.cs | 58 ------------ .../src/Services/Http/IHttpClientService.cs | 8 -- .../RegistryDiscoveryStrategyTests.cs | 5 +- .../Discovery/RegistryServerProviderTests.cs | 9 +- .../ToolLoading/ServerToolLoaderTests.cs | 5 +- .../ToolLoading/SingleProxyToolLoaderTests.cs | 5 +- .../Services/Http/HttpClientServiceTests.cs | 94 ------------------- 12 files changed, 119 insertions(+), 192 deletions(-) create mode 100644 core/Azure.Mcp.Core/src/Helpers/RegistryServerHelper.cs diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs index 36828f64fa..c6057e1de7 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs @@ -5,7 +5,6 @@ using Azure.Mcp.Core.Areas.Server.Models; using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Services.Azure.Authentication; -using Azure.Mcp.Core.Services.Http; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -17,10 +16,10 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; /// /// Options for configuring the service behavior. /// Logger instance for this discovery strategy. -public sealed class RegistryDiscoveryStrategy(IOptions options, ILogger logger, IHttpClientService httpClientService, IAzureTokenCredentialProvider tokenCredentialProvider) : BaseDiscoveryStrategy(logger) +public sealed class RegistryDiscoveryStrategy(IOptions options, ILogger logger, IHttpClientFactory httpClientFactory, IAzureTokenCredentialProvider tokenCredentialProvider) : BaseDiscoveryStrategy(logger) { private readonly IOptions _options = options; - private readonly IHttpClientService _httpClientService = httpClientService; + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; private readonly IAzureTokenCredentialProvider _tokenCredentialProvider = tokenCredentialProvider; /// @@ -37,7 +36,7 @@ public override async Task> DiscoverServersAsync .Where(s => _options.Value.Namespace == null || _options.Value.Namespace.Length == 0 || _options.Value.Namespace.Contains(s.Key, StringComparer.OrdinalIgnoreCase)) - .Select(s => new RegistryServerProvider(s.Key, s.Value, _httpClientService, _tokenCredentialProvider)) + .Select(s => new RegistryServerProvider(s.Key, s.Value, _httpClientFactory, _tokenCredentialProvider)) .Cast(); } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs index dd0bc6ccc7..c4f4aa0ed5 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs @@ -2,8 +2,8 @@ // Licensed under the MIT License. using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Services.Azure.Authentication; -using Azure.Mcp.Core.Services.Http; using ModelContextProtocol.Client; namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; @@ -16,11 +16,11 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; /// Configuration information for the server. /// The HTTP client service for creating HTTP clients. /// The token credential provider for OAuth authentication. -public sealed class RegistryServerProvider(string id, RegistryServerInfo serverInfo, IHttpClientService httpClientService, IAzureTokenCredentialProvider tokenCredentialProvider) : IMcpServerProvider +public sealed class RegistryServerProvider(string id, RegistryServerInfo serverInfo, IHttpClientFactory httpClientFactory, IAzureTokenCredentialProvider tokenCredentialProvider) : IMcpServerProvider { private readonly string _id = id; private readonly RegistryServerInfo _serverInfo = serverInfo; - private readonly IHttpClientService _httpClientService = httpClientService; + private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; private readonly IAzureTokenCredentialProvider _tokenCredentialProvider = tokenCredentialProvider; /// @@ -101,15 +101,7 @@ private async Task CreateHttpClientAsync(McpClientOptions clientOptio HttpClientTransport clientTransport; if (_serverInfo.OAuthScopes is not null) { - var fetchAccessToken = async (CancellationToken cancellationToken) => - { - var credential = await _tokenCredentialProvider.GetTokenCredentialAsync(tenantId: null, cancellationToken); - var tokenContext = new Azure.Core.TokenRequestContext(_serverInfo.OAuthScopes); - var token = await credential.GetTokenAsync(tokenContext, cancellationToken); - return token.Token; - }; - - var client = _httpClientService.CreateClientWithAccessToken(fetchAccessToken); + var client = _httpClientFactory.CreateClient(RegistryServerHelper.GetRegistryServerHttpClientName(_serverInfo.Name!)); clientTransport = new HttpClientTransport(transportOptions, client, ownsHttpClient: true); } else diff --git a/core/Azure.Mcp.Core/src/Helpers/RegistryServerHelper.cs b/core/Azure.Mcp.Core/src/Helpers/RegistryServerHelper.cs new file mode 100644 index 0000000000..533b06e78d --- /dev/null +++ b/core/Azure.Mcp.Core/src/Helpers/RegistryServerHelper.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net.Http.Headers; +using Azure.Mcp.Core.Areas.Server.Commands.Discovery; + +namespace Azure.Mcp.Core.Helpers +{ + /// + /// DelegatingHandler that adds a Bearer access token to each outgoing request. + /// + public sealed class AccessTokenHandler : DelegatingHandler + { + private readonly Func> _accessTokenProvider; + + public AccessTokenHandler(Func> accessTokenProvider) + { + _accessTokenProvider = accessTokenProvider ?? throw new ArgumentNullException(nameof(accessTokenProvider)); + } + + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var token = await _accessTokenProvider(cancellationToken); + if (!string.IsNullOrEmpty(token)) + { + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + } + return await base.SendAsync(request, cancellationToken); + } + } + + public sealed class RegistryServerHelper + { + public static string GetRegistryServerHttpClientName(string serverName) + { + return $"azmcp.{nameof(RegistryServerProvider)}.{serverName}"; + } + } +} diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs index 6a14cb9eb4..66b834680e 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs @@ -1,6 +1,10 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using System.Reflection; +using Azure.Core; +using Azure.Mcp.Core.Areas.Server; +using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Services.Azure.Authentication; using Azure.Mcp.Core.Services.Http; using Microsoft.Extensions.DependencyInjection; @@ -56,7 +60,66 @@ public static IServiceCollection AddAzureTenantService(this IServiceCollection s services.ConfigureDefaultHttpClient(); } + services.AddHttpClientForMcpRegistry(); + services.TryAddSingleton(); return services; } + + /// + /// Add an HttpClient that fetches access token using expected OauthScope for each extenral MCP server registry that needs it. + /// + private static IServiceCollection AddHttpClientForMcpRegistry(this IServiceCollection services) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = assembly + .GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith("registry.json", StringComparison.OrdinalIgnoreCase)); + if (resourceName is null) + { + return services; + } + + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + return services; + } + var registry = JsonSerializer.Deserialize(stream, ServerJsonContext.Default.RegistryRoot); + if (registry?.Servers is null) + { + return services; + } + + foreach (var kvp in registry.Servers) + { + if (kvp.Value is null || string.IsNullOrWhiteSpace(kvp.Value.Url) || kvp.Value.OAuthScopes is null) + { + continue; + } + var serverName = kvp.Key; + var serverUrl = kvp.Value.Url; + var oauthScopes = kvp.Value.OAuthScopes; + if (oauthScopes.Length == 0) + { + continue; + } + + services.AddHttpClient(RegistryServerHelper.GetRegistryServerHttpClientName(serverName)) + .AddHttpMessageHandler((services) => + { + var fetchAccessToken = async (CancellationToken cancellationToken) => + { + var tokenCredentialProvider = services.GetRequiredService(); + var credential = await tokenCredentialProvider.GetTokenCredentialAsync(tenantId: null, cancellationToken); + var tokenContext = new TokenRequestContext(oauthScopes); + var token = await credential.GetTokenAsync(tokenContext, cancellationToken); + return token.Token; + }; + return new AccessTokenHandler(fetchAccessToken); + }); + } + + return services; + } } diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs index 84c4ca4132..611cb5a6a9 100644 --- a/core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientFactoryConfigurator.cs @@ -1,8 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System; -using System.Linq; using System.Net; using System.Reflection; using System.Runtime.InteropServices; diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs index 879f175c01..1430127e86 100644 --- a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs @@ -63,41 +63,6 @@ public HttpClient CreateClient(Uri? baseAddress, Action configureCli return client; } - /// - /// Creates a new HttpClient instance with a customized function to acquire an access token for its outgoing requests. - /// - /// A function to acquire access token. - /// The base address for the HttpClient - /// A new HttpClient instance. - public HttpClient CreateClientWithAccessToken(Func> accessTokenProvider, Uri? baseAddress = null) - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(HttpClientService)); - } - - ArgumentNullException.ThrowIfNull(accessTokenProvider); - - var handler = CreateHttpClientHandler(); - - var accessTokenHandler = new AccessTokenHandler(accessTokenProvider) - { - InnerHandler = handler - }; - - var client = new HttpClient(accessTokenHandler, disposeHandler: true); - - client.Timeout = _options.DefaultTimeout; - client.DefaultRequestHeaders.UserAgent.ParseAdd(UserAgent); - - if (baseAddress != null) - { - client.BaseAddress = baseAddress; - } - - return client; - } - private HttpClient CreateClientInternal() { var handler = CreateHttpClientHandler(); @@ -227,27 +192,4 @@ public void Dispose() _disposed = true; } } - - /// - /// DelegatingHandler that adds a Bearer access token to each outgoing request. - /// - private sealed class AccessTokenHandler : DelegatingHandler - { - private readonly Func> _accessTokenProvider; - - public AccessTokenHandler(Func> accessTokenProvider) - { - _accessTokenProvider = accessTokenProvider ?? throw new ArgumentNullException(nameof(accessTokenProvider)); - } - - protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - var token = await _accessTokenProvider(cancellationToken); - if (!string.IsNullOrEmpty(token)) - { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); - } - return await base.SendAsync(request, cancellationToken); - } - } } diff --git a/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs b/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs index 3fcac965ab..5c4d478f36 100644 --- a/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs +++ b/core/Azure.Mcp.Core/src/Services/Http/IHttpClientService.cs @@ -27,12 +27,4 @@ public interface IHttpClientService /// Additional configuration for the HttpClient. /// A new HttpClient instance. HttpClient CreateClient(Uri? baseAddress, Action configureClient); - - /// - /// Creates a new HttpClient instance with a customized function to acquire an access token for its outgoing requests. - /// - /// A function to acquire access token. - /// The base address for the HttpClient - /// A new HttpClient instance. - HttpClient CreateClientWithAccessToken(Func> accessTokenProvider, Uri? baseAddress = null); } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs index e1e35a215a..db6db52d89 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs @@ -4,7 +4,6 @@ using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Services.Azure.Authentication; -using Azure.Mcp.Core.Services.Http; using Xunit; namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; @@ -15,9 +14,9 @@ private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions? opt { var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); var logger = NSubstitute.Substitute.For>(); - var httpClientService = NSubstitute.Substitute.For(); + var httpClientFactory = NSubstitute.Substitute.For(); var tokenCredentialProvider = NSubstitute.Substitute.For(); - return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientService, tokenCredentialProvider); + return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientFactory, tokenCredentialProvider); } [Fact] diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs index 6e1f221b55..dc425d9a64 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs @@ -6,7 +6,6 @@ using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Models; using Azure.Mcp.Core.Services.Azure.Authentication; -using Azure.Mcp.Core.Services.Http; using ModelContextProtocol.Client; using NSubstitute; using Xunit; @@ -17,11 +16,11 @@ public class RegistryServerProviderTests { private static RegistryServerProvider CreateServerProvider(string id, RegistryServerInfo serverInfo) { - var httpClientService = NSubstitute.Substitute.For(); - httpClientService.CreateClientWithAccessToken(Arg.Any>>(), Arg.Any()) + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()) .Returns(Substitute.For()); - var tokenCredentialProvider = NSubstitute.Substitute.For(); - return new RegistryServerProvider(id, serverInfo, httpClientService, tokenCredentialProvider); + var tokenCredentialProvider = Substitute.For(); + return new RegistryServerProvider(id, serverInfo, httpClientFactory, tokenCredentialProvider); } [Fact] public void Constructor_InitializesCorrectly() diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs index 3d12a42172..64a2b2657a 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs @@ -6,7 +6,6 @@ using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Services.Azure.Authentication; -using Azure.Mcp.Core.Services.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -54,9 +53,9 @@ private static ModelContextProtocol.Server.RequestContext private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions options, ILogger logger) { var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); - var httpClientService = Substitute.For(); + var httpClientFactory = Substitute.For(); var tokenCredentialProvider = Substitute.For(); - return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientService, tokenCredentialProvider); + return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientFactory, tokenCredentialProvider); } [Fact] diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs index bd0f810f7f..7466caa78d 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs @@ -6,7 +6,6 @@ using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; using Azure.Mcp.Core.Areas.Server.Options; using Azure.Mcp.Core.Services.Azure.Authentication; -using Azure.Mcp.Core.Services.Http; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -20,9 +19,9 @@ public class SingleProxyToolLoaderTests private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions options, ILogger logger) { var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); - var httpClientService = Substitute.For(); + var httpClientFactory = Substitute.For(); var tokenCredentialProvider = Substitute.For(); - return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientService, tokenCredentialProvider); + return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientFactory, tokenCredentialProvider); } private static (SingleProxyToolLoader toolLoader, IMcpDiscoveryStrategy discoveryStrategy) CreateToolLoader(bool useRealDiscovery = true) diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs index e47a6c6187..e25de72ca2 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Http/HttpClientServiceTests.cs @@ -152,98 +152,4 @@ public void UserAgent_UserAgentFromHttpClientOptionsIsIgnored() // Assert Assert.Contains("azmcp-http/", userAgent.ToString()); } - - [Fact] - public void CreateClientWithAccessToken_WithProxyConfiguration_CreatesProxyEnabledClient() - { - // Arrange - var options = new HttpClientOptions - { - AllProxy = "http://proxy.example.com:8080", - NoProxy = "localhost,127.0.0.1" - }; - var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); - using var service = new HttpClientService(optionsWrapper, null!); - - // Act - async Task fetchAccessToken(CancellationToken cancellationToken) - { - return ""; - } - using var client = service.CreateClientWithAccessToken(fetchAccessToken); - - // Assert - Assert.NotNull(client); - // Note: We can't easily test the proxy configuration without reflection - // or making the handler accessible, but this verifies the client is created - } - - [Fact] - public void CreateClientWithAccessToken_ClientHasCorrectUserAgent() - { - // Arrange - var options = new HttpClientOptions(); - options.DefaultUserAgent = "CustomAgent/1.0"; - var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); - var serviceStartOptions = new ServiceStartOptions - { - Transport = "http" - }; - var serviceStartOptionsWrapper = Microsoft.Extensions.Options.Options.Create(serviceStartOptions); - var service = new HttpClientService(optionsWrapper, serviceStartOptionsWrapper); - - async Task fetchAccessToken(CancellationToken cancellationToken) - { - return ""; - } - var client = service.CreateClientWithAccessToken(fetchAccessToken); - - // Act - var userAgent = client.DefaultRequestHeaders.UserAgent; - - // Assert - Assert.Contains("azmcp-http/", userAgent.ToString()); - } - - [Fact] - public async Task CreateClientWithAccessToken_ClientFetchesAccessTokenForEachRequest() - { - // Arrange - var options = new HttpClientOptions(); - var optionsWrapper = Microsoft.Extensions.Options.Options.Create(options); - var serviceStartOptions = new ServiceStartOptions - { - Transport = "http" - }; - var serviceStartOptionsWrapper = Microsoft.Extensions.Options.Options.Create(serviceStartOptions); - var service = new HttpClientService(optionsWrapper, serviceStartOptionsWrapper); - - using CancellationTokenSource cts = new(); - var upstreamCancellationToken = cts.Token; - cts.Cancel(); - var hasFetchedAccessToken = false; - var isCancelRequested = false; - async Task fetchAccessToken(CancellationToken cancellationToken) - { - hasFetchedAccessToken = true; - isCancelRequested = cancellationToken.IsCancellationRequested; - return ""; - } - var client = service.CreateClientWithAccessToken(fetchAccessToken); - - // Act - try - { - cts.Cancel(); - using var _ = await client.SendAsync(new HttpRequestMessage(HttpMethod.Get, "http://127.0.0.1"), upstreamCancellationToken); - } - catch (Exception) - { - // Ignore exceptions as we are only interested in whether the access token was fetched - } - - // Assert - Assert.True(hasFetchedAccessToken); - Assert.True(isCancelRequested); - } } From dcdfe82dfd338c18440ee8a809af408f25260525 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Thu, 22 Jan 2026 14:31:57 -0800 Subject: [PATCH 8/9] Fix a few comments --- .../Areas/Server/Commands/Discovery/RegistryServerProvider.cs | 3 ++- .../Services/Azure/Tenant/TenantServiceCollectionExtensions.cs | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs index c4f4aa0ed5..d0515dfa03 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs @@ -14,7 +14,7 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; /// /// The unique identifier for the server. /// Configuration information for the server. -/// The HTTP client service for creating HTTP clients. +/// Factory for creating HTTP clients. /// The token credential provider for OAuth authentication. public sealed class RegistryServerProvider(string id, RegistryServerInfo serverInfo, IHttpClientFactory httpClientFactory, IAzureTokenCredentialProvider tokenCredentialProvider) : IMcpServerProvider { @@ -101,6 +101,7 @@ private async Task CreateHttpClientAsync(McpClientOptions clientOptio HttpClientTransport clientTransport; if (_serverInfo.OAuthScopes is not null) { + // Registry servers with OAuthScopes must create HttpClient with this key to create an HttpClient that knows how to fetch its access tokens. var client = _httpClientFactory.CreateClient(RegistryServerHelper.GetRegistryServerHttpClientName(_serverInfo.Name!)); clientTransport = new HttpClientTransport(transportOptions, client, ownsHttpClient: true); } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs index 66b834680e..c2254ba45a 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs @@ -67,7 +67,7 @@ public static IServiceCollection AddAzureTenantService(this IServiceCollection s } /// - /// Add an HttpClient that fetches access token using expected OauthScope for each extenral MCP server registry that needs it. + /// Add HttpClient for each registry server with OAuthScopes that knows how to fetch its access token. /// private static IServiceCollection AddHttpClientForMcpRegistry(this IServiceCollection services) { From b4f5c44758257500adf34fe3f2bdc96efca99311 Mon Sep 17 00:00:00 2001 From: Chunan Ye Date: Fri, 23 Jan 2026 17:07:00 -0800 Subject: [PATCH 9/9] Refactor code for loading server registry Use DI to read server registry once and load at multiple places Style and documentation fixes per PR feedback --- .../Discovery/RegistryDiscoveryStrategy.cs | 43 ++----------- .../Discovery/RegistryServerProvider.cs | 5 +- .../src/Areas/Server/Models/IRegistryRoot.cs | 16 +++++ .../src/Areas/Server/Models/RegistryRoot.cs | 2 +- .../Areas/Server/Models/RegistryServerInfo.cs | 8 +-- ...gistryServerServiceCollectionExtensions.cs | 60 ++++++++++++++++++ .../src/Helpers/RegistryServerHelper.cs | 46 ++++++++++++-- .../TenantServiceCollectionExtensions.cs | 63 ------------------- .../src/Services/Http/HttpClientService.cs | 1 - .../RegistryDiscoveryStrategyTests.cs | 6 +- .../Discovery/RegistryServerProviderTests.cs | 4 +- .../ServiceCollectionExtensionsTests.cs | 3 +- .../ToolLoading/ServerToolLoaderTests.cs | 6 +- .../ToolLoading/SingleProxyToolLoaderTests.cs | 6 +- servers/Azure.Mcp.Server/src/Program.cs | 3 + 15 files changed, 142 insertions(+), 130 deletions(-) create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/Models/IRegistryRoot.cs create mode 100644 core/Azure.Mcp.Core/src/Areas/Server/RegistryServerServiceCollectionExtensions.cs diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs index c6057e1de7..0591da3a55 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs @@ -1,10 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Reflection; using Azure.Mcp.Core.Areas.Server.Models; using Azure.Mcp.Core.Areas.Server.Options; -using Azure.Mcp.Core.Services.Azure.Authentication; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -16,16 +14,16 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; /// /// Options for configuring the service behavior. /// Logger instance for this discovery strategy. -public sealed class RegistryDiscoveryStrategy(IOptions options, ILogger logger, IHttpClientFactory httpClientFactory, IAzureTokenCredentialProvider tokenCredentialProvider) : BaseDiscoveryStrategy(logger) +/// Factory that can create HttpClient objects. +/// Manifest of all the MCP server registries. +public sealed class RegistryDiscoveryStrategy(IOptions options, ILogger logger, IHttpClientFactory httpClientFactory, IRegistryRoot registryRoot) : BaseDiscoveryStrategy(logger) { private readonly IOptions _options = options; private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; - private readonly IAzureTokenCredentialProvider _tokenCredentialProvider = tokenCredentialProvider; /// public override async Task> DiscoverServersAsync(CancellationToken cancellationToken) { - var registryRoot = await LoadRegistryAsync(); if (registryRoot == null) { return []; @@ -36,40 +34,7 @@ public override async Task> DiscoverServersAsync .Where(s => _options.Value.Namespace == null || _options.Value.Namespace.Length == 0 || _options.Value.Namespace.Contains(s.Key, StringComparer.OrdinalIgnoreCase)) - .Select(s => new RegistryServerProvider(s.Key, s.Value, _httpClientFactory, _tokenCredentialProvider)) + .Select(s => new RegistryServerProvider(s.Key, s.Value, _httpClientFactory)) .Cast(); } - - /// - /// Loads the registry configuration from the embedded resource file. - /// - /// The deserialized registry root containing server configurations, or null if not found. - private async Task LoadRegistryAsync() - { - var assembly = Assembly.GetExecutingAssembly(); - var resourceName = assembly - .GetManifestResourceNames() - .FirstOrDefault(n => n.EndsWith("registry.json", StringComparison.OrdinalIgnoreCase)); - - if (resourceName is null) - { - return null; - } - - await using var stream = assembly.GetManifestResourceStream(resourceName)!; - var registry = await JsonSerializer.DeserializeAsync(stream, ServerJsonContext.Default.RegistryRoot); - - if (registry?.Servers != null) - { - foreach (var kvp in registry.Servers) - { - if (kvp.Value != null) - { - kvp.Value.Name = kvp.Key; - } - } - } - - return registry; - } } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs index d0515dfa03..4047c34c28 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs @@ -3,7 +3,6 @@ using Azure.Mcp.Core.Areas.Server.Models; using Azure.Mcp.Core.Helpers; -using Azure.Mcp.Core.Services.Azure.Authentication; using ModelContextProtocol.Client; namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; @@ -16,12 +15,11 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery; /// Configuration information for the server. /// Factory for creating HTTP clients. /// The token credential provider for OAuth authentication. -public sealed class RegistryServerProvider(string id, RegistryServerInfo serverInfo, IHttpClientFactory httpClientFactory, IAzureTokenCredentialProvider tokenCredentialProvider) : IMcpServerProvider +public sealed class RegistryServerProvider(string id, RegistryServerInfo serverInfo, IHttpClientFactory httpClientFactory) : IMcpServerProvider { private readonly string _id = id; private readonly RegistryServerInfo _serverInfo = serverInfo; private readonly IHttpClientFactory _httpClientFactory = httpClientFactory; - private readonly IAzureTokenCredentialProvider _tokenCredentialProvider = tokenCredentialProvider; /// /// Creates metadata that describes this registry-based server. @@ -102,6 +100,7 @@ private async Task CreateHttpClientAsync(McpClientOptions clientOptio if (_serverInfo.OAuthScopes is not null) { // Registry servers with OAuthScopes must create HttpClient with this key to create an HttpClient that knows how to fetch its access tokens. + // The HttpClients are registered in RegistryServerServiceCollectionExtensions.cs. var client = _httpClientFactory.CreateClient(RegistryServerHelper.GetRegistryServerHttpClientName(_serverInfo.Name!)); clientTransport = new HttpClientTransport(transportOptions, client, ownsHttpClient: true); } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/IRegistryRoot.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/IRegistryRoot.cs new file mode 100644 index 0000000000..8c633d8516 --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/IRegistryRoot.cs @@ -0,0 +1,16 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +namespace Azure.Mcp.Core.Areas.Server.Models; + +/// +/// Represents the root structure of the MCP server registry JSON file. +/// Contains a collection of server configurations keyed by server name. +/// +public interface IRegistryRoot +{ + /// + /// Gets the dictionary of server configurations, keyed by server name. + /// + public Dictionary? Servers { get; init; } +} diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryRoot.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryRoot.cs index 9116486065..65395109ed 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryRoot.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryRoot.cs @@ -9,7 +9,7 @@ namespace Azure.Mcp.Core.Areas.Server.Models; /// Represents the root structure of the MCP server registry JSON file. /// Contains a collection of server configurations keyed by server name. /// -public sealed class RegistryRoot +public sealed class RegistryRoot : IRegistryRoot { /// /// Gets the dictionary of server configurations, keyed by server name. diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs index 1076c62c7e..6a2375c84e 100644 --- a/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs +++ b/core/Azure.Mcp.Core/src/Areas/Server/Models/RegistryServerInfo.cs @@ -19,14 +19,14 @@ public sealed class RegistryServerInfo public string? Name { get; set; } /// - /// URL of the remote server. - /// This should be undefined if the transport type is "stdio". + /// Gets the URL of the remote server. + /// This should be if is "stdio". /// [JsonPropertyName("url")] public string? Url { get; init; } /// - /// OAuth scopes to request in the access token. + /// Gets OAuth scopes to request in the access token. /// Used for remote MCP servers protected by OAuth. /// [JsonPropertyName("oauthScopes")] @@ -46,7 +46,7 @@ public sealed class RegistryServerInfo /// /// Gets the transport type, e.g., "stdio". - /// This should be undefined if url is defined. + /// This should be if is non-. /// [JsonPropertyName("type")] public string? Type { get; init; } diff --git a/core/Azure.Mcp.Core/src/Areas/Server/RegistryServerServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Areas/Server/RegistryServerServiceCollectionExtensions.cs new file mode 100644 index 0000000000..2a3ab39d7f --- /dev/null +++ b/core/Azure.Mcp.Core/src/Areas/Server/RegistryServerServiceCollectionExtensions.cs @@ -0,0 +1,60 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Helpers; +using Azure.Mcp.Core.Services.Azure.Authentication; +using Microsoft.Extensions.DependencyInjection; + +namespace Azure.Mcp.Core.Areas.Server; + +/// +/// Extension methods for configuring RegistryServer services. +/// +public static class RegistryServerServiceCollectionExtensions +{ + /// + /// Add HttpClient for each registry server with OAuthScopes that knows how to fetch its access token. + /// + public static IServiceCollection AddRegistryRoot(this IServiceCollection services) + { + var registry = RegistryServerHelper.GetRegistryRoot(); + if (registry?.Servers is null) + { + return services; + } + + foreach (var kvp in registry.Servers) + { + if (kvp.Value is not null) + { + // Set the name of the server for easier access + kvp.Value.Name = kvp.Key; + } + + if (kvp.Value is null || string.IsNullOrWhiteSpace(kvp.Value.Url) || kvp.Value.OAuthScopes is null) + { + continue; + } + + var serverName = kvp.Key; + var serverUrl = kvp.Value.Url; + var oauthScopes = kvp.Value.OAuthScopes; + if (oauthScopes.Length == 0) + { + continue; + } + + services.AddHttpClient(RegistryServerHelper.GetRegistryServerHttpClientName(serverName)) + .AddHttpMessageHandler((services) => + { + var tokenCredentialProvider = services.GetRequiredService(); + return new AccessTokenHandler(tokenCredentialProvider, oauthScopes); + }); + } + + services.AddSingleton(registry); + + return services; + } +} diff --git a/core/Azure.Mcp.Core/src/Helpers/RegistryServerHelper.cs b/core/Azure.Mcp.Core/src/Helpers/RegistryServerHelper.cs index 533b06e78d..5d8c477881 100644 --- a/core/Azure.Mcp.Core/src/Helpers/RegistryServerHelper.cs +++ b/core/Azure.Mcp.Core/src/Helpers/RegistryServerHelper.cs @@ -2,7 +2,12 @@ // Licensed under the MIT License. using System.Net.Http.Headers; +using System.Reflection; +using Azure.Core; +using Azure.Mcp.Core.Areas.Server; using Azure.Mcp.Core.Areas.Server.Commands.Discovery; +using Azure.Mcp.Core.Areas.Server.Models; +using Azure.Mcp.Core.Services.Azure.Authentication; namespace Azure.Mcp.Core.Helpers { @@ -11,19 +16,23 @@ namespace Azure.Mcp.Core.Helpers /// public sealed class AccessTokenHandler : DelegatingHandler { - private readonly Func> _accessTokenProvider; + private readonly IAzureTokenCredentialProvider _tokenCredentialProvider; + private readonly string[] _oauthScopes; - public AccessTokenHandler(Func> accessTokenProvider) + public AccessTokenHandler(IAzureTokenCredentialProvider tokenCredentialProvider, string[] oauthScopes) { - _accessTokenProvider = accessTokenProvider ?? throw new ArgumentNullException(nameof(accessTokenProvider)); + _tokenCredentialProvider = tokenCredentialProvider; + _oauthScopes = oauthScopes; } protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { - var token = await _accessTokenProvider(cancellationToken); - if (!string.IsNullOrEmpty(token)) + var credential = await _tokenCredentialProvider.GetTokenCredentialAsync(tenantId: null, cancellationToken); + var tokenContext = new TokenRequestContext(_oauthScopes); + var token = await credential.GetTokenAsync(tokenContext, cancellationToken); + if (!string.IsNullOrEmpty(token.Token)) { - request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token); + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token.Token); } return await base.SendAsync(request, cancellationToken); } @@ -35,5 +44,30 @@ public static string GetRegistryServerHttpClientName(string serverName) { return $"azmcp.{nameof(RegistryServerProvider)}.{serverName}"; } + + public static IRegistryRoot? GetRegistryRoot() + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = assembly + .GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith("registry.json", StringComparison.OrdinalIgnoreCase)); + if (resourceName is null) + { + return null; + } + + using var stream = assembly.GetManifestResourceStream(resourceName); + if (stream is null) + { + return null; + } + var registry = JsonSerializer.Deserialize(stream, ServerJsonContext.Default.RegistryRoot); + if (registry?.Servers is null) + { + return null; + } + + return registry; + } } } diff --git a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs index c2254ba45a..6a14cb9eb4 100644 --- a/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs +++ b/core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs @@ -1,10 +1,6 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. -using System.Reflection; -using Azure.Core; -using Azure.Mcp.Core.Areas.Server; -using Azure.Mcp.Core.Helpers; using Azure.Mcp.Core.Services.Azure.Authentication; using Azure.Mcp.Core.Services.Http; using Microsoft.Extensions.DependencyInjection; @@ -60,66 +56,7 @@ public static IServiceCollection AddAzureTenantService(this IServiceCollection s services.ConfigureDefaultHttpClient(); } - services.AddHttpClientForMcpRegistry(); - services.TryAddSingleton(); return services; } - - /// - /// Add HttpClient for each registry server with OAuthScopes that knows how to fetch its access token. - /// - private static IServiceCollection AddHttpClientForMcpRegistry(this IServiceCollection services) - { - var assembly = Assembly.GetExecutingAssembly(); - var resourceName = assembly - .GetManifestResourceNames() - .FirstOrDefault(n => n.EndsWith("registry.json", StringComparison.OrdinalIgnoreCase)); - if (resourceName is null) - { - return services; - } - - using var stream = assembly.GetManifestResourceStream(resourceName); - if (stream is null) - { - return services; - } - var registry = JsonSerializer.Deserialize(stream, ServerJsonContext.Default.RegistryRoot); - if (registry?.Servers is null) - { - return services; - } - - foreach (var kvp in registry.Servers) - { - if (kvp.Value is null || string.IsNullOrWhiteSpace(kvp.Value.Url) || kvp.Value.OAuthScopes is null) - { - continue; - } - var serverName = kvp.Key; - var serverUrl = kvp.Value.Url; - var oauthScopes = kvp.Value.OAuthScopes; - if (oauthScopes.Length == 0) - { - continue; - } - - services.AddHttpClient(RegistryServerHelper.GetRegistryServerHttpClientName(serverName)) - .AddHttpMessageHandler((services) => - { - var fetchAccessToken = async (CancellationToken cancellationToken) => - { - var tokenCredentialProvider = services.GetRequiredService(); - var credential = await tokenCredentialProvider.GetTokenCredentialAsync(tenantId: null, cancellationToken); - var tokenContext = new TokenRequestContext(oauthScopes); - var token = await credential.GetTokenAsync(tokenContext, cancellationToken); - return token.Token; - }; - return new AccessTokenHandler(fetchAccessToken); - }); - } - - return services; - } } diff --git a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs index 1430127e86..e2f3184edf 100644 --- a/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs +++ b/core/Azure.Mcp.Core/src/Services/Http/HttpClientService.cs @@ -2,7 +2,6 @@ // Licensed under the MIT License. using System.Net; -using System.Net.Http.Headers; using System.Reflection; using System.Runtime.Versioning; using Azure.Mcp.Core.Areas.Server.Options; diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs index db6db52d89..1a53e94c46 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategyTests.cs @@ -3,7 +3,7 @@ using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Options; -using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Helpers; using Xunit; namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery; @@ -15,8 +15,8 @@ private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions? opt var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); var logger = NSubstitute.Substitute.For>(); var httpClientFactory = NSubstitute.Substitute.For(); - var tokenCredentialProvider = NSubstitute.Substitute.For(); - return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientFactory, tokenCredentialProvider); + var registryRoot = RegistryServerHelper.GetRegistryRoot(); + return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientFactory, registryRoot!); } [Fact] diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs index dc425d9a64..27b68ee830 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/Discovery/RegistryServerProviderTests.cs @@ -5,7 +5,6 @@ using System.Net.Sockets; using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Models; -using Azure.Mcp.Core.Services.Azure.Authentication; using ModelContextProtocol.Client; using NSubstitute; using Xunit; @@ -19,8 +18,7 @@ private static RegistryServerProvider CreateServerProvider(string id, RegistrySe var httpClientFactory = Substitute.For(); httpClientFactory.CreateClient(Arg.Any()) .Returns(Substitute.For()); - var tokenCredentialProvider = Substitute.For(); - return new RegistryServerProvider(id, serverInfo, httpClientFactory, tokenCredentialProvider); + return new RegistryServerProvider(id, serverInfo, httpClientFactory); } [Fact] public void Constructor_InitializesCorrectly() diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs index 02fe5da704..51d8014695 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ServiceCollectionExtensionsTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +using Azure.Mcp.Core.Areas.Server; using Azure.Mcp.Core.Areas.Server.Commands; using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Commands.Runtime; @@ -24,7 +25,7 @@ private IServiceCollection SetupBaseServices() var services = CommandFactoryHelpers.SetupCommonServices(); services.AddSingleton(sp => CommandFactoryHelpers.CreateCommandFactory(sp)); services.AddSingleIdentityTokenCredentialProvider(); - + services.AddRegistryRoot(); return services; } diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs index 64a2b2657a..0e44e41311 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/ServerToolLoaderTests.cs @@ -5,7 +5,7 @@ using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; using Azure.Mcp.Core.Areas.Server.Options; -using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Helpers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -54,8 +54,8 @@ private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions opti { var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); var httpClientFactory = Substitute.For(); - var tokenCredentialProvider = Substitute.For(); - return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientFactory, tokenCredentialProvider); + var registryRoot = RegistryServerHelper.GetRegistryRoot(); + return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientFactory, registryRoot!); } [Fact] diff --git a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs index 7466caa78d..f5281320f6 100644 --- a/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs +++ b/core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Areas/Server/Commands/ToolLoading/SingleProxyToolLoaderTests.cs @@ -5,7 +5,7 @@ using Azure.Mcp.Core.Areas.Server.Commands.Discovery; using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading; using Azure.Mcp.Core.Areas.Server.Options; -using Azure.Mcp.Core.Services.Azure.Authentication; +using Azure.Mcp.Core.Helpers; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; using ModelContextProtocol.Protocol; @@ -20,8 +20,8 @@ private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions opti { var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions()); var httpClientFactory = Substitute.For(); - var tokenCredentialProvider = Substitute.For(); - return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientFactory, tokenCredentialProvider); + var registryRoot = RegistryServerHelper.GetRegistryRoot(); + return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientFactory, registryRoot!); } private static (SingleProxyToolLoader toolLoader, IMcpDiscoveryStrategy discoveryStrategy) CreateToolLoader(bool useRealDiscovery = true) diff --git a/servers/Azure.Mcp.Server/src/Program.cs b/servers/Azure.Mcp.Server/src/Program.cs index 7946cb01da..89f41016c3 100644 --- a/servers/Azure.Mcp.Server/src/Program.cs +++ b/servers/Azure.Mcp.Server/src/Program.cs @@ -2,6 +2,7 @@ // Licensed under the MIT License. using System.Net; +using Azure.Mcp.Core.Areas.Server; using Azure.Mcp.Core.Areas.Server.Commands; using Azure.Mcp.Core.Commands; using Azure.Mcp.Core.Services.Azure.ResourceGroup; @@ -211,6 +212,8 @@ internal static void ConfigureServices(IServiceCollection services) services.AddSingleton(area); area.ConfigureServices(services); } + + services.AddRegistryRoot(); } internal static async Task InitializeServicesAsync(IServiceProvider serviceProvider)