Skip to content

Commit 2c930f6

Browse files
authored
Populate HttpClientFactory, utilize in BaseAzureService (#1239)
* Configure IHttpClientFactory and inject to TenantService in preparation for record/playback for any class inheriting from BaseAzureService.
1 parent 842d6be commit 2c930f6

File tree

13 files changed

+288
-8
lines changed

13 files changed

+288
-8
lines changed

core/Azure.Mcp.Core/src/Services/Azure/BaseAzureService.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Reflection;
55
using System.Runtime.Versioning;
66
using Azure.Core;
7+
using Azure.Core.Pipeline;
78
using Azure.Mcp.Core.Options;
89
using Azure.Mcp.Core.Services.Azure.Tenant;
910
using Azure.ResourceManager;
@@ -217,6 +218,7 @@ protected async Task<ArmClient> CreateArmClientAsync(
217218
{
218219
TokenCredential credential = await GetCredential(tenantId, cancellationToken);
219220
ArmClientOptions options = armClientOptions ?? new();
221+
options.Transport = new HttpClientTransport(TenantService.GetClient());
220222
ConfigureRetryPolicy(AddDefaultPolicies(options), retryPolicy);
221223

222224
ArmClient armClient = new(credential, defaultSubscriptionId: default, options);

core/Azure.Mcp.Core/src/Services/Azure/Tenant/ITenantService.cs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,30 @@ public interface ITenantService
102102
Task<TokenCredential> GetTokenCredentialAsync(
103103
string? tenantId,
104104
CancellationToken cancellationToken);
105+
106+
/// <summary>
107+
/// Gets a new instance of <see cref="HttpClient"/> configured for use with Azure tenant operations.
108+
/// </summary>
109+
/// <remarks>
110+
/// <para>Each instance includes the following configuration:</para>
111+
/// <list type="bullet">
112+
/// <item><description>Proxy settings</description></item>
113+
/// <item><description>Record/playback handler</description></item>
114+
/// <item><description>Timeout configuration</description></item>
115+
/// <item><description>User-Agent header</description></item>
116+
/// </list>
117+
/// <para>Do:</para>
118+
/// <list type="bullet">
119+
/// <item><description>Utilize the client for a single method or MCP tool invocation.</description></item>
120+
/// <item><description>Add request-specific configuration that is scoped to the current operation.</description></item>
121+
/// </list>
122+
/// <para>Don't:</para>
123+
/// <list type="bullet">
124+
/// <item><description>Persist the client beyond the lifetime of the invoking tool.</description></item>
125+
/// </list>
126+
/// </remarks>
127+
/// <returns>
128+
/// An <see cref="HttpClient"/> instance configured for use with Azure tenant operations.
129+
/// </returns>
130+
HttpClient GetClient();
105131
}

core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantService.cs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,16 +13,19 @@ public class TenantService : BaseAzureService, ITenantService
1313
{
1414
private readonly IAzureTokenCredentialProvider _credentialProvider;
1515
private readonly ICacheService _cacheService;
16+
private readonly IHttpClientFactory _httpClientFactory;
1617
private const string CacheGroup = "tenant";
1718
private const string CacheKey = "tenants";
1819
private static readonly TimeSpan s_cacheDuration = TimeSpan.FromHours(12);
1920

2021
public TenantService(
2122
IAzureTokenCredentialProvider credentialProvider,
22-
ICacheService cacheService)
23+
ICacheService cacheService,
24+
IHttpClientFactory clientFactory)
2325
{
2426
_credentialProvider = credentialProvider;
2527
_cacheService = cacheService ?? throw new ArgumentNullException(nameof(cacheService));
28+
_httpClientFactory = clientFactory ?? throw new ArgumentNullException(nameof(clientFactory));
2629
TenantService = this;
2730
}
2831

@@ -102,4 +105,10 @@ public async Task<TokenCredential> GetTokenCredentialAsync(string? tenantId, Can
102105
{
103106
return await _credentialProvider.GetTokenCredentialAsync(tenantId, cancellationToken);
104107
}
108+
109+
/// <inheritdoc/>
110+
public HttpClient GetClient()
111+
{
112+
return _httpClientFactory.CreateClient();
113+
}
105114
}

core/Azure.Mcp.Core/src/Services/Azure/Tenant/TenantServiceCollectionExtensions.cs

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// Licensed under the MIT License.
33

44
using Azure.Mcp.Core.Services.Azure.Authentication;
5+
using Azure.Mcp.Core.Services.Http;
56
using Microsoft.Extensions.DependencyInjection;
67
using Microsoft.Extensions.DependencyInjection.Extensions;
78

@@ -36,7 +37,7 @@ public static class TenantServiceCollectionExtensions
3637
/// </item>
3738
/// </list>
3839
/// </remarks>
39-
public static IServiceCollection AddAzureTenantService(this IServiceCollection services)
40+
public static IServiceCollection AddAzureTenantService(this IServiceCollection services, bool addUserAgentClient = false)
4041
{
4142
// !!! HACK !!!
4243
// Program.cs for the CLI servers have their own DI containers vs ServiceStartCommand.
@@ -49,6 +50,12 @@ public static IServiceCollection AddAzureTenantService(this IServiceCollection s
4950
// running as a remote HTTP MCP service.
5051
services.AddSingleIdentityTokenCredentialProvider();
5152

53+
services.AddHttpClient();
54+
if (addUserAgentClient)
55+
{
56+
services.ConfigureDefaultHttpClient();
57+
}
58+
5259
services.TryAddSingleton<ITenantService, TenantService>();
5360
return services;
5461
}
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using System.Linq;
5+
using System.Net;
6+
using System.Net.Http;
7+
using System.Reflection;
8+
using System.Runtime.InteropServices;
9+
using System.Runtime.Versioning;
10+
using Azure.Mcp.Core.Areas.Server.Options;
11+
using Microsoft.Extensions.DependencyInjection;
12+
using Microsoft.Extensions.Http;
13+
using Microsoft.Extensions.Options;
14+
15+
namespace Azure.Mcp.Core.Services.Http;
16+
17+
public static class HttpClientFactoryConfigurator
18+
{
19+
private static readonly string s_version;
20+
private static readonly string s_framework;
21+
private static readonly string s_platform;
22+
23+
private static string? s_userAgent = null;
24+
25+
static HttpClientFactoryConfigurator()
26+
{
27+
var assembly = typeof(HttpClientService).Assembly;
28+
s_version = assembly.GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? "unknown";
29+
s_framework = assembly.GetCustomAttribute<TargetFrameworkAttribute>()?.FrameworkName ?? "unknown";
30+
s_platform = RuntimeInformation.OSDescription;
31+
}
32+
33+
public static IServiceCollection ConfigureDefaultHttpClient(this IServiceCollection services)
34+
{
35+
ArgumentNullException.ThrowIfNull(services);
36+
37+
services.ConfigureHttpClientDefaults(ConfigureHttpClientBuilder);
38+
39+
return services;
40+
}
41+
42+
private static void ConfigureHttpClientBuilder(IHttpClientBuilder builder)
43+
{
44+
builder.ConfigureHttpClient((serviceProvider, client) =>
45+
{
46+
var httpClientOptions = serviceProvider.GetRequiredService<IOptions<HttpClientOptions>>().Value;
47+
client.Timeout = httpClientOptions.DefaultTimeout;
48+
49+
var transport = serviceProvider.GetRequiredService<IOptions<ServiceStartOptions>>().Value.Transport;
50+
client.DefaultRequestHeaders.UserAgent.ParseAdd(BuildUserAgent(transport));
51+
});
52+
53+
builder.ConfigurePrimaryHttpMessageHandler(serviceProvider =>
54+
{
55+
var httpClientOptions = serviceProvider.GetRequiredService<IOptions<HttpClientOptions>>().Value;
56+
return CreateHttpMessageHandler(httpClientOptions);
57+
});
58+
}
59+
60+
private static HttpMessageHandler CreateHttpMessageHandler(HttpClientOptions options)
61+
{
62+
var handler = new HttpClientHandler();
63+
64+
var proxy = CreateProxy(options);
65+
if (proxy != null)
66+
{
67+
handler.Proxy = proxy;
68+
handler.UseProxy = true;
69+
}
70+
71+
#if DEBUG
72+
var testProxyUrl = Environment.GetEnvironmentVariable("TEST_PROXY_URL");
73+
if (!string.IsNullOrWhiteSpace(testProxyUrl) && Uri.TryCreate(testProxyUrl, UriKind.Absolute, out var proxyUri))
74+
{
75+
return new RecordingRedirectHandler(proxyUri)
76+
{
77+
InnerHandler = handler
78+
};
79+
}
80+
#endif
81+
82+
return handler;
83+
}
84+
85+
private static WebProxy? CreateProxy(HttpClientOptions options)
86+
{
87+
string? proxyAddress = options.AllProxy ?? options.HttpsProxy ?? options.HttpProxy;
88+
89+
if (string.IsNullOrEmpty(proxyAddress))
90+
{
91+
return null;
92+
}
93+
94+
if (!Uri.TryCreate(proxyAddress, UriKind.Absolute, out var proxyUri))
95+
{
96+
return null;
97+
}
98+
99+
var proxy = new WebProxy(proxyUri);
100+
101+
if (!string.IsNullOrEmpty(options.NoProxy))
102+
{
103+
var bypassList = options.NoProxy
104+
.Split(',', StringSplitOptions.RemoveEmptyEntries)
105+
.Select(s => s.Trim())
106+
.Where(s => !string.IsNullOrEmpty(s))
107+
.Select(ConvertGlobToRegex)
108+
.ToArray();
109+
110+
if (bypassList.Length > 0)
111+
{
112+
proxy.BypassList = bypassList;
113+
}
114+
}
115+
116+
return proxy;
117+
}
118+
119+
private static string ConvertGlobToRegex(string globPattern)
120+
{
121+
if (string.IsNullOrEmpty(globPattern))
122+
{
123+
return string.Empty;
124+
}
125+
126+
var escaped = globPattern
127+
.Replace("\\", "\\\\")
128+
.Replace(".", "\\.")
129+
.Replace("+", "\\+")
130+
.Replace("$", "\\$")
131+
.Replace("^", "\\^")
132+
.Replace("{", "\\{")
133+
.Replace("}", "\\}")
134+
.Replace("[", "\\[")
135+
.Replace("]", "\\]")
136+
.Replace("(", "\\(")
137+
.Replace(")", "\\)")
138+
.Replace("|", "\\|");
139+
140+
var regex = escaped
141+
.Replace("*", ".*")
142+
.Replace("?", ".");
143+
144+
return $"^{regex}$";
145+
}
146+
147+
private static string BuildUserAgent(string transport)
148+
{
149+
s_userAgent ??= $"azmcp/{s_version} azmcp-{transport}/{s_version} ({s_framework}; {s_platform})";
150+
return s_userAgent;
151+
}
152+
}

core/Azure.Mcp.Core/src/Services/Http/RecordingRedirectHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ namespace Azure.Mcp.Core.Services.Http;
88
/// <summary>
99
/// DelegatingHandler that rewrites outgoing requests to a recording/replace proxy specified by TEST_PROXY_URL.
1010
/// It also sets the x-recording-upstream-base-uri header once per request to preserve the original target.
11-
///
11+
///
1212
/// This handler is intended to be injected as the LAST delegating handler (closest to the transport) so
1313
/// that it rewrites the final outgoing wire request.
1414
/// </summary>

core/Azure.Mcp.Core/tests/Azure.Mcp.Core.UnitTests/Services/Azure/BaseAzureServiceTests.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ public BaseAzureServiceTests()
2828
Arg.Any<string?>(),
2929
Arg.Any<CancellationToken>())
3030
.Returns(Substitute.For<TokenCredential>());
31+
_tenantService.GetClient().Returns(_ => new HttpClient(new HttpClientHandler()));
3132
}
3233

3334
[Fact]
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT License.
3+
4+
using Azure.Mcp.Core.Services.Http;
5+
using Microsoft.Extensions.DependencyInjection;
6+
7+
namespace Azure.Mcp.Tests.Helpers;
8+
9+
/// <summary>
10+
/// Provides a test helper for creating a <see cref="ServiceProvider"/> pre-configured with HTTP client services.
11+
/// This is intended for use in unit and integration tests that require HTTP client dependencies.
12+
/// </summary>
13+
public static class TestHttpClientFactoryProvider
14+
{
15+
/// <summary>
16+
/// Creates a new <see cref="ServiceProvider"/> instance with HTTP client services configured for testing.
17+
/// </summary>
18+
/// <returns>
19+
/// A <see cref="ServiceProvider"/> containing the configured HTTP client services for use in tests.
20+
/// </returns>
21+
public static ServiceProvider Create()
22+
{
23+
var services = new ServiceCollection();
24+
services.AddOptions();
25+
services.AddHttpClient();
26+
return services.BuildServiceProvider();
27+
}
28+
}

servers/Azure.Mcp.Server/src/Program.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System;
45
using System.Net;
56
using Azure.Mcp.Core.Areas;
67
using Azure.Mcp.Core.Commands;
78
using Azure.Mcp.Core.Services.Azure.ResourceGroup;
89
using Azure.Mcp.Core.Services.Azure.Subscription;
910
using Azure.Mcp.Core.Services.Azure.Tenant;
1011
using Azure.Mcp.Core.Services.Caching;
12+
using Azure.Mcp.Core.Services.Http;
1113
using Azure.Mcp.Core.Services.ProcessExecution;
1214
using Azure.Mcp.Core.Services.Telemetry;
1315
using Azure.Mcp.Core.Services.Time;
@@ -197,7 +199,7 @@ internal static void ConfigureServices(IServiceCollection services)
197199
// stdio-transport-specific implementations of ITenantService and ICacheService.
198200
// The http-traport-specific implementations and configurations must be registered
199201
// within ServiceStartCommand.ExecuteAsync().
200-
services.AddAzureTenantService();
202+
services.AddAzureTenantService(addUserAgentClient: true);
201203
services.AddSingleUserCliCacheService();
202204

203205
foreach (var area in Areas)

tools/Azure.Mcp.Tools.AppConfig/tests/Azure.Mcp.Tools.AppConfig.LiveTests/AppConfigCommandTests.cs

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4+
using System.Net.Http;
45
using System.Text.Json;
56
using Azure.Mcp.Core.Services.Azure.Authentication;
67
using Azure.Mcp.Core.Services.Azure.Subscription;
78
using Azure.Mcp.Core.Services.Azure.Tenant;
89
using Azure.Mcp.Core.Services.Caching;
910
using Azure.Mcp.Tests;
1011
using Azure.Mcp.Tests.Client;
12+
using Azure.Mcp.Tests.Helpers;
1113
using Azure.Mcp.Tools.AppConfig.Services;
1214
using Microsoft.Extensions.Caching.Memory;
15+
using Microsoft.Extensions.DependencyInjection;
1316
using Microsoft.Extensions.Logging;
1417
using Microsoft.Extensions.Logging.Abstractions;
1518
using Xunit;
@@ -22,18 +25,31 @@ public class AppConfigCommandTests : CommandTestsBase
2225
private const string SettingsKey = "settings";
2326
private readonly AppConfigService _appConfigService;
2427
private readonly ILogger<AppConfigService> _logger;
28+
private readonly ServiceProvider _httpClientProvider;
2529

2630
public AppConfigCommandTests(ITestOutputHelper output) : base(output)
2731
{
2832
_logger = NullLogger<AppConfigService>.Instance;
2933
var memoryCache = new MemoryCache(Microsoft.Extensions.Options.Options.Create(new MemoryCacheOptions()));
3034
var cacheService = new SingleUserCliCacheService(memoryCache);
3135
var tokenProvider = new SingleIdentityTokenCredentialProvider(NullLoggerFactory.Instance);
32-
var tenantService = new TenantService(tokenProvider, cacheService);
36+
_httpClientProvider = TestHttpClientFactoryProvider.Create();
37+
var httpClientFactory = _httpClientProvider.GetRequiredService<IHttpClientFactory>();
38+
var tenantService = new TenantService(tokenProvider, cacheService, httpClientFactory);
3339
var subscriptionService = new SubscriptionService(cacheService, tenantService);
3440
_appConfigService = new AppConfigService(subscriptionService, tenantService, _logger);
3541
}
3642

43+
protected override void Dispose(bool disposing)
44+
{
45+
if (disposing)
46+
{
47+
_httpClientProvider.Dispose();
48+
}
49+
50+
base.Dispose(disposing);
51+
}
52+
3753
[Fact]
3854
public async Task Should_list_appconfig_accounts()
3955
{

0 commit comments

Comments
 (0)