Skip to content
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// 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 Microsoft.Extensions.Logging;
Expand All @@ -15,14 +14,16 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
/// </summary>
/// <param name="options">Options for configuring the service behavior.</param>
/// <param name="logger">Logger instance for this discovery strategy.</param>
public sealed class RegistryDiscoveryStrategy(IOptions<ServiceStartOptions> options, ILogger<RegistryDiscoveryStrategy> logger) : BaseDiscoveryStrategy(logger)
/// <param name="httpClientFactory">Factory that can create HttpClient objects.</param>
/// <param name="registryRoot">Manifest of all the MCP server registries.</param>
public sealed class RegistryDiscoveryStrategy(IOptions<ServiceStartOptions> options, ILogger<RegistryDiscoveryStrategy> logger, IHttpClientFactory httpClientFactory, IRegistryRoot registryRoot) : BaseDiscoveryStrategy(logger)
{
private readonly IOptions<ServiceStartOptions> _options = options;
private readonly IHttpClientFactory _httpClientFactory = httpClientFactory;

/// <inheritdoc/>
public override async Task<IEnumerable<IMcpServerProvider>> DiscoverServersAsync(CancellationToken cancellationToken)
{
var registryRoot = await LoadRegistryAsync();
if (registryRoot == null)
{
return [];
Expand All @@ -33,40 +34,7 @@ public override async Task<IEnumerable<IMcpServerProvider>> 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, _httpClientFactory))
.Cast<IMcpServerProvider>();
}

/// <summary>
/// Loads the registry configuration from the embedded resource file.
/// </summary>
/// <returns>The deserialized registry root containing server configurations, or null if not found.</returns>
private async Task<RegistryRoot?> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using Azure.Mcp.Core.Areas.Server.Models;
using Azure.Mcp.Core.Helpers;
using ModelContextProtocol.Client;

namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
Expand All @@ -12,10 +13,13 @@ namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
/// </summary>
/// <param name="id">The unique identifier for the server.</param>
/// <param name="serverInfo">Configuration information for the server.</param>
public sealed class RegistryServerProvider(string id, RegistryServerInfo serverInfo) : IMcpServerProvider
/// <param name="httpClientFactory">Factory for creating HTTP clients.</param>
/// <param name="tokenCredentialProvider">The token credential provider for OAuth authentication.</param>
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;

/// <summary>
/// Creates metadata that describes this registry-based server.
Expand All @@ -37,7 +41,6 @@ public async Task<McpClient> CreateClientAsync(McpClientOptions clientOptions, C
{
Func<McpClientOptions, CancellationToken, Task<McpClient>>? clientFactory = null;

// Determine which factory function to use based on configuration
if (!string.IsNullOrWhiteSpace(_serverInfo.Url))
{
clientFactory = CreateHttpClientAsync;
Expand Down Expand Up @@ -88,8 +91,24 @@ private async Task<McpClient> 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.
};
var clientTransport = new HttpClientTransport(transportOptions);

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.
// The HttpClients are registered in RegistryServerServiceCollectionExtensions.cs.
var client = _httpClientFactory.CreateClient(RegistryServerHelper.GetRegistryServerHttpClientName(_serverInfo.Name!));
clientTransport = new HttpClientTransport(transportOptions, client, ownsHttpClient: true);
}
else
{
clientTransport = new HttpClientTransport(transportOptions);
}

return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken);
}

Expand Down
16 changes: 16 additions & 0 deletions core/Azure.Mcp.Core/src/Areas/Server/Models/IRegistryRoot.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

namespace Azure.Mcp.Core.Areas.Server.Models;

/// <summary>
/// Represents the root structure of the MCP server registry JSON file.
/// Contains a collection of server configurations keyed by server name.
/// </summary>
public interface IRegistryRoot
{
/// <summary>
/// Gets the dictionary of server configurations, keyed by server name.
/// </summary>
public Dictionary<string, RegistryServerInfo>? Servers { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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.
/// </summary>
public sealed class RegistryRoot
public sealed class RegistryRoot : IRegistryRoot
{
/// <summary>
/// Gets the dictionary of server configurations, keyed by server name.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,19 @@ public sealed class RegistryServerInfo
public string? Name { get; set; }

/// <summary>
/// Gets the URL endpoint (deprecated - no longer used).
/// Gets the URL of the remote server.
/// This should be <see langword="null"/> if <see cref="Type"/> is "stdio".
/// </summary>
[JsonPropertyName("url")]
public string? Url { get; init; }

/// <summary>
/// Gets OAuth scopes to request in the access token.
/// Used for remote MCP servers protected by OAuth.
/// </summary>
[JsonPropertyName("oauthScopes")]
public string[]? OAuthScopes { get; init; }

/// <summary>
/// Gets a description of the server's purpose or capabilities.
/// </summary>
Expand All @@ -38,6 +46,7 @@ public sealed class RegistryServerInfo

/// <summary>
/// Gets the transport type, e.g., "stdio".
/// This should be <see langword="null"/> if <see cref="Url"/> is non-<see langword="null"/>.
/// </summary>
[JsonPropertyName("type")]
public string? Type { get; init; }
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Extension methods for configuring RegistryServer services.
/// </summary>
public static class RegistryServerServiceCollectionExtensions
{
/// <summary>
/// Add HttpClient for each registry server with OAuthScopes that knows how to fetch its access token.
/// </summary>
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<IAzureTokenCredentialProvider>();
return new AccessTokenHandler(tokenCredentialProvider, oauthScopes);
});
}

services.AddSingleton<IRegistryRoot>(registry);

return services;
}
}
73 changes: 73 additions & 0 deletions core/Azure.Mcp.Core/src/Helpers/RegistryServerHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
// Copyright (c) Microsoft Corporation.
// 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
{
/// <summary>
/// DelegatingHandler that adds a Bearer access token to each outgoing request.
/// </summary>
public sealed class AccessTokenHandler : DelegatingHandler
{
private readonly IAzureTokenCredentialProvider _tokenCredentialProvider;
private readonly string[] _oauthScopes;

public AccessTokenHandler(IAzureTokenCredentialProvider tokenCredentialProvider, string[] oauthScopes)
{
_tokenCredentialProvider = tokenCredentialProvider;
_oauthScopes = oauthScopes;
}

protected override async Task<HttpResponseMessage> SendAsync(HttpRequestMessage request, CancellationToken cancellationToken)
{
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.Token);
}
return await base.SendAsync(request, cancellationToken);
}
}

public sealed class RegistryServerHelper
{
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;
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using Azure.Mcp.Core.Areas.Server.Commands.Discovery;
using Azure.Mcp.Core.Areas.Server.Options;
using Azure.Mcp.Core.Helpers;
using Xunit;

namespace Azure.Mcp.Core.UnitTests.Areas.Server.Commands.Discovery;
Expand All @@ -13,7 +14,9 @@ private static RegistryDiscoveryStrategy CreateStrategy(ServiceStartOptions? opt
{
var serviceOptions = Microsoft.Extensions.Options.Options.Create(options ?? new ServiceStartOptions());
var logger = NSubstitute.Substitute.For<Microsoft.Extensions.Logging.ILogger<RegistryDiscoveryStrategy>>();
return new RegistryDiscoveryStrategy(serviceOptions, logger);
var httpClientFactory = NSubstitute.Substitute.For<IHttpClientFactory>();
var registryRoot = RegistryServerHelper.GetRegistryRoot();
return new RegistryDiscoveryStrategy(serviceOptions, logger, httpClientFactory, registryRoot!);
}

[Fact]
Expand Down
Loading