Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,11 @@ namespace Azure.Mcp.Core.Tests;
public class CommandTests(ITestOutputHelper output, TestProxyFixture testProxyFixture, LiveServerFixture liveServerFixture)
: RecordedCommandTestsBase(output, testProxyFixture, liveServerFixture)
{
public override string[] Tools => [
"group_list",
"subscription_list"
];

[Fact]
public async Task Should_list_groups_by_subscription()
{
Expand All @@ -33,7 +38,7 @@ public async Task Should_list_subscriptions()
{
var result = await CallToolAsync(
"subscription_list",
new Dictionary<string, object?>());
[]);

var subscriptionsArray = result.AssertProperty("subscriptions");
Assert.Equal(JsonValueKind.Array, subscriptionsArray.ValueKind);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,19 +50,19 @@ namespace Microsoft.Mcp.Core.Areas.Server.Commands;
ReadOnly = true)]
public sealed class ServiceStartCommand : BaseCommand<ServiceStartOptions>
{
private static readonly string[] StdioHostBuilderArgs =
private static readonly string[] s_stdioHostBuilderArgs =
[
$"--contentRoot={AppContext.BaseDirectory}",
"--hostBuilder:reloadConfigOnChange=false"
];

private static readonly WebApplicationOptions HttpWebApplicationOptions = new()
private static readonly WebApplicationOptions s_httpWebApplicationOptions = new()
{
ContentRootPath = AppContext.BaseDirectory
};

public static List<IAreaSetup> Areas { get; set; } = [];
public static Action<IServiceCollection> ConfigureServices { get; set; } = _ => { };

public static Func<IServiceProvider, Task> InitializeServicesAsync { get; set; } = _ => Task.CompletedTask;

/// <summary>
Expand Down Expand Up @@ -186,6 +186,23 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,

try
{
// After binding options, see if any tool filters were applied (--namespace or --tool).
// If any were specified, filter Areas based on the IAreaSetup name (case-insensitive).
var hasNamespace = options.Namespace is { Length: > 0 };
var hasTool = options.Tool is { Length: > 0 };
if (hasNamespace || hasTool)
{
// --namespace and --tool are mutually exclusive. Setup the filter based on which was set.
// When --namespace is used, use the namespace as the filter, as that should already be the IAreaSetup name.
// When --tool is used, use the first part of each tool name (split by '_') as the filter, as that represents the area (e.g., 'storage_account_get' -> 'storage').
var areaNames = hasNamespace ?
new HashSet<string>(options.Namespace!, StringComparer.OrdinalIgnoreCase)! :
options.Tool?.Select(t => t.Split('_').First()).ToHashSet(StringComparer.OrdinalIgnoreCase)!;

// Filter the Areas that were setup by Program.cs.
Areas.RemoveAll(area => !areaNames.Contains(area.Name));
}

using var tracerProvider = AddIncomingAndOutgoingHttpSpans(options);

using var host = CreateHost(options);
Expand Down Expand Up @@ -403,7 +420,7 @@ private IHost CreateHost(ServiceStartOptions serverOptions)
/// <returns>An IHost instance configured for STDIO transport.</returns>
private IHost CreateStdioHost(ServiceStartOptions serverOptions)
{
return Host.CreateDefaultBuilder(StdioHostBuilderArgs)
return Host.CreateDefaultBuilder(s_stdioHostBuilderArgs)
.ConfigureLogging(logging =>
{
logging.ClearProviders();
Expand Down Expand Up @@ -453,7 +470,7 @@ private IHost CreateStdioHost(ServiceStartOptions serverOptions)
/// <returns>An IHost instance configured for HTTP transport.</returns>
private IHost CreateHttpHost(ServiceStartOptions serverOptions)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(HttpWebApplicationOptions);
WebApplicationBuilder builder = WebApplication.CreateBuilder(s_httpWebApplicationOptions);

// Read once at host setup time — this env var is process-wide and effectively static,
// so there is no need to re-read it on every incoming request.
Expand Down Expand Up @@ -645,7 +662,7 @@ await JsonSerializer.SerializeAsync(
/// <returns>An IHost instance configured for HTTP transport.</returns>
private IHost CreateIncomingAuthDisabledHttpHost(ServiceStartOptions serverOptions)
{
WebApplicationBuilder builder = WebApplication.CreateBuilder(HttpWebApplicationOptions);
WebApplicationBuilder builder = WebApplication.CreateBuilder(s_httpWebApplicationOptions);

InitializeListingUrls(builder, serverOptions);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,16 @@ public abstract class CommandTestsBase(ITestOutputHelper output, LiveServerFixtu
protected ITestOutputHelper Output { get; } = output;
protected LiveServerFixture LiveServerFixture { get; } = liveServerFixture;

public string[]? CustomArguments;
public TestMode TestMode = TestMode.Live;

/// <summary>
/// Sets custom arguments for the MCP server. Call this before InitializeAsync().
/// Sets the tools that the MCP server will scope to. Call this before InitializeAsync().
/// <para>
/// This reduces the number of tools that the MCP server will load and initialize.
/// </para>
/// </summary>
/// <param name="arguments">Custom arguments to pass to the server (e.g., ["server", "start", "--mode", "single"])</param>
public void SetArguments(params string[] arguments)
{
CustomArguments = arguments;
}
/// <param name="tools">Tools to pass to the server (e.g., "storage_blob_get", "appconfig_kv_get", etc.)</param>
public virtual string[] Tools => [];

public virtual async ValueTask InitializeAsync()
{
Expand Down Expand Up @@ -124,10 +123,21 @@ protected virtual async ValueTask InitializeAsyncInternal(TestProxyFixture? prox
// Use custom arguments if provided, otherwise use standard mode (debug can be enabled via environment variable)
var debugEnvVar = Environment.GetEnvironmentVariable("AZURE_MCP_TEST_DEBUG");
var enableDebug = string.Equals(debugEnvVar, "true", StringComparison.OrdinalIgnoreCase) || Settings.DebugOutput;
List<string> defaultArgs = enableDebug

// Live testing uses '--mode all' as it doesn't require sampling and the way live tests are ran an agent isn't
// used to make the request, so sampling isn't available.
List<string> arguments = enableDebug
? ["server", "start", "--mode", "all", "--debug", "--dangerously-disable-elicitation", "--disable-caching"]
: ["server", "start", "--mode", "all", "--dangerously-disable-elicitation", "--disable-caching"];
var arguments = CustomArguments?.ToList() ?? defaultArgs;

if (Tools != null && Tools.Length > 0)
{
foreach (var tool in Tools)
{
arguments.Add("--tool");
arguments.Add(tool);
}
}

LiveServerFixture.EnvironmentVariables = GetEnvironmentVariables(proxy);
LiveServerFixture.Arguments = arguments;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the MIT License.

using System.Diagnostics;
using System.Net;
using System.Reflection;
using ModelContextProtocol.Client;
using ModelContextProtocol.Protocol;
Expand Down Expand Up @@ -190,9 +191,9 @@ private static IClientTransport CreateStdioTransport(
/// <returns>An available port number.</returns>
private static int GetAvailablePort()
{
using var listener = new System.Net.Sockets.TcpListener(System.Net.IPAddress.Loopback, 0);
using var listener = new System.Net.Sockets.TcpListener(IPAddress.Loopback, 0);
listener.Start();
var port = ((System.Net.IPEndPoint)listener.LocalEndpoint).Port;
var port = ((IPEndPoint)listener.LocalEndpoint).Port;
listener.Stop();
return port;
}
Expand Down Expand Up @@ -261,24 +262,22 @@ public static async Task WaitForServerReadinessAsync(
id = Guid.NewGuid().ToString()
};

var content = new System.Net.Http.StringContent(
var content = new StringContent(
System.Text.Json.JsonSerializer.Serialize(request),
System.Text.Encoding.UTF8,
"application/json");
using var requestMessage = new HttpRequestMessage(HttpMethod.Post, serverUrl)
{
Content = content
};
requestMessage.Headers.Accept.Add(
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("application/json"));
requestMessage.Headers.Accept.Add(
new System.Net.Http.Headers.MediaTypeWithQualityHeaderValue("text/event-stream"));
requestMessage.Headers.Accept.Add(new("application/json"));
requestMessage.Headers.Accept.Add(new("text/event-stream"));
using var resp = await httpClient.SendAsync(requestMessage);

// If authentication is enabled, 401 Unauthorized means server is ready
// If authentication is disabled, we need a success status code
if (resp.IsSuccessStatusCode || (authenticationEnabled &&
(resp.StatusCode == System.Net.HttpStatusCode.Unauthorized || resp.StatusCode == System.Net.HttpStatusCode.Forbidden)))
(resp.StatusCode == HttpStatusCode.Unauthorized || resp.StatusCode == HttpStatusCode.Forbidden)))
{
return;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ public sealed class LiveServerFixture() : IAsyncLifetime
private string? _serverUrl;
private bool _started;

public Dictionary<string, string?> EnvironmentVariables { get; set; } = new();
public Dictionary<string, string?> EnvironmentVariables { get; set; } = [];
public List<string> Arguments { get; set; } = [];
public ITestOutputHelper? Output { get; set; }
public LiveTestSettings? Settings { get; set; }
Expand Down Expand Up @@ -60,11 +60,7 @@ public async Task EnsureStartedAsync()

public McpClient GetMcpClient()
{
if (_mcpClient == null)
{
throw new InvalidOperationException("MCP Client has not been initialized.");
}
return _mcpClient;
return _mcpClient ?? throw new InvalidOperationException("MCP Client has not been initialized.");
}

public async ValueTask DisposeAsync()
Expand Down
29 changes: 14 additions & 15 deletions servers/Azure.Mcp.Server/src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Core.Services.Azure.Tenant;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Options;
Expand All @@ -33,12 +32,11 @@ namespace Azure.Mcp.Server;

internal class Program
{
private static readonly IAreaSetup[] Areas = RegisterAreas();
private static readonly List<IAreaSetup> s_areas = RegisterAreas();

// Derived from the registered ServerSetup instance so the name stays in sync
// with the actual area registration — no magic string duplication.
private static readonly string ServerAreaName =
Array.Find(Areas, static a => a is Microsoft.Mcp.Core.Areas.Server.ServerSetup)?.Name ?? "server";
private static readonly string s_serverAreaName = s_areas.First(static a => a is ServerSetup)?.Name ?? "server";

private static async Task<int> Main(string[] args)
{
Expand All @@ -53,6 +51,7 @@ private static async Task<int> Main(string[] args)
}

// The server start and plugin-telemetry containers always need full area registration.
ServiceStartCommand.Areas = s_areas;
ServiceStartCommand.ConfigureServices = services => ConfigureServices(services);
ServiceStartCommand.InitializeServicesAsync = InitializeServicesAsync;

Expand All @@ -65,7 +64,7 @@ private static async Task<int> Main(string[] args)

ServiceCollection services = new();

ConfigureServices(services, targetAreaName);
ConfigureServices(services, areaSetup => string.Equals(areaSetup.Name, targetAreaName, StringComparison.OrdinalIgnoreCase));

services.AddLogging(builder =>
{
Expand Down Expand Up @@ -169,17 +168,17 @@ private static async Task<int> Main(string[] args)
}
}

private static IAreaSetup[] RegisterAreas()
private static List<IAreaSetup> RegisterAreas()
{

return [
// Register core areas
new Microsoft.Mcp.Core.Areas.Server.ServerSetup(),
new Microsoft.Mcp.Core.Areas.Tools.ToolsSetup(),
new Azure.Mcp.Tools.AzureBestPractices.AzureBestPracticesSetup(),
new Azure.Mcp.Tools.Extension.ExtensionSetup(),
new Azure.Mcp.Core.Areas.Group.GroupSetup(),
new Microsoft.Mcp.Core.Areas.Server.ServerSetup(),
new Azure.Mcp.Core.Areas.Subscription.SubscriptionSetup(),
new Microsoft.Mcp.Core.Areas.Tools.ToolsSetup(),
// Register Azure service areas
new Azure.Mcp.Tools.Aks.AksSetup(),
new Azure.Mcp.Tools.AppConfig.AppConfigSetup(),
Expand Down Expand Up @@ -311,7 +310,7 @@ private static void WriteResponse(CommandResponse response)
/// (those with <see cref="CommandCategory"/> other than <see cref="CommandCategory.AzureServices"/>)
/// have their services registered. Pass <see langword="null"/> for full initialization.
/// </param>
internal static void ConfigureServices(IServiceCollection services, string? areaFilter = null)
internal static void ConfigureServices(IServiceCollection services, Predicate<IAreaSetup>? areaFilter = null)
{
var thisAssembly = typeof(Program).Assembly;

Expand All @@ -334,27 +333,27 @@ internal static void ConfigureServices(IServiceCollection services, string? area
services.AddAzureTenantService();
services.AddSingleUserCliCacheService(disabled: true);

foreach (var area in Areas)
bool configuredServerArea = false;
foreach (var area in s_areas)
{
// When areaFilter is set (CLI path), skip Azure service areas that don't match the target.
// Non-Azure-service areas (Category != AzureServices) provide shared infrastructure
// (command routing, server start, subscription listing, etc.) and must always be registered.
// Any area whose Category is AzureServices and whose name doesn't match the filter is skipped;
// this avoids registering services (HTTP clients, SDKs, etc.) for 60+ irrelevant services.
if (areaFilter != null &&
area.Category == CommandCategory.AzureServices &&
!string.Equals(area.Name, areaFilter, StringComparison.OrdinalIgnoreCase))
if (areaFilter != null && area.Category == CommandCategory.AzureServices && !areaFilter(area))
{
continue;
}
services.AddSingleton(area);
area.ConfigureServices(services);
configuredServerArea |= string.Equals(area.Name, s_serverAreaName, StringComparison.OrdinalIgnoreCase);
}

// Optimization: server-mode providers (registry, instructions, plugin allowlists) are only
// used when running as an MCP server. For CLI area invocations they are never resolved, so
// register lightweight stubs to avoid reading embedded resources on every CLI call.
if (areaFilter == null || string.Equals(areaFilter, ServerAreaName, StringComparison.OrdinalIgnoreCase))
if (areaFilter == null || configuredServerArea)
{
services.AddRegistryRoot(thisAssembly, $"registry.json");

Expand Down Expand Up @@ -443,7 +442,7 @@ internal static async Task InitializeServicesAsync(IServiceProvider serviceProvi
// Only apply the optimization when the first token is a known registered area.
// If the token doesn't match any area (e.g. a typo), fall through to full initialization
// so System.CommandLine can produce helpful "Did you mean..." suggestions.
if (!Array.Exists(Areas, a => string.Equals(a.Name, firstToken, StringComparison.OrdinalIgnoreCase)))
if (!s_areas.Any(a => string.Equals(a.Name, firstToken, StringComparison.OrdinalIgnoreCase)))
{
return null;
}
Expand Down
7 changes: 4 additions & 3 deletions servers/Fabric.Mcp.Server/src/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,13 @@

internal class Program
{
private static IAreaSetup[] Areas = RegisterAreas();
private static readonly List<IAreaSetup> s_areas = RegisterAreas();

private static async Task<int> Main(string[] args)
{
try
{
ServiceStartCommand.Areas = s_areas;
ServiceStartCommand.ConfigureServices = ConfigureServices;
ServiceStartCommand.InitializeServicesAsync = InitializeServicesAsync;

Expand Down Expand Up @@ -103,7 +104,7 @@ private static async Task<int> Main(string[] args)
return 1;
}
}
private static IAreaSetup[] RegisterAreas()
private static List<IAreaSetup> RegisterAreas()
{

return [
Expand Down Expand Up @@ -191,7 +192,7 @@ internal static void ConfigureServices(IServiceCollection services)
services.AddHttpClientServices();
services.AddSingleUserCliCacheService(disabled: true);

foreach (var area in Areas)
foreach (var area in s_areas)
{
services.AddSingleton(area);
area.ConfigureServices(services);
Expand Down
Loading
Loading