diff --git a/Directory.Packages.props b/Directory.Packages.props
index a265d88a6a..e3b9e1e5b1 100644
--- a/Directory.Packages.props
+++ b/Directory.Packages.props
@@ -118,5 +118,7 @@
+
+
diff --git a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs
index 0dfa7c69ce..ac82c2477d 100644
--- a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs
+++ b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupJsonContext.cs
@@ -6,7 +6,6 @@
namespace Azure.Mcp.Core.Areas.Group.Commands;
[JsonSerializable(typeof(GroupListCommand.Result))]
-[JsonSerializable(typeof(ResourceListCommand.ResourceListCommandResult))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
internal partial class GroupJsonContext : JsonSerializerContext
{
diff --git a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs
index add4515b21..f2c0aaf010 100644
--- a/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs
+++ b/core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs
@@ -2,34 +2,42 @@
// Licensed under the MIT License.
using Azure.Mcp.Core.Areas.Group.Options;
+using Azure.Mcp.Core.Commands;
using Azure.Mcp.Core.Commands.Subscription;
+using Azure.Mcp.Core.Models.Option;
+using Azure.Mcp.Core.Models.ResourceGroup;
using Azure.Mcp.Core.Services.Azure.ResourceGroup;
using Microsoft.Extensions.Logging;
-using Microsoft.Mcp.Core.Commands;
-using Microsoft.Mcp.Core.Models.Command;
-using Microsoft.Mcp.Core.Models.ResourceGroup;
namespace Azure.Mcp.Core.Areas.Group.Commands;
-[CommandMetadata(
- Id = "a0049f31-9a32-4b5e-91ec-e7b074fc7246",
- Name = "list",
- Title = "List Resource Groups",
- Description = """
- List all resource groups in a subscription. This command retrieves all resource groups available
- in the specified subscription. Results include resource group names and IDs,
- returned as a JSON array.
- """,
- Destructive = false,
- Idempotent = true,
- OpenWorld = false,
- ReadOnly = true,
- LocalRequired = false,
- Secret = false)]
-public sealed class GroupListCommand(ILogger logger, IResourceGroupService resourceGroupService) : SubscriptionCommand()
+public sealed class GroupListCommand(ILogger logger) : SubscriptionCommand()
{
+ private const string CommandTitle = "List Resource Groups";
private readonly ILogger _logger = logger;
- private readonly IResourceGroupService _resourceGroupService = resourceGroupService;
+
+ public override string Id => "a0049f31-9a32-4b5e-91ec-e7b074fc7246";
+
+ public override string Name => "list";
+
+ public override string Description =>
+ $"""
+ List all resource groups in a subscription. This command retrieves all resource groups available
+ in the specified {OptionDefinitions.Common.SubscriptionName}. Results include resource group names and IDs,
+ returned as a JSON array.
+ """;
+
+ public override string Title => CommandTitle;
+
+ public override ToolMetadata Metadata => new()
+ {
+ Destructive = false,
+ Idempotent = true,
+ OpenWorld = false,
+ ReadOnly = true,
+ LocalRequired = false,
+ Secret = false
+ };
public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
{
@@ -42,13 +50,16 @@ public override async Task ExecuteAsync(CommandContext context,
try
{
- var groups = await _resourceGroupService.GetResourceGroups(
+ var resourceGroupService = context.GetService();
+ var groups = await resourceGroupService.GetResourceGroups(
options.Subscription!,
options.Tenant,
options.RetryPolicy,
cancellationToken);
- context.Response.Results = ResponseResult.Create(new(groups ?? []), GroupJsonContext.Default.Result);
+ context.Response.Results = groups?.Count > 0 ?
+ ResponseResult.Create(new Result(groups), GroupJsonContext.Default.Result) :
+ null;
}
catch (Exception ex)
{
diff --git a/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs b/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs
index 460b34248b..560e81fece 100644
--- a/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs
+++ b/core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs
@@ -2,9 +2,8 @@
// Licensed under the MIT License.
using Azure.Mcp.Core.Areas.Group.Commands;
+using Azure.Mcp.Core.Commands;
using Microsoft.Extensions.DependencyInjection;
-using Microsoft.Mcp.Core.Areas;
-using Microsoft.Mcp.Core.Commands;
namespace Azure.Mcp.Core.Areas.Group;
@@ -14,26 +13,18 @@ public sealed class GroupSetup : IAreaSetup
public string Title => "Azure Resource Groups";
- public CommandCategory Category => CommandCategory.SubscriptionManagement;
-
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton();
- services.AddSingleton();
}
public CommandGroup RegisterCommands(IServiceProvider serviceProvider)
{
- var group = new CommandGroup(Name, "Resource group operations - Commands for listing and managing Azure resource groups and their resources in your subscriptions.", Title);
+ var group = new CommandGroup(Name, "Resource group operations - Commands for listing and managing Azure resource groups in your subscriptions.", Title);
// Register Group commands
- group.AddCommand(serviceProvider);
-
- // Register Resource sub-group
- var resource = new CommandGroup("resource", "Resource operations - Commands for listing resources within a resource group.");
- group.AddSubGroup(resource);
-
- resource.AddCommand(serviceProvider);
+ var listCommand = serviceProvider.GetRequiredService();
+ group.AddCommand(listCommand.Name, listCommand);
return group;
}
diff --git a/core/Azure.Mcp.Core/src/Areas/Group/Options/BaseGroupOptions.cs b/core/Azure.Mcp.Core/src/Areas/Group/Options/BaseGroupOptions.cs
index 8e1ee2be57..7ffaef3dc9 100644
--- a/core/Azure.Mcp.Core/src/Areas/Group/Options/BaseGroupOptions.cs
+++ b/core/Azure.Mcp.Core/src/Areas/Group/Options/BaseGroupOptions.cs
@@ -1,7 +1,7 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
-using Microsoft.Mcp.Core.Options;
+using Azure.Mcp.Core.Options;
namespace Azure.Mcp.Core.Areas.Group.Options;
diff --git a/core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs b/core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs
new file mode 100644
index 0000000000..296b412cf1
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs
@@ -0,0 +1,31 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Mcp.Core.Commands;
+using Microsoft.Extensions.DependencyInjection;
+
+namespace Azure.Mcp.Core.Areas
+{
+ public interface IAreaSetup
+ {
+ ///
+ /// Gets the name of the area.
+ ///
+ string Name { get; }
+
+ ///
+ /// Gets the user-friendly title of the area for display purposes.
+ ///
+ string Title { get; }
+
+ ///
+ /// Configure any dependencies.
+ ///
+ void ConfigureServices(IServiceCollection services);
+
+ ///
+ /// Gets a tree whose root node represents the area.
+ ///
+ CommandGroup RegisterCommands(IServiceProvider serviceProvider);
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs
new file mode 100644
index 0000000000..0e37556ffa
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/BaseDiscoveryStrategy.cs
@@ -0,0 +1,132 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Client;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+///
+/// Base class for MCP server discovery strategies that provides common functionality.
+/// Implements client caching and server provider lookup by name.
+///
+public abstract class BaseDiscoveryStrategy(ILogger logger) : IMcpDiscoveryStrategy
+{
+ ///
+ /// Logger instance for this discovery strategy.
+ ///
+ protected readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+ ///
+ /// Cache of MCP clients created by this discovery strategy, keyed by server name (case-insensitive).
+ ///
+ protected readonly Dictionary _clientCache = new(StringComparer.OrdinalIgnoreCase);
+
+ private bool _disposed = false;
+
+ ///
+ public abstract Task> DiscoverServersAsync(CancellationToken cancellationToken);
+
+ ///
+ public async Task FindServerProviderAsync(string name, CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(name, nameof(name));
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException(nameof(name), "Server name cannot be null or empty.");
+ }
+
+ var serverProviders = await DiscoverServersAsync(cancellationToken);
+ foreach (var serverProvider in serverProviders)
+ {
+ var metadata = serverProvider.CreateMetadata();
+ if (metadata.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
+ {
+ return serverProvider;
+ }
+ }
+
+ throw new KeyNotFoundException($"No MCP server found with the name '{name}'.");
+ }
+
+ ///
+ public async Task GetOrCreateClientAsync(string name, McpClientOptions? clientOptions = null, CancellationToken cancellationToken = default)
+ {
+ ArgumentNullException.ThrowIfNull(name, nameof(name));
+ if (string.IsNullOrWhiteSpace(name))
+ {
+ throw new ArgumentNullException(nameof(name), "Server name cannot be null or empty.");
+ }
+
+ if (_clientCache.TryGetValue(name, out var client))
+ {
+ return client;
+ }
+
+ var serverProvider = await FindServerProviderAsync(name, cancellationToken);
+ client = await serverProvider.CreateClientAsync(clientOptions ?? new McpClientOptions(), cancellationToken);
+ _clientCache[name] = client;
+
+ return client;
+ }
+
+ ///
+ /// Disposes all cached MCP clients with double disposal protection.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ try
+ {
+ // First, let derived classes dispose their resources (isolated from base cleanup)
+ try
+ {
+ await DisposeAsyncCore();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error occurred while disposing derived resources in discovery strategy {StrategyType}", GetType().Name);
+ }
+
+ // Then dispose our own critical resources using best-effort approach
+ var clientDisposalTasks = _clientCache.Values.Select(async client =>
+ {
+ try
+ {
+ await client.DisposeAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to dispose MCP client in discovery strategy {StrategyType}", GetType().Name);
+ }
+ });
+
+ await Task.WhenAll(clientDisposalTasks);
+ _clientCache.Clear();
+ }
+ catch (Exception ex)
+ {
+ // Log disposal failures but don't throw - we want to ensure cleanup continues
+ // Individual disposal errors shouldn't stop the overall disposal process
+ _logger.LogError(ex, "Error occurred while disposing discovery strategy {StrategyType}. Some resources may not have been properly disposed.", GetType().Name);
+ }
+ finally
+ {
+ _disposed = true;
+ }
+ }
+
+ ///
+ /// Override this method in derived classes to implement disposal logic.
+ /// This method is called exactly once during disposal.
+ ///
+ /// A task representing the asynchronous disposal operation.
+ protected virtual ValueTask DisposeAsyncCore()
+ {
+ // Default implementation does nothing - derived classes override to add their specific cleanup
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs
new file mode 100644
index 0000000000..d3e7ebe065
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupDiscoveryStrategy.cs
@@ -0,0 +1,46 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Mcp.Core.Areas.Server.Options;
+using Azure.Mcp.Core.Commands;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+///
+/// Discovery strategy that exposes command groups as MCP servers.
+/// This strategy converts Azure CLI command groups into MCP servers, allowing them to be accessed via the MCP protocol.
+///
+/// The command factory used to access available command groups.
+/// Options for configuring the service behavior.
+/// Logger instance for this discovery strategy.
+public sealed class CommandGroupDiscoveryStrategy(CommandFactory commandFactory, IOptions options, ILogger logger) : BaseDiscoveryStrategy(logger)
+{
+ private readonly CommandFactory _commandFactory = commandFactory;
+ private readonly IOptions _options = options;
+
+ ///
+ /// Gets or sets the entry point to use for the command group servers.
+ /// This can be used to specify a custom entry point for the commands.
+ ///
+ public string? EntryPoint { get; set; } = null;
+
+ ///
+ public override Task> DiscoverServersAsync(CancellationToken cancellationToken)
+ {
+ var providers = _commandFactory.RootGroup.SubGroup
+ .Where(group => !DiscoveryConstants.IgnoredCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase))
+ .Where(group => _options.Value.Namespace == null ||
+ _options.Value.Namespace.Length == 0 ||
+ _options.Value.Namespace.Contains(group.Name, StringComparer.OrdinalIgnoreCase))
+ .Select(group => new CommandGroupServerProvider(group)
+ {
+ ReadOnly = _options.Value.ReadOnly ?? false,
+ EntryPoint = EntryPoint,
+ })
+ .Cast();
+
+ return Task.FromResult(providers);
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs
new file mode 100644
index 0000000000..99972f6f65
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CommandGroupServerProvider.cs
@@ -0,0 +1,85 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Mcp.Core.Areas.Server.Options;
+using Azure.Mcp.Core.Commands;
+using ModelContextProtocol.Client;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+///
+/// Represents a command group that provides metadata and MCP client creation.
+///
+public sealed class CommandGroupServerProvider(CommandGroup commandGroup) : IMcpServerProvider
+{
+ private readonly CommandGroup _commandGroup = commandGroup;
+ private string? _entryPoint = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
+
+ ///
+ /// Gets or sets the entry point executable path for the MCP server.
+ /// If set to null or empty, defaults to the current process executable.
+ ///
+ public string? EntryPoint
+ {
+ get => _entryPoint;
+ set => _entryPoint = string.IsNullOrWhiteSpace(value)
+ ? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName
+ : value;
+ }
+
+ ///
+ /// Gets or sets whether the MCP server should run in read-only mode.
+ ///
+ public bool ReadOnly { get; set; } = false;
+
+ ///
+ public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(EntryPoint))
+ {
+ throw new InvalidOperationException("EntryPoint must be set before creating the MCP client.");
+ }
+
+ var arguments = BuildArguments();
+
+ var transportOptions = new StdioClientTransportOptions
+ {
+ Name = _commandGroup.Name,
+ Command = EntryPoint,
+ Arguments = arguments,
+ };
+
+ var clientTransport = new StdioClientTransport(transportOptions);
+ return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Builds the command-line arguments for the MCP server process.
+ ///
+ /// An array of command-line arguments.
+ internal string[] BuildArguments()
+ {
+ var arguments = new List { "server", "start", "--mode", "all", "--namespace", _commandGroup.Name };
+
+ if (ReadOnly)
+ {
+ arguments.Add($"--{ServiceOptionDefinitions.ReadOnlyName}");
+ }
+
+ return [.. arguments];
+ }
+
+ ///
+ /// Creates metadata for the MCP server provider based on the command group.
+ ///
+ public McpServerMetadata CreateMetadata()
+ {
+ return new McpServerMetadata
+ {
+ Id = _commandGroup.Name,
+ Name = _commandGroup.Name,
+ Title = _commandGroup.Title ?? _commandGroup.Name,
+ Description = _commandGroup.Description
+ };
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs
new file mode 100644
index 0000000000..3b63315bc3
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/CompositeDiscoveryStrategy.cs
@@ -0,0 +1,69 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.Logging;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+///
+/// A discovery strategy that combines multiple discovery strategies into one.
+/// This allows discovering servers from multiple sources and aggregating the results.
+///
+/// The collection of discovery strategies to combine.
+/// Logger instance for this discovery strategy.
+public sealed class CompositeDiscoveryStrategy(IEnumerable strategies, ILogger logger) : BaseDiscoveryStrategy(logger)
+{
+ private readonly List _strategies = InitializeStrategies(strategies);
+
+ ///
+ /// Initializes the list of discovery strategies, validating that at least one is provided.
+ ///
+ /// The collection of discovery strategies to initialize.
+ /// A list of initialized discovery strategies.
+ /// Thrown when the strategies parameter is null.
+ /// Thrown when no discovery strategies are provided.
+ private static List InitializeStrategies(IEnumerable strategies)
+ {
+ ArgumentNullException.ThrowIfNull(strategies);
+
+ var strategyList = new List(strategies);
+
+ if (strategyList.Count == 0)
+ {
+ throw new ArgumentException("At least one discovery strategy must be provided.", nameof(strategies));
+ }
+
+ return strategyList;
+ }
+
+ ///
+ public override async Task> DiscoverServersAsync(CancellationToken cancellationToken)
+ {
+ var tasks = _strategies.Select(strategy => strategy.DiscoverServersAsync(cancellationToken));
+ var results = await Task.WhenAll(tasks);
+
+ return results.SelectMany(result => result);
+ }
+
+ ///
+ /// Disposes all child discovery strategies and then calls the base dispose.
+ /// Uses best-effort disposal to ensure all strategies are disposed even if some fail.
+ ///
+ protected override async ValueTask DisposeAsyncCore()
+ {
+ // Dispose all child strategies using best-effort approach
+ var childDisposalTasks = _strategies.Select(async strategy =>
+ {
+ try
+ {
+ await strategy.DisposeAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to dispose discovery strategy {StrategyType}", strategy.GetType().Name);
+ }
+ });
+
+ await Task.WhenAll(childDisposalTasks);
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs
new file mode 100644
index 0000000000..effb5d5531
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolDiscoveryStrategy.cs
@@ -0,0 +1,297 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Reflection;
+using System.Text.Json;
+using Azure.Mcp.Core.Areas;
+using Azure.Mcp.Core.Areas.Server.Models;
+using Azure.Mcp.Core.Areas.Server.Options;
+using Azure.Mcp.Core.Commands;
+using Azure.Mcp.Core.Configuration;
+using Azure.Mcp.Core.Services.Telemetry;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+///
+/// Discovery strategy that exposes command groups as MCP servers.
+/// This strategy converts Azure CLI command groups into MCP servers, allowing them to be accessed via the MCP protocol.
+///
+/// The command factory used to access available command groups.
+/// Options for configuring the service behavior.
+/// Configuration options for the Azure MCP server.
+/// Logger instance for this discovery strategy.
+public sealed class ConsolidatedToolDiscoveryStrategy(CommandFactory commandFactory, IServiceProvider serviceProvider, IOptions options, IOptions configurationOptions, ILogger logger) : BaseDiscoveryStrategy(logger)
+{
+ private readonly CommandFactory _commandFactory = commandFactory;
+ private readonly IServiceProvider _serviceProvider = serviceProvider;
+ private readonly IOptions _options = options;
+ private readonly IOptions _configurationOptions = configurationOptions;
+ private CommandFactory? _consolidatedCommandFactory;
+
+ ///
+ /// Gets or sets the entry point to use for the command group servers.
+ /// This can be used to specify a custom entry point for the commands.
+ ///
+ public string? EntryPoint { get; set; } = null;
+ public static readonly string[] IgnoredCommandGroups = ["server", "tools"];
+
+ ///
+ /// Creates a new CommandFactory with consolidated command groups.
+ /// This method builds command groups from the consolidated tools definition
+ /// without mutating the original CommandFactory.
+ ///
+ /// A new CommandFactory instance with consolidated command groups.
+ public CommandFactory CreateConsolidatedCommandFactory()
+ {
+ if (_consolidatedCommandFactory != null)
+ {
+ return _consolidatedCommandFactory;
+ }
+
+ // Load consolidated tool definitions from JSON file
+ var consolidatedTools = LoadConsolidatedToolDefinitions();
+
+ // Filter commands based on options
+ var allCommands = _commandFactory.AllCommands;
+ var filteredCommands = FilterCommands(allCommands);
+
+ // Create individual area setups for each consolidated tool
+ // This way, each consolidated tool becomes a top-level namespace
+ var consolidatedAreas = new List();
+
+ var unmatchedCommands = new HashSet(filteredCommands.Keys, StringComparer.OrdinalIgnoreCase);
+
+ foreach (var consolidatedTool in consolidatedTools)
+ {
+ var matchingCommands = filteredCommands
+ .Where(kvp => consolidatedTool.MappedToolList != null &&
+ consolidatedTool.MappedToolList.Contains(kvp.Key, StringComparer.OrdinalIgnoreCase))
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+
+ if (matchingCommands.Count == 0)
+ {
+ continue;
+ }
+
+#if DEBUG
+ // In debug mode, validate that all tools in MappedToolList found a match when conditions are met
+ if (_options.Value.ReadOnly == false && (_options.Value.Namespace == null || _options.Value.Namespace.Length == 0))
+ {
+ if (consolidatedTool.MappedToolList != null)
+ {
+ var matchedToolNames = new HashSet(matchingCommands.Keys, StringComparer.OrdinalIgnoreCase);
+ var unmatchedToolsInList = consolidatedTool.MappedToolList
+ .Where(toolName => !matchedToolNames.Contains(toolName))
+ .ToList();
+
+ if (unmatchedToolsInList.Count > 0)
+ {
+ var unmatchedToolsList = string.Join(", ", unmatchedToolsInList);
+ var errorMessage = $"Consolidated tool '{consolidatedTool.Name}' has {unmatchedToolsInList.Count} tools in MappedToolList that didn't find a match in filteredCommands: {unmatchedToolsList}";
+ _logger.LogError(errorMessage);
+ throw new InvalidOperationException(errorMessage);
+ }
+ }
+ }
+#endif
+
+ // Validate metadata for each command
+ foreach (var (commandName, command) in matchingCommands)
+ {
+ if (!AreMetadataEqual(command.Metadata, consolidatedTool.ToolMetadata))
+ {
+ var errorMessage = $"Command '{commandName}' has mismatched ToolMetadata for consolidated tool '{consolidatedTool.Name}'. " +
+ $"Command metadata: [Destructive={command.Metadata.Destructive}, Idempotent={command.Metadata.Idempotent}, " +
+ $"OpenWorld={command.Metadata.OpenWorld}, ReadOnly={command.Metadata.ReadOnly}, Secret={command.Metadata.Secret}, " +
+ $"LocalRequired={command.Metadata.LocalRequired}], " +
+ $"Consolidated tool metadata: [Destructive={consolidatedTool.ToolMetadata?.Destructive}, " +
+ $"Idempotent={consolidatedTool.ToolMetadata?.Idempotent}, OpenWorld={consolidatedTool.ToolMetadata?.OpenWorld}, " +
+ $"ReadOnly={consolidatedTool.ToolMetadata?.ReadOnly}, Secret={consolidatedTool.ToolMetadata?.Secret}, " +
+ $"LocalRequired={consolidatedTool.ToolMetadata?.LocalRequired}]";
+#if DEBUG
+ _logger.LogError(errorMessage);
+ throw new InvalidOperationException(errorMessage);
+#else
+ _logger.LogWarning(errorMessage);
+#endif
+ }
+
+ unmatchedCommands.Remove(commandName);
+ }
+
+ // Create an area setup for this consolidated tool
+ var area = new SingleConsolidatedToolAreaSetup(
+ consolidatedTool,
+ matchingCommands
+ );
+
+ consolidatedAreas.Add(area);
+ }
+
+#if DEBUG
+ // Check for unmatched commands
+ if (unmatchedCommands.Count > 0)
+ {
+ var unmatchedList = string.Join(", ", unmatchedCommands.OrderBy(c => c));
+ var errorMessage = $"Found {unmatchedCommands.Count} unmatched commands: {unmatchedList}";
+ _logger.LogError(errorMessage);
+ throw new InvalidOperationException(errorMessage);
+ }
+#else
+ if (unmatchedCommands.Count > 0)
+ {
+ var unmatchedList = string.Join(", ", unmatchedCommands.OrderBy(c => c));
+ _logger.LogWarning("Found {Count} unmatched commands: {Commands}", unmatchedCommands.Count, unmatchedList);
+ }
+#endif
+
+ // Create a new CommandFactory with all consolidated areas
+ var telemetryService = _serviceProvider.GetRequiredService();
+ var factoryLogger = _serviceProvider.GetRequiredService>();
+
+ _consolidatedCommandFactory = new CommandFactory(
+ _serviceProvider,
+ consolidatedAreas,
+ telemetryService,
+ _configurationOptions,
+ factoryLogger
+ );
+
+ return _consolidatedCommandFactory;
+ }
+
+ private List LoadConsolidatedToolDefinitions()
+ {
+ try
+ {
+ var assembly = Assembly.GetExecutingAssembly();
+ var resourceName = "Azure.Mcp.Core.Areas.Server.Resources.consolidated-tools.json";
+ using var stream = assembly.GetManifestResourceStream(resourceName);
+ if (stream == null)
+ {
+ var errorMessage = $"Failed to load embedded resource '{resourceName}'";
+ _logger.LogError(errorMessage);
+ throw new InvalidOperationException(errorMessage);
+ }
+
+ using var reader = new StreamReader(stream);
+ var json = reader.ReadToEnd();
+ using var jsonDoc = JsonDocument.Parse(json);
+ if (!jsonDoc.RootElement.TryGetProperty("consolidated_tools", out var toolsArray))
+ {
+ var errorMessage = "Property 'consolidated_tools' not found in consolidated-tools.json";
+ _logger.LogError(errorMessage);
+ throw new InvalidOperationException(errorMessage);
+ }
+
+ return JsonSerializer.Deserialize(toolsArray.GetRawText(), ServerJsonContext.Default.ListConsolidatedToolDefinition) ?? new List();
+ }
+ catch (Exception ex)
+ {
+ var errorMessage = "Failed to load consolidated tools from JSON file";
+ _logger.LogError(ex, errorMessage);
+ throw new InvalidOperationException(errorMessage);
+ }
+ }
+
+ private Dictionary FilterCommands(IReadOnlyDictionary allCommands)
+ {
+ return allCommands
+ .Where(kvp =>
+ {
+ var serviceArea = _commandFactory.GetServiceArea(kvp.Key);
+ return serviceArea == null || !IgnoredCommandGroups.Contains(serviceArea, StringComparer.OrdinalIgnoreCase);
+ })
+ .Where(kvp => _options.Value.ReadOnly == false || kvp.Value.Metadata.ReadOnly == true)
+ .Where(kvp =>
+ {
+ // Filter by namespace if specified
+ if (_options.Value.Namespace == null || _options.Value.Namespace.Length == 0)
+ {
+ return true;
+ }
+ var serviceArea = _commandFactory.GetServiceArea(kvp.Key);
+ return serviceArea != null && _options.Value.Namespace.Contains(serviceArea, StringComparer.OrdinalIgnoreCase);
+ })
+ .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
+ }
+
+ ///
+ public override Task> DiscoverServersAsync(CancellationToken cancellationToken)
+ {
+ return Task.FromResult>(new List());
+ }
+
+ ///
+ /// Compares two ToolMetadata objects for equality.
+ ///
+ /// The first ToolMetadata to compare.
+ /// The second ToolMetadata to compare.
+ /// True if the metadata objects are equal, false otherwise.
+ internal static bool AreMetadataEqual(ToolMetadata? metadata1, ToolMetadata? metadata2)
+ {
+ if (metadata1 == null && metadata2 == null)
+ {
+ return true;
+ }
+
+ if (metadata1 == null || metadata2 == null)
+ {
+ return false;
+ }
+
+ return metadata1.Destructive == metadata2.Destructive &&
+ metadata1.Idempotent == metadata2.Idempotent &&
+ metadata1.OpenWorld == metadata2.OpenWorld &&
+ metadata1.ReadOnly == metadata2.ReadOnly &&
+ metadata1.Secret == metadata2.Secret &&
+ metadata1.LocalRequired == metadata2.LocalRequired;
+ }
+}
+
+///
+/// Represents a single consolidated tool as an IAreaSetup.
+/// Each instance creates a top-level namespace for one consolidated tool in the CommandFactory.
+/// This allows NamespaceToolLoader to see each consolidated tool as a separate top-level namespace.
+///
+internal sealed class SingleConsolidatedToolAreaSetup : IAreaSetup
+{
+ private readonly ConsolidatedToolDefinition _consolidatedTool;
+ private readonly Dictionary _matchingCommands;
+
+ public SingleConsolidatedToolAreaSetup(
+ ConsolidatedToolDefinition consolidatedTool,
+ Dictionary matchingCommands)
+ {
+ _consolidatedTool = consolidatedTool;
+ _matchingCommands = matchingCommands;
+ }
+
+ public string Name => _consolidatedTool.Name ?? string.Empty;
+ public string Title => Name;
+
+ public void ConfigureServices(IServiceCollection services)
+ {
+ // No additional services needed
+ }
+
+ public CommandGroup RegisterCommands(IServiceProvider serviceProvider)
+ {
+ // Create command group for this consolidated tool
+ var commandGroup = new CommandGroup(Name, Title);
+
+ // Add all matching commands to this group
+ foreach (var cmd in _matchingCommands)
+ {
+ commandGroup.AddCommand(cmd.Key, cmd.Value);
+ }
+
+ // Set tool metadata from the consolidated tool definition
+ commandGroup.ToolMetadata = _consolidatedTool.ToolMetadata;
+
+ return commandGroup;
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs
new file mode 100644
index 0000000000..e8fec77396
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/ConsolidatedToolServerProvider.cs
@@ -0,0 +1,94 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Mcp.Core.Areas.Server.Options;
+using Azure.Mcp.Core.Commands;
+using ModelContextProtocol.Client;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+///
+/// Server provider that starts the azmcp server in "all" mode while explicitly
+/// enumerating each tool (command) in a command group using repeated --tool flags.
+/// This allows selective exposure of only the commands that belong to the provided group
+/// without relying on the namespace grouping mechanism.
+///
+public sealed class ConsolidatedToolServerProvider(CommandGroup commandGroup) : IMcpServerProvider
+{
+ private readonly CommandGroup _commandGroup = commandGroup;
+ private string? _entryPoint = System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName;
+
+ ///
+ /// Gets or sets the entry point executable path for the MCP server.
+ /// If set to null or empty, defaults to the current process executable.
+ ///
+ public string? EntryPoint
+ {
+ get => _entryPoint;
+ set => _entryPoint = string.IsNullOrWhiteSpace(value)
+ ? System.Diagnostics.Process.GetCurrentProcess().MainModule?.FileName
+ : value;
+ }
+
+ ///
+ /// Gets or sets whether the MCP server should run in read-only mode.
+ ///
+ public bool ReadOnly { get; set; } = false;
+
+ ///
+ public async Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(EntryPoint))
+ {
+ throw new InvalidOperationException("EntryPoint must be set before creating the MCP client.");
+ }
+
+ var arguments = BuildArguments();
+
+ var transportOptions = new StdioClientTransportOptions
+ {
+ Name = _commandGroup.Name,
+ Command = EntryPoint,
+ Arguments = arguments,
+ };
+
+ var clientTransport = new StdioClientTransport(transportOptions);
+ return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Builds the command-line arguments for the MCP server process.
+ /// Pattern: server start --mode all (--tool )+ [--read-only]
+ ///
+ internal string[] BuildArguments()
+ {
+ var arguments = new List { "server", "start", "--mode", "all" };
+
+ foreach (var kvp in _commandGroup.Commands)
+ {
+ arguments.Add("--tool");
+ arguments.Add(kvp.Key);
+ }
+
+ if (ReadOnly)
+ {
+ arguments.Add($"--{ServiceOptionDefinitions.ReadOnlyName}");
+ }
+
+ return [.. arguments];
+ }
+
+ ///
+ /// Creates metadata for the MCP server provider based on the command group.
+ ///
+ public McpServerMetadata CreateMetadata()
+ {
+ return new McpServerMetadata
+ {
+ Id = _commandGroup.Name,
+ Name = _commandGroup.Name,
+ Description = _commandGroup.Description,
+ ToolMetadata = _commandGroup.ToolMetadata,
+ };
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/DiscoveryConstants.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/DiscoveryConstants.cs
new file mode 100644
index 0000000000..1d9a77fedf
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/DiscoveryConstants.cs
@@ -0,0 +1,22 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+///
+/// Constants used by discovery strategies and tool loaders.
+///
+public static class DiscoveryConstants
+{
+ ///
+ /// Utility namespaces that should be exposed as individual commands rather than namespace proxies.
+ /// These commands are always available in namespace mode regardless of namespace filters.
+ ///
+ public static readonly string[] UtilityNamespaces = ["subscription", "group"];
+
+ ///
+ /// Command groups that should be ignored when discovering namespace proxy servers.
+ /// These include both core infrastructure groups and utility groups that are handled separately.
+ ///
+ public static readonly string[] IgnoredCommandGroups = ["extension", "server", "tools", .. UtilityNamespaces];
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs
new file mode 100644
index 0000000000..6d8b1e67d1
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/IDiscoveryStrategy.cs
@@ -0,0 +1,36 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using ModelContextProtocol.Client;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+public interface IMcpDiscoveryStrategy : IAsyncDisposable
+{
+ ///
+ /// Discovers available MCP servers via this strategy.
+ ///
+ /// A cancellation token.
+ /// A collection of discovered MCP servers.
+ Task> DiscoverServersAsync(CancellationToken cancellationToken);
+
+ ///
+ /// Finds a server provider by name.
+ ///
+ /// The name of the server to find.
+ /// A cancellation token.
+ /// The server provider if found.
+ /// Thrown when no server with the specified name is found.
+ Task FindServerProviderAsync(string name, CancellationToken cancellationToken);
+
+ ///
+ /// Gets an MCP client for the specified server.
+ ///
+ /// The name of the server to get a client for.
+ /// Optional client configuration options. If null, default options are used.
+ /// A cancellation token.
+ /// An MCP client that can communicate with the specified server.
+ /// Thrown when no server with the specified name is found.
+ /// Thrown when the name parameter is null.
+ Task GetOrCreateClientAsync(string name, McpClientOptions? clientOptions = null, CancellationToken cancellationToken = default);
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs
new file mode 100644
index 0000000000..9ff0009574
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/McpServerMetadata.cs
@@ -0,0 +1,63 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Mcp.Core.Commands;
+using ModelContextProtocol.Client;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+///
+/// Represents metadata for an MCP server, including identification and descriptive information.
+///
+/// The unique identifier for the server.
+/// The display name of the server.
+/// A description of the server's purpose or capabilities.
+public sealed class McpServerMetadata(string id = "", string name = "", string description = "")
+{
+ ///
+ /// Gets or sets the unique identifier for the server.
+ ///
+ public string Id { get; set; } = id;
+
+ ///
+ /// Gets or sets the display name of the server.
+ ///
+ public string Name { get; set; } = name;
+
+ ///
+ /// Gets or sets the user-friendly title of the server for display purposes.
+ ///
+ public string? Title { get; set; }
+
+ ///
+ /// Gets or sets a description of the server's purpose or capabilities.
+ ///
+ public string Description { get; set; } = description;
+
+ ///
+ /// Gets or sets the tool metadata for this server, containing tool-specific information.
+ ///
+ public ToolMetadata? ToolMetadata { get; set; }
+}
+
+///
+/// Defines an interface for MCP server providers that can create server metadata and clients.
+///
+public interface IMcpServerProvider
+{
+ ///
+ /// Creates metadata that describes this server provider.
+ ///
+ /// A metadata object containing the server's identity and description.
+ McpServerMetadata CreateMetadata();
+
+ ///
+ /// Creates an MCP client that can communicate with this server.
+ ///
+ /// Options to configure the client behavior.
+ /// A token to cancel the operation.
+ /// A configured MCP client ready for use.
+ /// Thrown when the server configuration doesn't specify a valid transport type (missing URL or stdio configuration).
+ /// Thrown when the server configuration is valid but client creation fails (e.g., missing command for stdio transport, dependency issues, or external process failures).
+ Task CreateClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken);
+}
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
new file mode 100644
index 0000000000..f660392048
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryDiscoveryStrategy.cs
@@ -0,0 +1,72 @@
+// 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;
+using Microsoft.Extensions.Options;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+///
+/// Discovers MCP servers from an embedded registry.json resource file.
+/// This strategy loads server configurations from a JSON resource bundled with the assembly.
+///
+/// Options for configuring the service behavior.
+/// Logger instance for this discovery strategy.
+public sealed class RegistryDiscoveryStrategy(IOptions options, ILogger logger) : BaseDiscoveryStrategy(logger)
+{
+ private readonly IOptions _options = options;
+
+ ///
+ public override async Task> DiscoverServersAsync(CancellationToken cancellationToken)
+ {
+ var registryRoot = await LoadRegistryAsync();
+ if (registryRoot == null)
+ {
+ return [];
+ }
+
+ return registryRoot
+ .Servers!
+ .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))
+ .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
new file mode 100644
index 0000000000..dac744e633
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Discovery/RegistryServerProvider.cs
@@ -0,0 +1,134 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Mcp.Core.Areas.Server.Models;
+using ModelContextProtocol.Client;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+
+///
+/// Provides an MCP server implementation based on registry configuration.
+/// Supports stdio transport mechanism.
+///
+/// The unique identifier for the server.
+/// Configuration information for the server.
+public sealed class RegistryServerProvider(string id, RegistryServerInfo serverInfo) : IMcpServerProvider
+{
+ private readonly string _id = id;
+ private readonly RegistryServerInfo _serverInfo = serverInfo;
+
+ ///
+ /// Creates metadata that describes this registry-based server.
+ ///
+ /// A metadata object containing the server's identity and description.
+ public McpServerMetadata CreateMetadata()
+ {
+ return new McpServerMetadata
+ {
+ Id = _id,
+ Name = _id,
+ Title = _serverInfo.Title,
+ Description = _serverInfo.Description ?? string.Empty
+ };
+ }
+
+ ///
+ 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;
+ }
+ else if (!string.IsNullOrWhiteSpace(_serverInfo.Type) && _serverInfo.Type.Equals("stdio", StringComparison.OrdinalIgnoreCase))
+ {
+ clientFactory = CreateStdioClientAsync;
+ }
+
+ if (clientFactory == null)
+ {
+ throw new ArgumentException($"Registry server '{_id}' does not have a valid transport type. Either 'url' for HTTP transport or 'type=stdio' with 'command' must be specified.");
+ }
+
+ try
+ {
+ return await clientFactory(clientOptions, cancellationToken);
+ }
+ catch (Exception ex)
+ {
+ if (!string.IsNullOrWhiteSpace(_serverInfo.InstallInstructions))
+ {
+ var errorWithInstructions = $"""
+ Failed to initialize the '{_id}' MCP tool.
+ This tool may require dependencies that are not installed.
+
+ Installation Instructions:
+ {_serverInfo.InstallInstructions}
+ """;
+
+ throw new InvalidOperationException(errorWithInstructions.Trim(), ex);
+ }
+
+ throw new InvalidOperationException($"Failed to create MCP client for registry server '{_id}': {ex.Message}", ex);
+ }
+ }
+
+ ///
+ /// Creates an MCP client that communicates with the server using Server-Sent Events (SSE).
+ ///
+ /// Options to configure the client behavior.
+ /// A token to cancel the operation.
+ /// A configured MCP client using SSE transport.
+ private async Task CreateHttpClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken)
+ {
+ var transportOptions = new HttpClientTransportOptions
+ {
+ Name = _id,
+ Endpoint = new Uri(_serverInfo.Url!),
+ TransportMode = HttpTransportMode.AutoDetect,
+ };
+ var clientTransport = new HttpClientTransport(transportOptions);
+ return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Creates an MCP client that communicates with the server using stdio (standard input/output).
+ ///
+ /// Options to configure the client behavior.
+ /// A token to cancel the operation.
+ /// A configured MCP client using stdio transport.
+ /// Thrown when the server configuration doesn't specify a valid command for stdio transport.
+ private async Task CreateStdioClientAsync(McpClientOptions clientOptions, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(_serverInfo.Command))
+ {
+ throw new InvalidOperationException($"Registry server '{_id}' does not have a valid command for stdio transport.");
+ }
+
+ // Merge current system environment variables with serverInfo.Env (serverInfo.Env overrides system)
+ var env = Environment.GetEnvironmentVariables()
+ .Cast()
+ .ToDictionary(e => (string)e.Key, e => (string?)e.Value);
+
+ if (_serverInfo.Env != null)
+ {
+ foreach (var kvp in _serverInfo.Env)
+ {
+ env[kvp.Key] = kvp.Value;
+ }
+ }
+
+ var transportOptions = new StdioClientTransportOptions
+ {
+ Name = _id,
+ Command = _serverInfo.Command,
+ Arguments = _serverInfo.Args,
+ EnvironmentVariables = env
+ };
+
+ var clientTransport = new StdioClientTransport(transportOptions);
+ return await McpClient.CreateAsync(clientTransport, clientOptions, cancellationToken: cancellationToken);
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/README.md b/core/Azure.Mcp.Core/src/Areas/Server/Commands/README.md
new file mode 100644
index 0000000000..f32dfe4bd5
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/README.md
@@ -0,0 +1,22 @@
+
+
+# Starting the Server in different modes
+There are two ways to start the server in `STDIO` or `SSE` (Streaming HTTP) modes.
+
+## STDIO
+
+`dotnet build && npx @modelcontextprotocol/inspector ./bin/Debug/net9.0/azmcp.exe server start`
+
+## SSE
+`dotnet build && ./bin/Debug/net9.0/azmcp.exe server start --transport sse`
+
+`npx @modelcontextprotocol/inspector`
+
+The SSE URI would be `http://localhost:5008`
+
+Then attach to the azmcp process in debugger. The default port for SSE is 5008.
+
+## To set timeout in mcp inspector
+
+http://localhost:5173/?timeout=2000000000#resources
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/IMcpRuntime.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/IMcpRuntime.cs
new file mode 100644
index 0000000000..0b49e357d0
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/IMcpRuntime.cs
@@ -0,0 +1,29 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using ModelContextProtocol.Protocol;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Runtime;
+
+///
+/// Defines the core functionality for a Model Context Protocol (MCP) runtime.
+/// The runtime is responsible for handling tool discovery and invocation requests.
+///
+public interface IMcpRuntime : IAsyncDisposable
+{
+ ///
+ /// Handles requests to list all tools available in the MCP server.
+ ///
+ /// The request context containing metadata and parameters.
+ /// A token to monitor for cancellation requests.
+ /// A result containing the list of available tools.
+ ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken);
+
+ ///
+ /// Handles requests to call a specific tool with the provided parameters.
+ ///
+ /// The request context containing the tool name and parameters.
+ /// A token to monitor for cancellation requests.
+ /// A result containing the output of the tool invocation.
+ ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken);
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs
new file mode 100644
index 0000000000..9a9bdfcb27
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/Runtime/McpRuntime.cs
@@ -0,0 +1,202 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics;
+using System.Text.Json.Nodes;
+using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading;
+using Azure.Mcp.Core.Areas.Server.Options;
+using Azure.Mcp.Core.Helpers;
+using Azure.Mcp.Core.Models.Option;
+using Azure.Mcp.Core.Services.Telemetry;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Protocol;
+using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.Runtime;
+
+///
+/// Implementation of the MCP runtime that delegates tool discovery and invocation to a tool loader.
+/// Provides logging and configuration support for the MCP server.
+///
+public sealed class McpRuntime : IMcpRuntime
+{
+ private readonly IToolLoader _toolLoader;
+ private readonly IOptions _options;
+ private readonly ILogger _logger;
+
+ private readonly ITelemetryService _telemetry;
+
+ ///
+ /// Initializes a new instance of the McpRuntime class.
+ ///
+ /// The tool loader responsible for discovering and loading tools.
+ /// Configuration options for the MCP server.
+ /// Logger for runtime operations.
+ /// Thrown if any required dependencies are null.
+ public McpRuntime(
+ IToolLoader toolLoader,
+ IOptions options,
+ ITelemetryService telemetry,
+ ILogger logger)
+ {
+ _toolLoader = toolLoader ?? throw new ArgumentNullException(nameof(toolLoader));
+ _options = options ?? throw new ArgumentNullException(nameof(options));
+ _telemetry = telemetry ?? throw new ArgumentNullException(nameof(telemetry));
+ _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ _logger.LogInformation("McpRuntime initialized with tool loader of type {ToolLoaderType}.", _toolLoader.GetType().Name);
+ _logger.LogInformation("ReadOnly mode is set to {ReadOnly}.", _options.Value.ReadOnly ?? false);
+ _logger.LogInformation("Namespace is set to {Namespace}.", string.Join(",", _options.Value.Namespace ?? []));
+ }
+
+ ///
+ /// Delegates tool invocation requests to the configured tool loader.
+ ///
+ /// The request context containing the tool name and parameters.
+ /// A token to monitor for cancellation requests.
+ /// A result containing the output of the tool invocation.
+ public async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ using var activity = _telemetry.StartActivity(ActivityName.ToolExecuted, request.Server.ClientInfo);
+ CaptureToolCallMeta(activity, request.Params?.Meta);
+
+ if (request.Params == null)
+ {
+ var content = new TextContentBlock
+ {
+ Text = "Cannot call tools with null parameters.",
+ };
+
+ activity?.SetStatus(ActivityStatusCode.Error)?.AddTag(TagName.ErrorDetails, content.Text);
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+
+ activity?.AddTag(TagName.ToolName, request.Params.Name);
+
+ var symbol = OptionDefinitions.Common.Subscription;
+
+ var subscriptionArgument = request.Params?.Arguments?
+ .Where(kvp => string.Equals(kvp.Key, NameNormalization.NormalizeOptionName(symbol.Name), StringComparison.OrdinalIgnoreCase))
+ .Select(kvp => kvp.Value)
+ .FirstOrDefault();
+ if (subscriptionArgument != null
+ && subscriptionArgument.HasValue
+ && subscriptionArgument.Value.ValueKind == JsonValueKind.String)
+ {
+ var subscription = subscriptionArgument.Value.GetString();
+ if (subscription != null)
+ {
+ activity?.AddTag(TagName.SubscriptionGuid, subscription);
+ }
+ }
+
+ CallToolResult callTool;
+ try
+ {
+ callTool = await _toolLoader.CallToolHandler(request!, cancellationToken);
+
+ var isSuccessful = !callTool.IsError.HasValue || !callTool.IsError.Value;
+ if (isSuccessful)
+ {
+ activity?.SetStatus(ActivityStatusCode.Ok);
+ return callTool;
+ }
+
+ activity?.SetStatus(ActivityStatusCode.Error);
+
+ // In the error case, try to get some details about the error.
+ // Typically all the error information is stored in the first
+ // content block and is of type TextContentBlock.
+ var textContent = callTool.Content
+ .Where(x => x is TextContentBlock)
+ .Cast()
+ .FirstOrDefault();
+
+ if (textContent != default)
+ {
+ activity?.SetTag(TagName.ErrorDetails, textContent.Text);
+ }
+
+ return callTool;
+ }
+ // Catches scenarios where child MCP clients are unable to be created
+ // due to missing dependencies or misconfiguration.
+ catch (InvalidOperationException ex)
+ {
+ activity?.SetStatus(ActivityStatusCode.Error, "Exception occurred calling tool handler")
+ ?.AddTag(TagName.ErrorDetails, ex.Message);
+
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock
+ {
+ Text = ex.Message,
+ }],
+ IsError = true,
+ };
+ }
+ catch (Exception ex)
+ {
+ activity?.SetStatus(ActivityStatusCode.Error, "Exception occurred calling tool handler")
+ ?.AddTag(TagName.ErrorDetails, ex.Message);
+
+ throw;
+ }
+ }
+
+ private static void CaptureToolCallMeta(Activity? activity, JsonObject? meta)
+ {
+ if (activity != null && meta != null)
+ {
+ var vsCodeConversationIdNode = meta["vscode.conversationId"];
+ if (vsCodeConversationIdNode != null && vsCodeConversationIdNode.GetValueKind() == JsonValueKind.String)
+ {
+ activity.AddTag(TagName.VSCodeConversationId, vsCodeConversationIdNode.GetValue());
+ }
+ var vsCodeRequestIdNode = meta["vscode.requestId"];
+ if (vsCodeRequestIdNode != null && vsCodeRequestIdNode.GetValueKind() == JsonValueKind.String)
+ {
+ activity.AddTag(TagName.VSCodeRequestId, vsCodeRequestIdNode.GetValue());
+ }
+ }
+ }
+
+ ///
+ /// Delegates tool discovery requests to the configured tool loader.
+ ///
+ /// The request context containing metadata and parameters.
+ /// A token to monitor for cancellation requests.
+ /// A result containing the list of available tools.
+ public async ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ using var activity = _telemetry.StartActivity(ActivityName.ListToolsHandler, request.Server.ClientInfo);
+
+ try
+ {
+ var result = await _toolLoader.ListToolsHandler(request!, cancellationToken);
+ activity?.SetStatus(ActivityStatusCode.Ok);
+
+ return result;
+ }
+ catch (Exception ex)
+ {
+ activity?.SetStatus(ActivityStatusCode.Error, "Exception occurred calling list tools handler")
+ ?.SetTag(TagName.ErrorDetails, ex.Message);
+ throw;
+ }
+ }
+
+ ///
+ /// Disposes the tool loader and releases associated resources.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ await _toolLoader.DisposeAsync();
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs
new file mode 100644
index 0000000000..7c5713c364
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceCollectionExtensions.cs
@@ -0,0 +1,308 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Reflection;
+using System.Text;
+using Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+using Azure.Mcp.Core.Areas.Server.Commands.Runtime;
+using Azure.Mcp.Core.Areas.Server.Commands.ToolLoading;
+using Azure.Mcp.Core.Areas.Server.Options;
+using Azure.Mcp.Core.Commands;
+using Azure.Mcp.Core.Helpers;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Protocol;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands;
+
+// This is intentionally placed after the namespace declaration to avoid
+// conflicts with Azure.Mcp.Core.Areas.Server.Options
+using Options = Microsoft.Extensions.Options.Options;
+
+///
+/// Extension methods for configuring Azure MCP server services.
+///
+public static class AzureMcpServiceCollectionExtensions
+{
+ private const string DefaultServerName = "Azure MCP Server";
+
+ ///
+ /// Adds the Azure MCP server services to the specified .
+ ///
+ /// The service collection to add services to.
+ /// The options for configuring the server.
+ /// The service collection with MCP server services added.
+ public static IServiceCollection AddAzureMcpServer(this IServiceCollection services, ServiceStartOptions serviceStartOptions)
+ {
+ // Register HTTP client services
+ services.AddHttpClientServices();
+
+ // Register options for service start
+ services.AddSingleton(serviceStartOptions);
+ services.AddSingleton(Options.Create(serviceStartOptions));
+
+ // Register default tool loader options from service start options
+ var defaultToolLoaderOptions = new ToolLoaderOptions
+ {
+ Namespace = serviceStartOptions.Namespace,
+ ReadOnly = serviceStartOptions.ReadOnly ?? false,
+ InsecureDisableElicitation = serviceStartOptions.InsecureDisableElicitation,
+ Tool = serviceStartOptions.Tool,
+ };
+
+ if (serviceStartOptions.Mode == ModeTypes.NamespaceProxy)
+ {
+ if (defaultToolLoaderOptions.Namespace == null || defaultToolLoaderOptions.Namespace.Length == 0)
+ {
+ defaultToolLoaderOptions = defaultToolLoaderOptions with { Namespace = ["extension"] };
+ }
+ }
+
+ services.AddSingleton(defaultToolLoaderOptions);
+ services.AddSingleton(Options.Create(defaultToolLoaderOptions));
+
+ // Register tool loader strategies
+ services.AddSingleton();
+ services.AddSingleton();
+
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Register server discovery strategies
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+ services.AddSingleton();
+
+ // Register MCP runtimes
+ services.AddSingleton();
+
+ // Register MCP discovery strategies based on proxy mode
+ if (serviceStartOptions.Mode == ModeTypes.SingleToolProxy)
+ {
+ services.AddSingleton(sp =>
+ {
+ var discoveryStrategies = new List
+ {
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ };
+
+ var logger = sp.GetRequiredService>();
+ return new CompositeDiscoveryStrategy(discoveryStrategies, logger);
+ });
+ }
+ else if (serviceStartOptions.Mode == ModeTypes.NamespaceProxy)
+ {
+ services.AddSingleton();
+ }
+ else if (serviceStartOptions.Mode == ModeTypes.ConsolidatedProxy)
+ {
+ services.AddSingleton(sp =>
+ {
+ var discoveryStrategies = new List
+ {
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ };
+
+ var logger = sp.GetRequiredService>();
+ return new CompositeDiscoveryStrategy(discoveryStrategies, logger);
+ });
+ }
+
+ // Configure tool loading based on mode
+ if (serviceStartOptions.Mode == ModeTypes.SingleToolProxy)
+ {
+ services.AddSingleton();
+ }
+ else if (serviceStartOptions.Mode == ModeTypes.NamespaceProxy)
+ {
+ services.AddSingleton(sp =>
+ {
+ var loggerFactory = sp.GetRequiredService();
+ var toolLoaders = new List
+ {
+ // ServerToolLoader with RegistryDiscoveryStrategy creates proxy tools for external MCP servers.
+ new ServerToolLoader(
+ sp.GetRequiredService(),
+ sp.GetRequiredService>(),
+ loggerFactory.CreateLogger()
+ ),
+ // NamespaceToolLoader enables direct in-process execution for tools in Azure namespaces
+ sp.GetRequiredService(),
+ };
+
+ // Always add utility commands (subscription, group) in namespace mode
+ // so they are available regardless of which namespaces are loaded
+ var utilityToolLoaderOptions = new ToolLoaderOptions(
+ Namespace: Discovery.DiscoveryConstants.UtilityNamespaces,
+ ReadOnly: defaultToolLoaderOptions.ReadOnly,
+ InsecureDisableElicitation: defaultToolLoaderOptions.InsecureDisableElicitation,
+ Tool: defaultToolLoaderOptions.Tool
+ );
+
+ toolLoaders.Add(new CommandFactoryToolLoader(
+ sp,
+ sp.GetRequiredService(),
+ Options.Create(utilityToolLoaderOptions),
+ loggerFactory.CreateLogger()
+ ));
+
+ // Append extension commands when no other namespaces are specified.
+ if (defaultToolLoaderOptions.Namespace?.SequenceEqual(["extension"]) == true)
+ {
+ toolLoaders.Add(sp.GetRequiredService());
+ }
+
+ return new CompositeToolLoader(toolLoaders, loggerFactory.CreateLogger());
+ });
+ }
+ else if (serviceStartOptions.Mode == ModeTypes.ConsolidatedProxy)
+ {
+ services.AddSingleton(sp =>
+ {
+ var loggerFactory = sp.GetRequiredService();
+ var consolidatedStrategy = sp.GetRequiredService();
+
+ // Create a new CommandFactory with consolidated command groups
+ var consolidatedCommandFactory = consolidatedStrategy.CreateConsolidatedCommandFactory();
+
+ var toolLoaders = new List
+ {
+ // ServerToolLoader with RegistryDiscoveryStrategy creates proxy tools for external MCP servers.
+ new ServerToolLoader(
+ sp.GetRequiredService(),
+ sp.GetRequiredService>(),
+ loggerFactory.CreateLogger()
+ ),
+ // NamespaceToolLoader enables direct in-process execution for consolidated tools
+ new NamespaceToolLoader(
+ consolidatedCommandFactory,
+ sp.GetRequiredService>(),
+ sp,
+ loggerFactory.CreateLogger(),
+ false
+ ),
+ };
+
+ return new CompositeToolLoader(toolLoaders, loggerFactory.CreateLogger());
+ });
+ }
+ else if (serviceStartOptions.Mode == ModeTypes.All)
+ {
+ services.AddSingleton();
+ services.AddSingleton(sp =>
+ {
+ var loggerFactory = sp.GetRequiredService();
+ var toolLoaders = new List
+ {
+ sp.GetRequiredService(),
+ sp.GetRequiredService(),
+ };
+
+ return new CompositeToolLoader(toolLoaders, loggerFactory.CreateLogger());
+ });
+ }
+
+ var mcpServerOptions = services
+ .AddOptions()
+ .Configure((mcpServerOptions, mcpRuntime) =>
+ {
+ var mcpServerOptionsBuilder = services.AddOptions();
+ var entryAssembly = Assembly.GetEntryAssembly();
+ var assemblyName = entryAssembly?.GetName();
+ var serverName = entryAssembly?.GetCustomAttribute()?.Title ?? DefaultServerName;
+
+ mcpServerOptions.ProtocolVersion = "2024-11-05";
+ mcpServerOptions.ServerInfo = new Implementation
+ {
+ Name = serverName,
+ Version = assemblyName?.Version?.ToString() ?? "1.0.0-beta"
+ };
+
+ mcpServerOptions.Handlers = new()
+ {
+ CallToolHandler = mcpRuntime.CallToolHandler,
+ ListToolsHandler = mcpRuntime.ListToolsHandler,
+ };
+
+ // Add instructions for the server
+ mcpServerOptions.ServerInstructions = GetServerInstructions();
+ });
+
+ var mcpServerBuilder = services.AddMcpServer();
+
+ if (serviceStartOptions.Transport == TransportTypes.Http)
+ {
+ mcpServerBuilder.WithHttpTransport();
+ }
+ else
+ {
+ mcpServerBuilder.WithStdioServerTransport();
+ }
+
+ return services;
+ }
+
+ ///
+ /// Generates comprehensive instructions for using the Azure MCP Server effectively.
+ /// Includes Azure best practices from embedded resource files.
+ ///
+ /// Instructions text for LLM interactions with the Azure MCP Server.
+ private static string GetServerInstructions()
+ {
+ var instructions = new StringBuilder();
+
+ try
+ {
+ var azureRulesContent = LoadAzureRulesForBestPractices();
+ if (!string.IsNullOrEmpty(azureRulesContent))
+ {
+ instructions.AppendLine(azureRulesContent);
+ }
+ }
+ catch (Exception)
+ {
+ // Fallback if resources are not available
+ instructions.AppendLine("**Note**: Azure rules resources are not available in this configuration.");
+ instructions.AppendLine("An error occurred while loading Azure rules.");
+ }
+
+ return instructions.ToString();
+ }
+
+ ///
+ /// Loads Azure rules for calling bestpractices tool from embedded resource files.
+ ///
+ /// Combined content from all Azure best practices resource files.
+ private static string LoadAzureRulesForBestPractices()
+ {
+ var coreAssembly = typeof(AzureMcpServiceCollectionExtensions).Assembly;
+ var azureRulesContent = new StringBuilder();
+
+ // List of known best practices resource files
+ var resourceFile = "azure-rules.txt";
+
+ try
+ {
+ string resourceName = EmbeddedResourceHelper.FindEmbeddedResource(coreAssembly, resourceFile);
+ string content = EmbeddedResourceHelper.ReadEmbeddedResource(coreAssembly, resourceName);
+
+ azureRulesContent.AppendLine(content);
+ azureRulesContent.AppendLine();
+ }
+ catch (Exception)
+ {
+ // Log the error but continue processing other files
+ azureRulesContent.AppendLine($"### Error loading {resourceFile}");
+ azureRulesContent.AppendLine("An error occurred while loading this section.");
+ azureRulesContent.AppendLine();
+ }
+
+ return azureRulesContent.ToString();
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoCommand.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoCommand.cs
new file mode 100644
index 0000000000..ce269a0d08
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoCommand.cs
@@ -0,0 +1,65 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Azure.Mcp.Core.Commands;
+using Azure.Mcp.Core.Configuration;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands;
+
+///
+/// Command that provides basic server information.
+///
+[HiddenCommand]
+public sealed class ServiceInfoCommand(IOptions serverOptions, ILogger logger) : BaseCommand
+{
+ private static readonly EmptyOptions EmptyOptions = new EmptyOptions();
+
+ private readonly IOptions _serverOptions = serverOptions;
+ private readonly ILogger _logger = logger;
+
+ public override string Id => "add0f6fe-258c-45c4-af74-0c165d4913cb";
+
+ public override string Name => "info";
+
+ public override string Description => "Displays running MCP server information.";
+
+ public override string Title => "Server information.";
+
+ public override ToolMetadata Metadata => new ToolMetadata
+ {
+ Destructive = false,
+ Idempotent = true,
+ OpenWorld = false,
+ ReadOnly = true,
+ LocalRequired = false,
+ Secret = false
+ };
+
+ public override Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
+ {
+ try
+ {
+ context.Response.Results = ResponseResult.Create(
+ new ServiceInfoCommandResult(
+ _serverOptions.Value.Name,
+ _serverOptions.Value.Version),
+ ServiceInfoJsonContext.Default.ServiceInfoCommandResult);
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Error obtaining server information.");
+ HandleException(context, ex);
+ }
+
+ return Task.FromResult(context.Response);
+ }
+
+ protected override EmptyOptions BindOptions(ParseResult parseResult)
+ {
+ return EmptyOptions;
+ }
+
+ internal record ServiceInfoCommandResult(string Name, string Version);
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoJsonContext.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoJsonContext.cs
new file mode 100644
index 0000000000..f4b296fa66
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceInfoJsonContext.cs
@@ -0,0 +1,12 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json.Serialization;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands;
+
+[JsonSerializable(typeof(ServiceInfoCommand.ServiceInfoCommandResult))]
+[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
+internal partial class ServiceInfoJsonContext : JsonSerializerContext
+{
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs
new file mode 100644
index 0000000000..17cf4423e8
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ServiceStartCommand.cs
@@ -0,0 +1,830 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.CommandLine.Parsing;
+using System.Diagnostics;
+using System.Net;
+using Azure.Mcp.Core.Areas.Server.Models;
+using Azure.Mcp.Core.Areas.Server.Options;
+using Azure.Mcp.Core.Commands;
+using Azure.Mcp.Core.Helpers;
+using Azure.Mcp.Core.Services.Azure;
+using Azure.Mcp.Core.Services.Azure.Authentication;
+using Azure.Mcp.Core.Services.Caching;
+using Azure.Mcp.Core.Services.Telemetry;
+using Azure.Monitor.OpenTelemetry.Exporter;
+using Microsoft.AspNetCore.Authentication.JwtBearer;
+using Microsoft.AspNetCore.Builder;
+using Microsoft.AspNetCore.Hosting;
+using Microsoft.AspNetCore.Http;
+using Microsoft.Extensions.DependencyInjection;
+using Microsoft.Extensions.Hosting;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using Microsoft.Identity.Web;
+using OpenTelemetry;
+using OpenTelemetry.Logs;
+using OpenTelemetry.Metrics;
+using OpenTelemetry.Trace;
+using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands;
+
+///
+/// Command to start the MCP server with specified configuration options.
+/// This command is hidden from the main command list.
+///
+[HiddenCommand]
+public sealed class ServiceStartCommand : BaseCommand
+{
+ private const string CommandTitle = "Start MCP Server";
+
+ ///
+ /// Gets the name of the command.
+ ///
+ public override string Name => "start";
+
+ ///
+ /// Gets the description of the command.
+ ///
+ public override string Description => "Starts Azure MCP Server.";
+
+ ///
+ /// Gets the title of the command.
+ ///
+ public override string Title => CommandTitle;
+
+ ///
+ /// Gets the metadata for this command.
+ ///
+ public override ToolMetadata Metadata => new() { Destructive = false, ReadOnly = true };
+
+ public static Action ConfigureServices { get; set; } = _ => { };
+
+ public static Func InitializeServicesAsync { get; set; } = _ => Task.CompletedTask;
+
+ public override string Id => "9953ff62-e3d7-4bdf-9b70-d569e54e3df1";
+
+ ///
+ /// Registers command options for the service start command.
+ ///
+ /// The command to register options with.
+ protected override void RegisterOptions(Command command)
+ {
+ base.RegisterOptions(command);
+ command.Options.Add(ServiceOptionDefinitions.Transport);
+ command.Options.Add(ServiceOptionDefinitions.Namespace);
+ command.Options.Add(ServiceOptionDefinitions.Mode);
+ command.Options.Add(ServiceOptionDefinitions.Tool);
+ command.Options.Add(ServiceOptionDefinitions.ReadOnly);
+ command.Options.Add(ServiceOptionDefinitions.Debug);
+ command.Options.Add(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth);
+ command.Options.Add(ServiceOptionDefinitions.InsecureDisableElicitation);
+ command.Options.Add(ServiceOptionDefinitions.OutgoingAuthStrategy);
+ command.Validators.Add(commandResult =>
+ {
+ string transport = ResolveTransport(commandResult);
+ bool httpIncomingAuthDisabled = commandResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth);
+ ValidateMode(commandResult.GetValueOrDefault(ServiceOptionDefinitions.Mode), commandResult);
+ ValidateTransportConfiguration(transport, httpIncomingAuthDisabled, commandResult);
+ ValidateNamespaceAndToolMutualExclusion(
+ commandResult.GetValueOrDefault(ServiceOptionDefinitions.Namespace.Name),
+ commandResult.GetValueOrDefault(ServiceOptionDefinitions.Tool.Name),
+ commandResult);
+ ValidateOutgoingAuthStrategy(commandResult);
+ });
+ }
+
+ ///
+ /// Binds the parsed command line arguments to the ServiceStartOptions object.
+ ///
+ /// The parsed command line arguments.
+ /// A configured ServiceStartOptions instance.
+ protected override ServiceStartOptions BindOptions(ParseResult parseResult)
+ {
+ var mode = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Mode.Name);
+ var tools = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Tool.Name);
+
+ // When --tool switch is used, automatically change the mode to "all"
+ if (tools != null && tools.Length > 0)
+ {
+ mode = ModeTypes.All;
+ }
+
+ var outgoingAuthStrategy = ResolveAuthStrategy(parseResult);
+
+ var options = new ServiceStartOptions
+ {
+ Transport = ResolveTransport(parseResult),
+ Namespace = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Namespace.Name),
+ Mode = mode,
+ Tool = tools,
+ ReadOnly = parseResult.GetValueOrDefault(ServiceOptionDefinitions.ReadOnly.Name),
+ Debug = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Debug.Name),
+ DangerouslyDisableHttpIncomingAuth = parseResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth.Name),
+ InsecureDisableElicitation = parseResult.GetValueOrDefault(ServiceOptionDefinitions.InsecureDisableElicitation.Name),
+ OutgoingAuthStrategy = outgoingAuthStrategy
+ };
+ return options;
+ }
+
+ ///
+ /// Executes the service start command, creating and starting the MCP server.
+ ///
+ /// The command execution context.
+ /// The parsed command options.
+ /// A command response indicating the result of the operation.
+ public override async Task ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
+ {
+ if (!Validate(parseResult.CommandResult, context.Response).IsValid)
+ {
+ return context.Response;
+ }
+
+ var options = BindOptions(parseResult);
+
+ // Update the UserAgentPolicy for all Azure service calls to include the transport type.
+ var transport = string.IsNullOrEmpty(options.Transport) ? TransportTypes.StdIo : options.Transport;
+ BaseAzureService.InitializeUserAgentPolicy(transport);
+
+ try
+ {
+ using var tracerProvider = AddIncomingAndOutgoingHttpSpans(options);
+
+ using var host = CreateHost(options);
+
+ await InitializeServicesAsync(host.Services);
+
+ await host.StartAsync(cancellationToken);
+
+ var telemetryService = host.Services.GetRequiredService();
+ LogStartTelemetry(telemetryService, options);
+
+ await host.WaitForShutdownAsync(cancellationToken);
+
+ return context.Response;
+ }
+ catch (Exception ex)
+ {
+ HandleException(context, ex);
+ return context.Response;
+ }
+ }
+
+ internal static void LogStartTelemetry(ITelemetryService telemetryService, ServiceStartOptions options)
+ {
+ using var activity = telemetryService.StartActivity(ActivityName.ServerStarted);
+
+ if (activity != null)
+ {
+ activity.SetTag(TagName.Transport, options.Transport);
+ activity.SetTag(TagName.ServerMode, options.Mode);
+ activity.SetTag(TagName.IsReadOnly, options.ReadOnly);
+ activity.SetTag(TagName.InsecureDisableElicitation, options.InsecureDisableElicitation);
+ activity.SetTag(TagName.DangerouslyDisableHttpIncomingAuth, options.DangerouslyDisableHttpIncomingAuth);
+ activity.SetTag(TagName.IsDebug, options.Debug);
+
+ if (options.Namespace != null && options.Namespace.Length > 0)
+ {
+ activity.SetTag(TagName.Namespace, string.Join(",", options.Namespace));
+ }
+ if (options.Tool != null && options.Tool.Length > 0)
+ {
+ activity.SetTag(TagName.Tool, string.Join(",", options.Tool));
+ }
+ }
+ }
+
+ ///
+ /// Validates if the provided mode is a valid mode type.
+ ///
+ /// The mode to validate.
+ /// Command result to update on failure.
+ private static void ValidateMode(string? mode, CommandResult commandResult)
+ {
+ if (mode == ModeTypes.SingleToolProxy ||
+ mode == ModeTypes.NamespaceProxy ||
+ mode == ModeTypes.All ||
+ mode == ModeTypes.ConsolidatedProxy)
+ {
+ return; // Success
+ }
+
+ commandResult.AddError($"Invalid mode '{mode}'. Valid modes are: {ModeTypes.SingleToolProxy}, {ModeTypes.NamespaceProxy}, {ModeTypes.All}, {ModeTypes.ConsolidatedProxy}.");
+ }
+
+ ///
+ /// Validates the transport configuration, ensuring the transport type is valid and compatible with other options.
+ /// Verifies that HTTP transport is only used when available (ENABLE_HTTP), and that --dangerously-disable-http-incoming-auth
+ /// is only specified with HTTP transport.
+ ///
+ /// The transport to validate.
+ /// Whether HTTP incoming authentication is disabled.
+ /// Command result to update on failure.
+ private static void ValidateTransportConfiguration(string transport, bool httpIncomingAuthDisabled, CommandResult commandResult)
+ {
+ if (transport == TransportTypes.StdIo)
+ {
+ if (httpIncomingAuthDisabled)
+ {
+ commandResult.AddError($"The --dangerously-disable-http-incoming-auth option cannot be used with the {TransportTypes.StdIo} transport. To use this option, specify {TransportTypes.Http} transport with --transport http.");
+ }
+ return;
+ }
+
+ if (transport == TransportTypes.Http)
+ {
+#if ENABLE_HTTP
+ return;
+#else
+ commandResult.AddError($"{TransportTypes.Http} transport is only supported in the Docker image distribution of Azure MCP Server. Please use the Docker image or switch to {TransportTypes.StdIo} transport.");
+ return;
+#endif
+ }
+
+ commandResult.AddError($"Invalid transport '{transport}'. Valid transports are: {TransportTypes.StdIo}, {TransportTypes.Http}.");
+ }
+
+ ///
+ /// Validates that --namespace and --tool options are not used together.
+ ///
+ /// The namespace values.
+ /// The tool values.
+ /// Command result to update on failure.
+ private static void ValidateNamespaceAndToolMutualExclusion(string[]? namespaces, string[]? tools, CommandResult commandResult)
+ {
+ bool hasNamespace = namespaces != null && namespaces.Length > 0;
+ bool hasTool = tools != null && tools.Length > 0;
+
+ if (hasNamespace && hasTool)
+ {
+ commandResult.AddError("The --namespace and --tool options cannot be used together. Please specify either --namespace to filter by service namespace or --tool to filter by specific tool names, but not both.");
+ }
+ }
+
+ ///
+ /// Validates that the outgoing authentication strategy is compatible with the hosting mode.
+ ///
+ /// Command result to update on failure.
+ private static void ValidateOutgoingAuthStrategy(CommandResult commandResult)
+ {
+ var outgoingAuthStrategy = commandResult.GetValueOrDefault(ServiceOptionDefinitions.OutgoingAuthStrategy.Name);
+ if (outgoingAuthStrategy == OutgoingAuthStrategy.UseOnBehalfOf)
+ {
+#if ENABLE_HTTP
+ string transport = ResolveTransport(commandResult);
+ bool httpIncomingAuthDisabled = commandResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth);
+
+ if (transport != TransportTypes.Http || httpIncomingAuthDisabled)
+ {
+ commandResult.AddError($"The {OutgoingAuthStrategy.UseOnBehalfOf} outgoing authentication strategy requires the server to run in authenticated HTTP mode (--transport http without --{ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuthName}).");
+ }
+ return;
+#else
+ commandResult.AddError($"{OutgoingAuthStrategy.UseOnBehalfOf} outgoing authentication strategy is only supported in the Docker image distribution of Azure MCP Server. " +
+ "Please use the Docker image or switch to a different outgoing authentication strategy.");
+#endif
+ }
+ }
+
+ ///
+ /// Provides custom error messages for specific exception types to improve user experience.
+ ///
+ /// The exception to format an error message for.
+ /// A user-friendly error message.
+ protected override string GetErrorMessage(Exception ex) => ex switch
+ {
+ ArgumentException argEx when argEx.Message.Contains("Invalid transport") =>
+ "Invalid transport option specified. Use --transport stdio for the supported transport mechanism.",
+ ArgumentException argEx when argEx.Message.Contains("Invalid mode") =>
+ "Invalid mode option specified. Use --mode single, namespace, or all for the supported modes.",
+ ArgumentException argEx when argEx.Message.Contains("--namespace and --tool options cannot be used together") =>
+ "Configuration error: The --namespace and --tool options are mutually exclusive. Use either one or the other to filter available tools.",
+ ArgumentException argEx when argEx.Message.Contains($"{OutgoingAuthStrategy.UseOnBehalfOf} outgoing authentication strategy") =>
+ $"Configuration error: The {OutgoingAuthStrategy.UseOnBehalfOf} authentication strategy requires the server to run in authenticated HTTP mode (--transport http without --{ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuthName}).",
+ InvalidOperationException invOpEx when invOpEx.Message.Contains("Using --dangerously-disable-http-incoming-auth") =>
+ "Configuration error to disable incoming HTTP authentication. Ensure proper authentication is configured with Managed Identity or Workload Identity.",
+ _ => base.GetErrorMessage(ex)
+ };
+
+ ///
+ /// Creates the host for the MCP server with the specified options.
+ ///
+ /// The server configuration options.
+ /// An IHost instance configured for the MCP server.
+ private IHost CreateHost(ServiceStartOptions serverOptions)
+ {
+#if ENABLE_HTTP
+ if (serverOptions.Transport == TransportTypes.Http)
+ {
+ if (serverOptions.DangerouslyDisableHttpIncomingAuth)
+ {
+ return CreateIncomingAuthDisabledHttpHost(serverOptions);
+ }
+ else
+ {
+ return CreateHttpHost(serverOptions);
+ }
+ }
+ else
+ {
+ return CreateStdioHost(serverOptions);
+ }
+#else
+ return CreateStdioHost(serverOptions);
+#endif
+ }
+
+ ///
+ /// Creates a host for STDIO transport.
+ ///
+ /// The server configuration options.
+ /// An IHost instance configured for STDIO transport.
+ private IHost CreateStdioHost(ServiceStartOptions serverOptions)
+ {
+ return Host.CreateDefaultBuilder()
+ .ConfigureLogging(logging =>
+ {
+ logging.ClearProviders();
+ logging.ConfigureOpenTelemetryLogger();
+ logging.AddEventSourceLogger();
+
+ if (serverOptions.Debug)
+ {
+ // Configure console logger to emit Debug+ to stderr so tests can capture logs from StandardError
+ logging.AddConsole(options =>
+ {
+ options.LogToStandardErrorThreshold = LogLevel.Debug;
+ options.FormatterName = Microsoft.Extensions.Logging.Console.ConsoleFormatterNames.Simple;
+ });
+ logging.AddSimpleConsole(simple =>
+ {
+ simple.ColorBehavior = Microsoft.Extensions.Logging.Console.LoggerColorBehavior.Disabled;
+ simple.IncludeScopes = false;
+ simple.SingleLine = true;
+ simple.TimestampFormat = "[HH:mm:ss] ";
+ });
+ logging.AddFilter("Microsoft.Extensions.Logging.Console.ConsoleLoggerProvider", LogLevel.Debug);
+ logging.SetMinimumLevel(LogLevel.Debug);
+ }
+ })
+ .ConfigureServices(services =>
+ {
+ // Configure the outgoing authentication strategy.
+ services.AddSingleIdentityTokenCredentialProvider();
+
+ ConfigureServices(services);
+ ConfigureMcpServer(services, serverOptions);
+ })
+ .Build();
+ }
+
+ ///
+ /// Creates a host for HTTP transport.
+ ///
+ /// The server configuration options.
+ /// An IHost instance configured for HTTP transport.
+ private IHost CreateHttpHost(ServiceStartOptions serverOptions)
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+
+ // Configure logging
+ builder.Logging.ClearProviders();
+ builder.Logging.ConfigureOpenTelemetryLogger();
+ builder.Logging.AddEventSourceLogger();
+ builder.Logging.AddConsole();
+
+ IServiceCollection services = builder.Services;
+
+ // Configure outgoing and incoming authentication and authorization.
+ //
+ // Configure incoming authentication and authorization.
+ MicrosoftIdentityWebApiAuthenticationBuilderWithConfiguration authBuilder = services
+ .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
+ .AddMicrosoftIdentityWebApi(builder.Configuration);
+
+ // Configure incoming auth JWT Bearer events for OAuth protected resource metadata.
+ services.Configure(JwtBearerDefaults.AuthenticationScheme, options =>
+ {
+ options.Events = new JwtBearerEvents
+ {
+ OnChallenge = context =>
+ {
+ // Add resource_metadata parameter to WWW-Authenticate header
+ if (!context.Response.HasStarted)
+ {
+ HttpRequest request = context.Request;
+ string resourceMetadataUrl = $"{request.Scheme}://{request.Host}/.well-known/oauth-protected-resource";
+
+ // Modify the WWW-Authenticate header to include resource_metadata
+ context.Response.Headers.WWWAuthenticate =
+ $"Bearer realm=\"{request.Host}\", resource_metadata=\"{resourceMetadataUrl}\"";
+ }
+ return Task.CompletedTask;
+ }
+ };
+ });
+
+ // Configure authorization policy for MCP access.
+ services.AddAuthorizationBuilder()
+ .SetFallbackPolicy(null)
+ .AddPolicy("McpAccess", policy =>
+ {
+ policy.RequireAuthenticatedUser();
+
+ // Naming conventions used based on well-known Microsoft services, like MS Graph:
+ // - Scopes for delegated permissions: Mcp.Tools.Verb
+ // - App roles for application permissions: Mcp.Tools.Verb.All
+ // As of Oct 2025, we only have ReadWrite as a verb, but this can be extended
+ // in the future as needed. Other scenarios that aren't "MCP" or "Tools" can
+ // also be added in the future as they become relevant.
+ policy.RequireScopeOrAppPermission(
+ allowedScopeValues: ["Mcp.Tools.ReadWrite"],
+ allowedAppPermissionValues: ["Mcp.Tools.ReadWrite.All"]);
+ });
+
+ // Configure outgoing authentication strategy
+ if (serverOptions.OutgoingAuthStrategy == OutgoingAuthStrategy.UseOnBehalfOf)
+ {
+ services.AddHttpOnBehalfOfTokenCredentialProvider(authBuilder);
+ }
+ else
+ {
+ services.AddSingleIdentityTokenCredentialProvider();
+ }
+
+ // Add a multi-user, HTTP context-aware caching strategy to isolate cache entries.
+ services.AddHttpServiceCacheService();
+
+
+ // Configure non-MCP controllers/endpoints/routes/etc.
+ services.AddHealthChecks();
+
+ // Configure CORS
+ // We're allowing all origins, methods, and headers to support any web
+ // browser clients.
+ // Non-browser clients are unaffected by CORS.
+ services.AddCors(options =>
+ {
+ options.AddPolicy("AllowAll", policy =>
+ {
+ policy.AllowAnyOrigin()
+ .AllowAnyMethod()
+ .AllowAnyHeader();
+ });
+ });
+
+ // Configure services
+ ConfigureServices(services); // Our static callback hook
+ ConfigureMcpServer(services, serverOptions);
+
+ WebApplication app = builder.Build();
+
+ UseHttpsRedirectionIfEnabled(app);
+
+ // Configure middleware pipeline
+ app.UseCors("AllowAll");
+ app.UseRouting();
+
+ // Add OAuth protected resource metadata middleware
+ //
+ app.Use(async (context, next) =>
+ {
+ if (context.Request.Path == "/.well-known/oauth-protected-resource" &&
+ context.Request.Method == "GET")
+ {
+ IOptionsMonitor azureAdOptionsMonitor = context
+ .RequestServices
+ .GetRequiredService>();
+ MicrosoftIdentityOptions azureAdOptions = azureAdOptionsMonitor.Get(JwtBearerDefaults.AuthenticationScheme);
+ HttpRequest request = context.Request;
+ string baseUrl = $"{request.Scheme}://{request.Host}";
+ string? clientId = azureAdOptions.ClientId;
+ string? tenantId = azureAdOptions.TenantId;
+ string instance = azureAdOptions.Instance?.TrimEnd('/') ?? "https://login.microsoftonline.com";
+
+ var metadata = new OAuthProtectedResourceMetadata
+ {
+ Resource = baseUrl,
+ AuthorizationServers = [$"{instance}/{tenantId}/v2.0"],
+
+ // Only delegated permissions for user principal authorization is listed here.
+ // Client with users send these scopes to the identity platform to acquire an
+ // access token.
+ // However, special to Entra, service principals are expected to always
+ // request the special `app-id/.default` scope because service principals use
+ // app roles/app permissions instead of scopes. At time of writing (Oct 2025),
+ // we don't solve this problem here. Instead we expect any service principal
+ // clients to be hardcoded to use the `app-id/.default` scope when requesting
+ // access tokens for our endpoint and for the owners of the client and MCP
+ // server's service principals to ensure the necessary app roles are assigned
+ // upfront.
+ ScopesSupported = [$"{clientId}/Mcp.Tools.ReadWrite"],
+ BearerMethodsSupported = ["header"],
+
+ // Intentionally pointing to MCP repo for documentation. Could eventually
+ // have a dedicated usage doc page, potentially provided by this service itself.
+ ResourceDocumentation = "https://github.com/Microsoft/mcp"
+ };
+
+ context.Response.ContentType = "application/json";
+ await JsonSerializer.SerializeAsync(
+ context.Response.Body,
+ metadata,
+ OAuthMetadataJsonContext.Default.OAuthProtectedResourceMetadata);
+ return;
+ }
+
+ await next(context);
+ });
+
+ // AuthN/Z are always required in the remote HTTP service scenario.
+ app.UseAuthentication();
+ app.UseAuthorization();
+
+ IEndpointConventionBuilder mcpEndpointBuilder = app.MapMcp();
+ // All MCP endpoints require MCP.All scope or role
+ mcpEndpointBuilder.RequireAuthorization("McpAccess");
+
+ // Map non-MCP endpoints.
+ // Health checks are anonymous (no authentication required)
+ app.MapHealthChecks("/health")
+ .AllowAnonymous();
+
+ return app;
+ }
+
+ ///
+ /// Creates a host for HTTP transport without incoming authentication.
+ ///
+ /// The server configuration options.
+ /// An IHost instance configured for HTTP transport.
+ private IHost CreateIncomingAuthDisabledHttpHost(ServiceStartOptions serverOptions)
+ {
+ WebApplicationBuilder builder = WebApplication.CreateBuilder();
+
+ InitializeListingUrls(builder, serverOptions);
+
+ // Configure logging
+ builder.Logging.ClearProviders();
+ builder.Logging.ConfigureOpenTelemetryLogger();
+ builder.Logging.AddEventSourceLogger();
+ builder.Logging.AddConsole();
+
+ IServiceCollection services = builder.Services;
+
+ // Configure single identity token credential provider for outgoing authentication
+ services.AddSingleIdentityTokenCredentialProvider();
+
+ // Configure CORS
+ // We're allowing all origins, methods, and headers to support any web
+ // browser clients.
+ // Non-browser clients are unaffected by CORS.
+ services.AddCors(options =>
+ {
+ options.AddPolicy("AllowAll", policy =>
+ {
+ policy.AllowAnyOrigin()
+ .AllowAnyMethod()
+ .AllowAnyHeader();
+ });
+ });
+
+ // Configure services
+ ConfigureServices(services); // Our static callback hook
+ ConfigureMcpServer(services, serverOptions);
+
+ // We still use the multi-user, HTTP context-aware caching strategy here
+ // because we don't yet know what security model we want for this "insecure" mode.
+ // As a positive, it gives some isolation locally, but that's not a
+ // design strategy we've fully vetted or endorsed.
+ services.AddHttpServiceCacheService();
+
+ WebApplication app = builder.Build();
+
+ UseHttpsRedirectionIfEnabled(app);
+
+ // Configure middleware pipeline
+ app.UseCors("AllowAll");
+ app.UseRouting();
+ app.MapMcp();
+
+ return app;
+ }
+
+ ///
+ /// Configures the MCP server services.
+ ///
+ /// The service collection to configure.
+ /// The server configuration options.
+ private static void ConfigureMcpServer(IServiceCollection services, ServiceStartOptions options)
+ {
+ services.AddAzureMcpServer(options);
+ }
+
+ ///
+ /// Initializes the URL for ASP.NET Core to bind to.
+ ///
+ private static void InitializeListingUrls(WebApplicationBuilder builder, ServiceStartOptions options)
+ {
+ if (!options.DangerouslyDisableHttpIncomingAuth)
+ {
+ // When running in secured HTTP mode, allow the standard IConfiguration binding to handle
+ // the ASPNETCORE_URLS value without any additional validation.
+ return;
+ }
+
+ string url = Environment.GetEnvironmentVariable("ASPNETCORE_URLS") ?? "http://127.0.0.1:5001";
+
+ if (url.Contains(';'))
+ {
+ throw new InvalidOperationException("Multiple endpoints in ASPNETCORE_URLS are not supported. Provide a single URL.");
+ }
+
+ if (!Uri.TryCreate(url, UriKind.Absolute, out var uri))
+ {
+ throw new InvalidOperationException($"Invalid URL: '{url}'");
+ }
+
+ var scheme = uri.Scheme.ToLowerInvariant();
+ if (scheme is not ("http" or "https"))
+ {
+ throw new InvalidOperationException($"Unsupported scheme '{scheme}' in URL '{url}'.");
+ }
+
+ // loopback IP: 127.0.0.0/8 range, IPv6 (::1) and localhost when resolved to loopback addresses.
+ bool isLoopback = uri.IsLoopback;
+
+ // All interfaces, allowed only with ALLOW_INSECURE_EXTERNAL_BINDING opt-in.
+ bool isWildcard = uri.Host is "*" or "+" or "0.0.0.0" or "::" || (IPAddress.TryParse(uri.Host, out var anyIp) && (anyIp.Equals(IPAddress.Any) || anyIp.Equals(IPAddress.IPv6Any)));
+
+ if (!isLoopback && !isWildcard)
+ {
+ throw new InvalidOperationException($"Explicit external binding is not supported for '{url}'.");
+ }
+
+ if (isWildcard && !EnvironmentHelpers.GetEnvironmentVariableAsBool("ALLOW_INSECURE_EXTERNAL_BINDING"))
+ {
+ throw new InvalidOperationException(
+ $"External binding blocked for '{url}'. " +
+ $"Set ALLOW_INSECURE_EXTERNAL_BINDING=true if you intentionally want to bind beyond loopback.");
+ }
+
+ builder.WebHost.UseUrls(url);
+ }
+
+ ///
+ /// Resolves the service mode and outgoing authentication strategy based on parsed command line options, applying appropriate defaults.
+ ///
+ /// The parsed command line arguments.
+ /// A tuple containing whether to run as remote HTTP service and the outgoing auth strategy.
+ private static OutgoingAuthStrategy ResolveAuthStrategy(ParseResult parseResult)
+ {
+#if ENABLE_HTTP
+ var outgoingAuthStrategy = parseResult.GetValueOrDefault(ServiceOptionDefinitions.OutgoingAuthStrategy.Name);
+ if (outgoingAuthStrategy == OutgoingAuthStrategy.NotSet)
+ {
+ string transport = ResolveTransport(parseResult);
+ if (transport == TransportTypes.Http)
+ {
+ bool httpIncomingAuthDisabled = parseResult.GetValueOrDefault(ServiceOptionDefinitions.DangerouslyDisableHttpIncomingAuth.Name);
+ return httpIncomingAuthDisabled
+ ? OutgoingAuthStrategy.UseHostingEnvironmentIdentity
+ : OutgoingAuthStrategy.UseOnBehalfOf;
+ }
+ else
+ {
+ return OutgoingAuthStrategy.UseHostingEnvironmentIdentity;
+ }
+ }
+ return outgoingAuthStrategy;
+#else
+ return OutgoingAuthStrategy.UseHostingEnvironmentIdentity;
+#endif
+ }
+
+ ///
+ /// Resolves the transport type from parsed command line arguments, defaulting to STDIO if not specified.
+ ///
+ /// The parsed command line arguments.
+ /// The transport type string (stdio or http).
+ private static string ResolveTransport(ParseResult parseResult)
+ {
+ return parseResult.GetValueOrDefault(ServiceOptionDefinitions.Transport.Name) ?? TransportTypes.StdIo;
+ }
+
+ ///
+ /// Resolves the transport type from command result, defaulting to STDIO if not specified.
+ ///
+ /// The command result to extract transport from.
+ /// The transport type string (stdio or http).
+ private static string ResolveTransport(CommandResult commandResult)
+ {
+ return commandResult.GetValueOrDefault(ServiceOptionDefinitions.Transport.Name) ?? TransportTypes.StdIo;
+ }
+
+ private static WebApplication UseHttpsRedirectionIfEnabled(WebApplication app)
+ {
+ // Some hosting environments may not need HTTPS redirection, such as:
+ // - Running behind a reverse proxy that handles TLS termination.
+ // - Local development when not using self-signed development certs.
+ // - The application or server's HTTP stack is not listening for non-HTTPS requests.
+ //
+ // Safe default to enable HTTPS redirection unless explicitly opted-out.
+ string? httpsRedirectionOptOut = Environment.GetEnvironmentVariable("AZURE_MCP_DANGEROUSLY_DISABLE_HTTPS_REDIRECTION");
+ if (!bool.TryParse(httpsRedirectionOptOut, out bool isOptedOut) || !isOptedOut)
+ {
+ app.UseHttpsRedirection();
+ }
+
+ return app;
+ }
+
+ ///
+ /// Configures incoming and outgoing HTTP spans for self-hosted HTTP mode with Azure Monitor exporter.
+ ///
+ /// The server configuration options.
+ ///
+ /// A instance if telemetry is enabled and properly configured for HTTP transport;
+ /// otherwise, null.
+ ///
+ ///
+ /// Telemetry is only configured when:
+ ///
+ /// - The transport is HTTP (not STDIO)
+ /// - AZURE_MCP_COLLECT_TELEMETRY is not explicitly set to false
+ /// - APPLICATIONINSIGHTS_CONNECTION_STRING environment variable is set
+ ///
+ /// The tracer provider includes ASP.NET Core and HttpClient instrumentation with filtering
+ /// to avoid duplicate spans and telemetry loops.
+ /// This telemetry configuration is intended for self-hosted scenarios where
+ /// the MCP server is running in HTTP mode. This creates an independent telemetry pipeline using TracerProvider to export
+ /// traces to user-configured Application Insights instance only when the necessary environment variables are set. This also honors
+ /// the AZURE_MCP_COLLECT_TELEMETRY environment variable to allow users to disable telemetry collection if desired. Note that this is
+ /// in addition to the telemetry configured in .
+ ///
+ private static TracerProvider? AddIncomingAndOutgoingHttpSpans(ServiceStartOptions options)
+ {
+ if (options.Transport != TransportTypes.Http)
+ {
+ return null;
+ }
+
+ string? collectTelemetry = Environment.GetEnvironmentVariable("AZURE_MCP_COLLECT_TELEMETRY");
+ bool isTelemetryEnabled = string.IsNullOrWhiteSpace(collectTelemetry) ||
+ (bool.TryParse(collectTelemetry, out bool shouldCollectTelemetry) && shouldCollectTelemetry);
+
+ string? connectionString = Environment.GetEnvironmentVariable("APPLICATIONINSIGHTS_CONNECTION_STRING");
+ if (!isTelemetryEnabled || string.IsNullOrWhiteSpace(connectionString))
+ {
+ return null;
+ }
+
+ return Sdk.CreateTracerProviderBuilder()
+ // captures incoming HTTP requests
+ .AddAspNetCoreInstrumentation()
+ // captures outgoing HTTP requests with filtering
+ .AddHttpClientInstrumentation(o => o.FilterHttpRequestMessage = ShouldInstrumentHttpRequest)
+ .AddAzureMonitorTraceExporter(exporterOptions => exporterOptions.ConnectionString = connectionString)
+ .Build();
+ }
+
+ ///
+ /// Determines whether an HTTP request should be instrumented for telemetry collection.
+ ///
+ /// The HTTP request message to evaluate.
+ ///
+ /// true if the request should be instrumented; otherwise, false.
+ ///
+ ///
+ /// This method filters out specific requests to prevent telemetry issues:
+ ///
+ /// - Application Insights ingestion endpoints (to avoid telemetry loops)
+ /// - Requests where the parent span is from Azure SDK (to avoid duplicate spans)
+ ///
+ ///
+ private static bool ShouldInstrumentHttpRequest(HttpRequestMessage request)
+ {
+ // Exclude Application Insights ingestion requests to skip requests that are made to AppInsights when sending telemetry.
+ // See related issue - https://github.com/Azure/azure-sdk-for-net/issues/45366#issuecomment-2278511391
+ if (request.RequestUri?.AbsoluteUri.Contains("applicationinsights.azure.com/v2.1/track", StringComparison.Ordinal) == true)
+ {
+ return false;
+ }
+
+ // **NOTE**: This check is copied from the UseAzureMonitor extension method in the Azure SDK repository:
+ // https://github.com/Azure/azure-sdk-for-net/blob/242ba3eca16d914522669ae62baac7437bf71db8/sdk/monitor/Azure.Monitor.OpenTelemetry.AspNetCore/src/OpenTelemetryBuilderExtensions.cs#L98-L108
+ // The decision to filter these out is not finalized for the product. We may revisit this in the future depending on
+ // how users want to see telemetry from Azure SDK calls made by the MCP server.
+
+ // Azure SDKs create their own client span before calling the service using HttpClient.
+ // To prevent duplicate spans (Azure SDK + HttpClient), filter HttpClient spans when
+ // the parent span is from Azure SDK, as it contains all relevant information.
+ Activity? parentActivity = Activity.Current?.Parent;
+ if (parentActivity?.Source.Name == "Azure.Core.Http")
+ {
+ return false;
+ }
+ return true;
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs
new file mode 100644
index 0000000000..a929df93d8
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/BaseToolLoader.cs
@@ -0,0 +1,231 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using Azure.Mcp.Core.Models.Elicitation;
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading;
+
+///
+/// Base class for tool loaders that provides common functionality including disposal patterns.
+///
+/// Logger instance for this tool loader.
+public abstract class BaseToolLoader(ILogger logger) : IToolLoader
+{
+ ///
+ /// Logger instance for this tool loader.
+ ///
+ protected readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
+
+ ///
+ /// Cached empty JSON object to avoid repeated parsing.
+ ///
+ protected static readonly JsonElement EmptyJsonObject;
+
+ static BaseToolLoader()
+ {
+ using (var jsonDoc = JsonDocument.Parse("{}"))
+ {
+ EmptyJsonObject = jsonDoc.RootElement.Clone();
+ }
+ }
+
+ private bool _disposed = false;
+
+ ///
+ /// Handles requests to list all tools available in the MCP server.
+ ///
+ /// The request context containing metadata and parameters.
+ /// A token to monitor for cancellation requests.
+ /// A result containing the list of available tools.
+ public abstract ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken);
+
+ ///
+ /// Handles requests to call a specific tool with the provided parameters.
+ ///
+ /// The request context containing the tool name and parameters.
+ /// A token to monitor for cancellation requests.
+ /// A result containing the output of the tool invocation.
+ public abstract ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken);
+
+ ///
+ /// Extracts the "parameters" JsonElement from the tool call request arguments.
+ ///
+ /// The request context containing the tool call parameters.
+ ///
+ /// The "parameters" JsonElement if it exists and is a valid JSON object;
+ /// otherwise, returns an empty JSON object.
+ ///
+ protected static JsonElement GetParametersJsonElement(RequestContext request)
+ {
+ IReadOnlyDictionary? args = request.Params?.Arguments;
+ if (args != null && args.TryGetValue("parameters", out var parametersElem) && parametersElem.ValueKind == JsonValueKind.Object)
+ {
+ return parametersElem;
+ }
+
+ return EmptyJsonObject;
+ }
+
+ ///
+ /// Extracts the "parameters" object from the tool call request arguments and converts it to a dictionary.
+ ///
+ /// The request context containing the tool call parameters.
+ ///
+ /// A dictionary containing the parameter names and values if the "parameters" object exists and is valid;
+ /// otherwise, returns an empty dictionary.
+ ///
+ protected static Dictionary GetParametersDictionary(RequestContext request)
+ {
+ JsonElement parametersElem = GetParametersJsonElement(request);
+ return parametersElem.EnumerateObject().ToDictionary(prop => prop.Name, prop => (object?)prop.Value);
+ }
+
+ ///
+ /// Disposes resources owned by this tool loader with double disposal protection.
+ ///
+ public async ValueTask DisposeAsync()
+ {
+ if (_disposed)
+ {
+ return;
+ }
+
+ try
+ {
+ await DisposeAsyncCore();
+ }
+ catch (Exception ex)
+ {
+ // Log disposal failures but don't throw - we want to ensure cleanup continues
+ // Individual disposal errors shouldn't stop the overall disposal process
+ _logger.LogError(ex, "Error occurred while disposing tool loader {LoaderType}. Some resources may not have been properly disposed.", GetType().Name);
+ }
+ finally
+ {
+ _disposed = true;
+ }
+ }
+
+ ///
+ /// Override this method in derived classes to implement disposal logic.
+ /// This method is called exactly once during disposal.
+ ///
+ /// A task representing the asynchronous disposal operation.
+ protected virtual ValueTask DisposeAsyncCore()
+ {
+ // Default implementation does nothing
+ return ValueTask.CompletedTask;
+ }
+
+ protected McpClientOptions CreateClientOptions(McpServer server)
+ {
+ McpClientHandlers handlers = new();
+
+ if (server.ClientCapabilities?.Sampling != null)
+ {
+ handlers.SamplingHandler = (request, progress, token) =>
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ return server.SampleAsync(request, token);
+ };
+ }
+
+ if (server.ClientCapabilities?.Elicitation != null)
+ {
+ handlers.ElicitationHandler = (request, token) =>
+ {
+ ArgumentNullException.ThrowIfNull(request);
+ return server.ElicitAsync(request, token);
+ };
+ }
+
+ var clientOptions = new McpClientOptions
+ {
+ ClientInfo = server.ClientInfo,
+ Handlers = handlers
+ };
+
+ return clientOptions;
+ }
+
+ ///
+ /// Handles elicitation for commands that access sensitive data.
+ /// If elicitation is disabled or not supported, returns appropriate error result.
+ ///
+ /// The request context containing the MCP server.
+ /// The name of the tool being invoked.
+ /// Whether elicitation has been disabled via insecure option.
+ /// Logger instance for recording elicitation events.
+ /// Cancellation token for the operation.
+ ///
+ /// Null if elicitation was accepted or bypassed (operation should proceed).
+ /// A CallToolResult with IsError=true if elicitation was rejected or failed (operation should not proceed).
+ ///
+ protected static async Task HandleSecretElicitationAsync(
+ RequestContext request,
+ string toolName,
+ bool insecureDisableElicitation,
+ ILogger logger,
+ CancellationToken cancellationToken)
+ {
+ // Check if elicitation is disabled by insecure option
+ if (insecureDisableElicitation)
+ {
+ logger.LogWarning("Tool '{Tool}' handles sensitive data but elicitation is disabled via --insecure-disable-elicitation. Proceeding without user consent (INSECURE).", toolName);
+ return null;
+ }
+
+ // If client doesn't support elicitation, treat as rejected and don't execute
+ if (!request.Server.SupportsElicitation())
+ {
+ logger.LogWarning("Tool '{Tool}' handles sensitive data but client does not support elicitation. Operation rejected.", toolName);
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = "This tool handles sensitive data and requires user consent, but the client does not support elicitation. Operation rejected for security." }],
+ IsError = true
+ };
+ }
+
+ try
+ {
+ logger.LogInformation("Tool '{Tool}' handles sensitive data. Requesting user confirmation via elicitation.", toolName);
+
+ // Create the elicitation request using our custom model
+ var elicitationRequest = new ElicitationRequestParams
+ {
+ Message = $"⚠️ SECURITY WARNING: The tool '{toolName}' may expose secrets or sensitive information.\n\nThis operation could reveal confidential data such as passwords, API keys, certificates, or other sensitive values.\n\nDo you want to continue with this potentially sensitive operation?"
+ };
+
+ // Use our extension method to handle the elicitation
+ var elicitationResponse = await request.Server.RequestElicitationAsync(elicitationRequest, cancellationToken);
+
+ if (elicitationResponse.Action != ElicitationAction.Accept)
+ {
+ logger.LogInformation("User {Action} the elicitation for tool '{Tool}'. Operation not executed.",
+ elicitationResponse.Action.ToString().ToLower(), toolName);
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = $"Operation cancelled by user ({elicitationResponse.Action.ToString().ToLower()})." }],
+ IsError = true
+ };
+ }
+
+ logger.LogInformation("User accepted elicitation for tool '{Tool}'. Proceeding with execution.", toolName);
+ return null;
+ }
+ catch (Exception ex)
+ {
+ logger.LogError(ex, "Error during elicitation for tool '{Tool}': {Error}", toolName, ex.Message);
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = $"Elicitation failed for sensitive tool '{toolName}': {ex.Message}. Operation not executed for security." }],
+ IsError = true
+ };
+ }
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs
new file mode 100644
index 0000000000..ff5b503f6d
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CommandFactoryToolLoader.cs
@@ -0,0 +1,293 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics;
+using System.Net;
+using System.Text.Json.Nodes;
+using Azure.Mcp.Core.Areas.Server.Models;
+using Azure.Mcp.Core.Commands;
+using Azure.Mcp.Core.Helpers;
+using Azure.Mcp.Core.Models.Elicitation;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Protocol;
+using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading;
+
+///
+/// A tool loader that creates MCP tools from the registered command factory.
+/// Exposes AzureMcp commands as MCP tools that can be invoked through the MCP protocol.
+///
+public sealed class CommandFactoryToolLoader(
+ IServiceProvider serviceProvider,
+ CommandFactory commandFactory,
+ IOptions options,
+ ILogger logger) : BaseToolLoader(logger)
+{
+ private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+ private readonly CommandFactory _commandFactory = commandFactory;
+ private readonly IOptions _options = options;
+ private IReadOnlyDictionary _toolCommands =
+ (options.Value.Namespace == null || options.Value.Namespace.Length == 0)
+ ? commandFactory.AllCommands
+ : commandFactory.GroupCommands(options.Value.Namespace);
+
+ public const string RawMcpToolInputOptionName = "raw-mcp-tool-input";
+
+ private static bool IsRawMcpToolInputOption(Option option)
+ {
+ if (string.Equals(NameNormalization.NormalizeOptionName(option.Name), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ foreach (var alias in option.Aliases)
+ {
+ if (string.Equals(NameNormalization.NormalizeOptionName(alias), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+
+ ///
+ /// Lists all tools available from the command factory.
+ ///
+ /// The request context containing parameters and metadata.
+ /// A cancellation token.
+ /// A result containing the list of available tools.
+ public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ var visibleCommands = CommandFactory.GetVisibleCommands(_toolCommands);
+
+ // Filter by specific tools if provided
+ if (_options.Value.Tool != null && _options.Value.Tool.Length > 0)
+ {
+ visibleCommands = visibleCommands.Where(kvp =>
+ {
+ var toolKey = kvp.Key;
+ return _options.Value.Tool.Any(tool => tool.Contains(toolKey, StringComparison.OrdinalIgnoreCase));
+ });
+ }
+
+ var tools = visibleCommands
+ .Select(kvp => GetTool(kvp.Key, kvp.Value))
+ .Where(tool => !_options.Value.ReadOnly || (tool.Annotations?.ReadOnlyHint == true))
+ .ToList();
+
+ var listToolsResult = new ListToolsResult { Tools = tools };
+
+ _logger.LogInformation("Listing {NumberOfTools} tools.", tools.Count);
+
+ return ValueTask.FromResult(listToolsResult);
+ }
+
+ ///
+ /// Handles tool calls by executing the corresponding command from the command factory.
+ ///
+ /// The request context containing parameters and metadata.
+ /// A cancellation token.
+ /// The result of the tool call operation.
+ public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ if (request.Params == null)
+ {
+ var content = new TextContentBlock
+ {
+ Text = "Cannot call tools with null parameters.",
+ };
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+
+ var toolName = request.Params.Name;
+
+ // Check if tool filtering is enabled and validate the requested tool
+ if (_options.Value.Tool != null && _options.Value.Tool.Length > 0)
+ {
+ if (!_options.Value.Tool.Any(tool => tool.Contains(toolName, StringComparison.OrdinalIgnoreCase)))
+ {
+ var content = new TextContentBlock
+ {
+ Text = $"Tool '{toolName}' is not available. This server is configured to only expose the tools: {string.Join(", ", _options.Value.Tool.Select(t => $"'{t}'"))}",
+ };
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+ }
+
+ var activity = Activity.Current;
+
+ if (activity != null)
+ {
+ activity.SetTag(TagName.ToolName, toolName);
+ }
+
+ var command = _toolCommands.GetValueOrDefault(toolName);
+ if (command == null)
+ {
+ var content = new TextContentBlock
+ {
+ Text = $"Could not find command: {toolName}",
+ };
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+ activity?.SetTag(TagName.ToolId, command.Id);
+ var commandContext = new CommandContext(_serviceProvider, activity);
+
+ // Check if this tool requires elicitation for sensitive data
+ var metadata = command.Metadata;
+ if (metadata.Secret)
+ {
+ var elicitationResult = await HandleSecretElicitationAsync(
+ request,
+ toolName,
+ _options.Value.InsecureDisableElicitation,
+ _logger,
+ cancellationToken);
+
+ if (elicitationResult != null)
+ {
+ return elicitationResult;
+ }
+ }
+
+ var realCommand = command.GetCommand();
+ ParseResult? commandOptions = null;
+
+ if (realCommand.Options.Count == 1 && IsRawMcpToolInputOption(realCommand.Options[0]))
+ {
+ commandOptions = realCommand.ParseFromRawMcpToolInput(request.Params.Arguments);
+ }
+ else
+ {
+ commandOptions = realCommand.ParseFromDictionary(request.Params.Arguments);
+ }
+
+ _logger.LogTrace("Invoking '{Tool}'.", realCommand.Name);
+
+ if (commandContext.Activity != null)
+ {
+ var serviceArea = _commandFactory.GetServiceArea(toolName);
+ commandContext.Activity.SetTag(TagName.ToolArea, serviceArea);
+ }
+
+ try
+ {
+ var commandResponse = await command.ExecuteAsync(commandContext, commandOptions, cancellationToken);
+ var jsonResponse = JsonSerializer.Serialize(commandResponse, ModelsJsonContext.Default.CommandResponse);
+ var isError = commandResponse.Status < HttpStatusCode.OK || commandResponse.Status >= HttpStatusCode.Ambiguous;
+
+ return new CallToolResult
+ {
+ Content = [
+ new TextContentBlock {
+ Text = jsonResponse
+ }
+ ],
+ IsError = isError
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "An exception occurred running '{Tool}'. ", realCommand.Name);
+
+ throw;
+ }
+ finally
+ {
+ _logger.LogTrace("Finished executing '{Tool}'.", realCommand.Name);
+ }
+ }
+
+ ///
+ /// Converts a command to an MCP tool definition.
+ ///
+ /// The full name of the command.
+ /// The command to convert.
+ /// An MCP tool definition.
+ private static Tool GetTool(string fullName, IBaseCommand command)
+ {
+ var underlyingCommand = command.GetCommand();
+ var tool = new Tool
+ {
+ Name = fullName,
+ Description = underlyingCommand.Description,
+ };
+
+ // Get tool metadata from the command's Metadata property
+ var metadata = command.Metadata;
+ tool.Annotations = new ToolAnnotations()
+ {
+ DestructiveHint = metadata.Destructive,
+ IdempotentHint = metadata.Idempotent,
+ OpenWorldHint = metadata.OpenWorld,
+ ReadOnlyHint = metadata.ReadOnly,
+ Title = command.Title,
+ };
+
+ // Add Secret metadata to tool.Meta if the property exists
+ if (metadata.Secret)
+ {
+ tool.Meta = new JsonObject
+ {
+ ["SecretHint"] = metadata.Secret
+ };
+ }
+
+ var options = command.GetCommand().Options;
+
+ var schema = new ToolInputSchema();
+
+ if (options != null && options.Count > 0)
+ {
+ if (options.Count == 1 && IsRawMcpToolInputOption(options[0]))
+ {
+ var arguments = JsonNode.Parse(options[0].Description ?? "{}") as JsonObject ?? new JsonObject();
+ tool.InputSchema = JsonSerializer.SerializeToElement(arguments, ServerJsonContext.Default.JsonObject);
+ return tool;
+ }
+ else
+ {
+ foreach (var option in options)
+ {
+ // Use the CreatePropertySchema method to properly handle array types with items
+ var propName = NameNormalization.NormalizeOptionName(option.Name);
+ schema.Properties.Add(propName, TypeToJsonTypeMapper.CreatePropertySchema(option.ValueType, option.Description));
+ }
+
+ schema.Required = [.. options.Where(p => p.Required).Select(p => NameNormalization.NormalizeOptionName(p.Name))];
+ }
+ }
+
+ tool.InputSchema = JsonSerializer.SerializeToElement(schema, ServerJsonContext.Default.ToolInputSchema);
+
+ return tool;
+ }
+
+ ///
+ /// Disposes resources owned by this tool loader.
+ /// CommandFactoryToolLoader doesn't own external resources that need disposal.
+ ///
+ protected override ValueTask DisposeAsyncCore()
+ {
+ // CommandFactoryToolLoader doesn't create or manage disposable resources
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs
new file mode 100644
index 0000000000..9659295a12
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/CompositeToolLoader.cs
@@ -0,0 +1,218 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.Extensions.Logging;
+using ModelContextProtocol.Protocol;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading;
+
+///
+/// A tool loader that combines multiple tool loaders into one.
+/// This allows aggregating tools from multiple sources and delegating tool calls to the appropriate loader.
+///
+/// The collection of tool loaders to combine.
+/// Logger for tool loading operations.
+public sealed class CompositeToolLoader(IEnumerable toolLoaders, ILogger logger) : BaseToolLoader(logger)
+{
+ private readonly IEnumerable _toolLoaders = InitializeToolLoaders(toolLoaders);
+ private readonly Dictionary _toolLoaderMap = new();
+ private readonly SemaphoreSlim _initializationSemaphore = new(1, 1);
+ private bool _isInitialized = false;
+ private List? _cachedTools;
+
+ ///
+ /// Initializes the list of tool loaders, validating that at least one is provided.
+ ///
+ /// The collection of tool loaders to initialize.
+ /// A list of initialized tool loaders.
+ /// Thrown when the toolLoaders parameter is null.
+ /// Thrown when no tool loaders are provided.
+ private static IEnumerable InitializeToolLoaders(IEnumerable toolLoaders)
+ {
+ ArgumentNullException.ThrowIfNull(toolLoaders);
+
+ var loadersList = toolLoaders.ToList();
+
+ if (loadersList.Count == 0)
+ {
+ throw new ArgumentException("At least one tool loader must be provided.", nameof(toolLoaders));
+ }
+
+ return loadersList;
+ }
+
+ ///
+ /// Lists all tools from all tool loaders and builds a mapping of tool names to their respective loaders.
+ ///
+ /// The request context containing metadata and parameters.
+ /// A token to monitor for cancellation requests.
+ /// A result containing the combined list of all available tools, or an empty list if initialization fails.
+ public override async ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ try
+ {
+ await InitializeAsync(request.Server, cancellationToken);
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.LogError(ex, "Tool loader initialization failed");
+
+ // Return empty result with error indication
+ return new ListToolsResult
+ {
+ Tools = []
+ };
+ }
+
+ // Return cached result after initialization
+ return new ListToolsResult
+ {
+ Tools = _cachedTools!
+ };
+ }
+
+ ///
+ /// Calls a tool by its name using the appropriate tool loader.
+ ///
+ /// The request context containing the tool name and parameters.
+ /// A token to monitor for cancellation requests.
+ /// A result containing the output of the tool invocation, or an error result if the tool is not found or initialization fails.
+ public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ if (request.Params == null)
+ {
+ var content = new TextContentBlock
+ {
+ Text = "Cannot call tools with null parameters.",
+ };
+
+ _logger.LogWarning(content.Text);
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+
+ // Ensure tool loader map is populated before attempting tool lookup
+ try
+ {
+ await InitializeAsync(request.Server, cancellationToken);
+ }
+ catch (InvalidOperationException ex)
+ {
+ var content = new TextContentBlock
+ {
+ Text = $"Failed to initialize tool loaders: {ex.Message}",
+ };
+
+ _logger.LogError(ex, "Tool loader initialization failed");
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+
+ if (!_toolLoaderMap.TryGetValue(request.Params.Name, out var toolCaller))
+ {
+ var content = new TextContentBlock
+ {
+ Text = $"The tool {request.Params.Name} was not found",
+ };
+
+ _logger.LogWarning(content.Text);
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+
+ return await toolCaller.CallToolHandler(request, cancellationToken);
+ }
+
+ ///
+ /// Initializes the tool loader map by discovering all tools from child loaders.
+ /// This provides thread-safe initialization using the double-checked locking pattern.
+ ///
+ /// The server context for creating list tools requests.
+ /// A token to monitor for cancellation requests.
+ /// A task representing the asynchronous operation.
+ private async Task InitializeAsync(McpServer server, CancellationToken cancellationToken)
+ {
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ await _initializationSemaphore.WaitAsync(cancellationToken);
+ try
+ {
+ // Double-check pattern: verify we're still not initialized after acquiring the lock
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ // Populate the tool loader map and cache the combined tools
+ var allTools = new List();
+
+ // Create a request for listing tools to populate the tool loader map
+ var listToolsRequest = new RequestContext(server, new() { Method = RequestMethods.ToolsList })
+ {
+ Params = new ListToolsRequestParams()
+ };
+
+ foreach (var loader in _toolLoaders)
+ {
+ var toolsResponse = await loader.ListToolsHandler(listToolsRequest, cancellationToken);
+ if (toolsResponse == null)
+ {
+ throw new InvalidOperationException("Tool loader returned null response during initialization.");
+ }
+
+ foreach (var tool in toolsResponse.Tools)
+ {
+ _toolLoaderMap[tool.Name] = loader;
+ allTools.Add(tool);
+ }
+ }
+
+ _cachedTools = allTools;
+ _isInitialized = true;
+ }
+ finally
+ {
+ _initializationSemaphore.Release();
+ }
+ }
+
+ ///
+ /// Disposes all child tool loaders and the initialization semaphore.
+ /// Uses best-effort disposal to ensure all loaders are disposed even if some fail.
+ ///
+ protected override async ValueTask DisposeAsyncCore()
+ {
+ // Dispose initialization semaphore first
+ _initializationSemaphore?.Dispose();
+
+ // Dispose all child loaders using best-effort approach
+ var childDisposalTasks = _toolLoaders.Select(async loader =>
+ {
+ try
+ {
+ await loader.DisposeAsync();
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Failed to dispose tool loader {LoaderType}", loader.GetType().Name);
+ }
+ });
+
+ await Task.WhenAll(childDisposalTasks);
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/IToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/IToolLoader.cs
new file mode 100644
index 0000000000..b7bff4db6e
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/IToolLoader.cs
@@ -0,0 +1,30 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using ModelContextProtocol.Protocol;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading;
+
+///
+/// Defines the interface for tool loaders in the MCP server.
+/// Tool loaders are responsible for discovering, listing, and invoking tools.
+///
+public interface IToolLoader : IAsyncDisposable
+{
+ ///
+ /// Handles requests to list all tools available in the MCP server.
+ ///
+ /// The request context containing metadata and parameters.
+ /// A token to monitor for cancellation requests.
+ /// A result containing the list of available tools.
+ ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken);
+
+ ///
+ /// Handles requests to call a specific tool with the provided parameters. If an error occurs while calling the
+ /// tool, loaders should return a where the contents are details of the exception.
+ ///
+ /// The request context containing the tool name and parameters.
+ /// A token to monitor for cancellation requests.
+ /// A result containing the output of the tool invocation.
+ ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken);
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs
new file mode 100644
index 0000000000..44f206643b
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/NamespaceToolLoader.cs
@@ -0,0 +1,717 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics;
+using System.Net;
+using System.Text.Json.Nodes;
+using Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+using Azure.Mcp.Core.Areas.Server.Models;
+using Azure.Mcp.Core.Areas.Server.Options;
+using Azure.Mcp.Core.Commands;
+using Azure.Mcp.Core.Helpers;
+using Azure.Mcp.Core.Models.Elicitation;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol;
+using ModelContextProtocol.Protocol;
+using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading;
+
+///
+/// A tool loader that exposes Azure command groups as hierarchical namespace tools with direct in-process execution.
+/// Provides the same functionality as but without spawning child azmcp processes.
+/// Supports learn functionality for progressive discovery of commands within each namespace.
+///
+public sealed class NamespaceToolLoader(
+ CommandFactory commandFactory,
+ IOptions options,
+ IServiceProvider serviceProvider,
+ ILogger logger,
+ bool applyFilter = true) : BaseToolLoader(logger)
+{
+ private readonly CommandFactory _commandFactory = commandFactory ?? throw new ArgumentNullException(nameof(commandFactory));
+ private readonly IOptions _options = options ?? throw new ArgumentNullException(nameof(options));
+ private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
+ private readonly bool _applyFilter = applyFilter;
+
+ private readonly Lazy> _availableNamespaces = new(() =>
+ {
+ IEnumerable allSubGroups = commandFactory.RootGroup.SubGroup;
+
+ if (applyFilter)
+ {
+ allSubGroups = allSubGroups
+ .Where(group => !DiscoveryConstants.IgnoredCommandGroups.Contains(group.Name, StringComparer.OrdinalIgnoreCase))
+ .Where(group => options.Value.Namespace == null ||
+ options.Value.Namespace.Length == 0 ||
+ options.Value.Namespace.Contains(group.Name, StringComparer.OrdinalIgnoreCase));
+ }
+
+ return allSubGroups.Select(group => group.Name).ToList();
+ });
+
+ private readonly Dictionary> _cachedToolLists = new(StringComparer.OrdinalIgnoreCase);
+ private ListToolsResult? _cachedListToolsResult;
+
+ private const string ToolCallProxySchema = """
+ {
+ "type": "object",
+ "properties": {
+ "tool": {
+ "type": "string",
+ "description": "The name of the tool to call."
+ },
+ "parameters": {
+ "type": "object",
+ "description": "A key/value pair of parameters names and values to pass to the tool call command."
+ }
+ },
+ "additionalProperties": false
+ }
+ """;
+
+ private static readonly JsonElement ToolSchema = JsonSerializer.Deserialize("""
+ {
+ "type": "object",
+ "properties": {
+ "intent": {
+ "type": "string",
+ "description": "The intent of the azure operation to perform."
+ },
+ "command": {
+ "type": "string",
+ "description": "The command to execute against the specified tool."
+ },
+ "parameters": {
+ "type": "object",
+ "description": "The parameters to pass to the tool command."
+ },
+ "learn": {
+ "type": "boolean",
+ "description": "To learn about the tool and its supported child tools and parameters.",
+ "default": false
+ }
+ },
+ "required": ["intent"],
+ "additionalProperties": false
+ }
+ """, ServerJsonContext.Default.JsonElement);
+
+ public override ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ if (_cachedListToolsResult != null)
+ {
+ return ValueTask.FromResult(_cachedListToolsResult);
+ }
+
+ var namespaces = _availableNamespaces.Value;
+ var allToolsResponse = new ListToolsResult
+ {
+ Tools = new List()
+ };
+
+ foreach (var namespaceName in namespaces)
+ {
+ var group = _commandFactory.RootGroup.SubGroup
+ .First(g => string.Equals(g.Name, namespaceName, StringComparison.OrdinalIgnoreCase));
+
+ var tool = new Tool
+ {
+ Name = namespaceName,
+ Description = group.Description + """
+ This tool is a hierarchical MCP command router.
+ Sub commands are routed to MCP servers that require specific fields inside the "parameters" object.
+ To invoke a command, set "command" and wrap its args in "parameters".
+ Set "learn=true" to discover available sub commands.
+ """,
+ InputSchema = ToolSchema,
+ Annotations = new ToolAnnotations()
+ {
+ Title = group.Title ?? namespaceName,
+ DestructiveHint = group.ToolMetadata?.Destructive,
+ IdempotentHint = group.ToolMetadata?.Idempotent,
+ OpenWorldHint = group.ToolMetadata?.OpenWorld,
+ ReadOnlyHint = group.ToolMetadata?.ReadOnly,
+ },
+ };
+
+ allToolsResponse.Tools.Add(tool);
+ }
+
+ // Cache the result
+ _cachedListToolsResult = allToolsResponse;
+ return ValueTask.FromResult(allToolsResponse);
+ }
+
+ public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ if (string.IsNullOrWhiteSpace(request.Params?.Name))
+ {
+ throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty.");
+ }
+
+ string tool = request.Params.Name;
+ var args = request.Params?.Arguments;
+ string? intent = null;
+ string? command = null;
+ bool learn = false;
+
+ // In namespace mode, the name of the tool is also its IAreaSetup name.
+ Activity.Current?.SetTag(TagName.ToolArea, tool);
+
+ if (args != null)
+ {
+ if (args.TryGetValue("intent", out var intentElem) && intentElem.ValueKind == JsonValueKind.String)
+ {
+ intent = intentElem.GetString();
+ }
+ if (args.TryGetValue("learn", out var learnElem) && learnElem.ValueKind == JsonValueKind.True)
+ {
+ learn = true;
+ }
+ if (args.TryGetValue("command", out var commandElem) && commandElem.ValueKind == JsonValueKind.String)
+ {
+ command = commandElem.GetString();
+ }
+ }
+
+ if (!learn && !string.IsNullOrEmpty(intent) && string.IsNullOrEmpty(command))
+ {
+ learn = true;
+ }
+
+ try
+ {
+ var activity = Activity.Current;
+
+ if (learn)
+ {
+ return await InvokeToolLearn(request, intent ?? "", tool, cancellationToken);
+ }
+ else if (!string.IsNullOrEmpty(tool) && !string.IsNullOrEmpty(command))
+ {
+ // We no longer spawn new processes to handle child tool invocations.
+ // So, we have to update ToolName to represent the namespace's child tool name
+ // rather than what is exposed to the user. The following inputs would
+ // be routed here. In both examples, the end-user's MCP client sees that we expose
+ // a tool called "storage" and would invoke our "storage" tool.
+ //
+ // A) {
+ // "intent": "List storage blobs.",
+ // "command": "blob_list",
+ // "parameters": [ "--name", "foo", "--subscription-id", "bar" ]
+ // }
+ //
+ // This is the case where the LLM knows what tool should be executed, so it passes
+ // in all the parameters required to execute the underlying Storage tool.
+ //
+ // B) {
+ // "intent": "List storage blobs.",
+ // "command": "blob_list",
+ // "parameters": []
+ // "learn": true
+ // }
+ //
+ // This command attempts to learn what the command "blob_list" entails by
+ // invoking it with no parameters and "learn" == "true". The command will
+ // generally fail, providing the LLM with extra information it needs to pass
+ // in for the command to succeed the next time.
+ activity?.SetTag(TagName.ToolName, command);
+
+ var toolParams = GetParametersFromArgs(args);
+ return await InvokeChildToolAsync(request, intent ?? "", tool, command, toolParams, cancellationToken);
+ }
+ }
+ catch (KeyNotFoundException ex)
+ {
+ _logger.LogError(ex, "Key not found while calling tool: {Tool}", tool);
+
+ return new CallToolResult
+ {
+ Content =
+ [
+ new TextContentBlock {
+ Text = $"""
+ The tool '{tool}.{command}' was not found or does not support the specified command.
+ Please ensure the tool name and command are correct.
+ If you want to learn about available tools, run again with the "learn=true" argument.
+ """
+ }
+ ],
+ IsError = true
+ };
+ }
+
+ return new CallToolResult
+ {
+ Content =
+ [
+ new TextContentBlock {
+ Text = """
+ The "command" parameters are required when not learning
+ Run again with the "learn" argument to get a list of available tools and their parameters.
+ To learn about a specific tool, use the "tool" argument with the name of the tool.
+ """
+ }
+ ],
+ IsError = false
+ };
+ }
+
+ private async Task InvokeChildToolAsync(
+ RequestContext request,
+ string? intent,
+ string namespaceName,
+ string command,
+ IReadOnlyDictionary parameters,
+ CancellationToken cancellationToken)
+ {
+ if (request.Params == null)
+ {
+ var content = new TextContentBlock
+ {
+ Text = "Cannot call tools with null parameters.",
+ };
+
+ _logger.LogWarning(content.Text);
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+
+ Activity.Current?.SetTag(TagName.IsServerCommandInvoked, true);
+ IReadOnlyDictionary namespaceCommands;
+ try
+ {
+ namespaceCommands = _commandFactory.GroupCommands([namespaceName]);
+ if (namespaceCommands == null || namespaceCommands.Count == 0)
+ {
+ _logger.LogError("Failed to get commands for namespace: {Namespace}", namespaceName);
+ return await InvokeToolLearn(request, intent, namespaceName, cancellationToken);
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception thrown while getting commands for namespace: {Namespace}", namespaceName);
+ return await InvokeToolLearn(request, intent, namespaceName, cancellationToken);
+ }
+
+ try
+ {
+ var availableTools = GetChildToolList(request, namespaceName);
+
+ // When the specified command is not available, we try to learn about the tool's capabilities
+ // and infer the command and parameters from the users intent.
+ if (!availableTools.Any(t => string.Equals(t.Name, command, StringComparison.OrdinalIgnoreCase)))
+ {
+ _logger.LogWarning("Namespace {Namespace} does not have a command {Command}.", namespaceName, command);
+ if (string.IsNullOrWhiteSpace(intent))
+ {
+ return await InvokeToolLearn(request, intent, namespaceName, cancellationToken);
+ }
+
+ var samplingResult = await GetCommandAndParametersFromIntentAsync(request, intent, namespaceName, availableTools, cancellationToken);
+ if (string.IsNullOrWhiteSpace(samplingResult.commandName))
+ {
+ return await InvokeToolLearn(request, intent ?? "", namespaceName, cancellationToken);
+ }
+
+ command = samplingResult.commandName;
+ parameters = samplingResult.parameters;
+ }
+
+ await NotifyProgressAsync(request, $"Calling {namespaceName} {command}...", cancellationToken);
+
+ if (!namespaceCommands.TryGetValue(command, out var cmd))
+ {
+ _logger.LogError("Command {Command} found in tools but missing from namespace {Namespace} commands.", command, namespaceName);
+ return await InvokeToolLearn(request, intent, namespaceName, cancellationToken);
+ }
+
+ // Check if this tool requires elicitation for sensitive data
+ var metadata = cmd.Metadata;
+ if (metadata.Secret)
+ {
+ var elicitationResult = await HandleSecretElicitationAsync(
+ request,
+ $"{namespaceName} {command}",
+ _options.Value.InsecureDisableElicitation,
+ _logger,
+ cancellationToken);
+
+ if (elicitationResult != null)
+ {
+ return elicitationResult;
+ }
+ }
+
+ var currentActivity = Activity.Current;
+ var commandContext = new CommandContext(_serviceProvider, currentActivity);
+ var realCommand = cmd.GetCommand();
+
+ ParseResult commandOptions;
+ if (realCommand.Options.Count == 1 && IsRawMcpToolInputOption(realCommand.Options[0]))
+ {
+ commandOptions = realCommand.ParseFromRawMcpToolInput(parameters);
+ }
+ else
+ {
+ commandOptions = realCommand.ParseFromDictionary(parameters);
+ }
+
+ _logger.LogTrace("Executing namespace command '{Namespace} {Command}'", namespaceName, command);
+
+ // It is possible that the command provided by the LLM is not one that exists, such as "blob-list".
+ // The logic above performs sampling to try and get a correct command name. "blob_get" in
+ // this case, which will be executed.
+ currentActivity?.SetTag(TagName.ToolName, command).SetTag(TagName.ToolId, cmd.Id);
+
+ var commandResponse = await cmd.ExecuteAsync(commandContext, commandOptions, cancellationToken);
+ var jsonResponse = JsonSerializer.Serialize(commandResponse, ModelsJsonContext.Default.CommandResponse);
+ var isError = commandResponse.Status < HttpStatusCode.OK || commandResponse.Status >= HttpStatusCode.Ambiguous;
+
+ if (jsonResponse.Contains("Missing required options", StringComparison.OrdinalIgnoreCase))
+ {
+ var childToolSpecJson = GetChildToolJson(request, namespaceName, command);
+
+ _logger.LogWarning("Namespace {Namespace} command {Command} requires additional parameters.", namespaceName, command);
+ var finalResponse = new CallToolResult
+ {
+ Content =
+ [
+ new TextContentBlock {
+ Text = $"""
+ The '{command}' command is missing required parameters.
+
+ - Review the following command spec and identify the required arguments from the input schema.
+ - Omit any arguments that are not required or do not apply to your use case.
+ - Wrap all command arguments into the root "parameters" argument.
+ - If required data is missing infer the data from your context or prompt the user as needed.
+ - Run the tool again with the "command" and root "parameters" object.
+
+ Command Spec:
+ {childToolSpecJson}
+ """
+ }
+ ],
+ IsError = true
+ };
+
+ // Add original response content
+ finalResponse.Content.Add(new TextContentBlock { Text = jsonResponse });
+ return finalResponse;
+ }
+
+ return new CallToolResult
+ {
+ Content = [new TextContentBlock { Text = jsonResponse }],
+ IsError = isError
+ };
+ }
+ catch (Exception ex)
+ {
+ _logger.LogError(ex, "Exception thrown while calling namespace: {Namespace}, command: {Command}", namespaceName, command);
+ return new CallToolResult
+ {
+ Content =
+ [
+ new TextContentBlock {
+ Text = $"""
+ There was an error finding or calling tool and command.
+ Failed to call namespace: {namespaceName}, command: {command}
+ Error: {ex.Message}
+
+ Run again with the "learn=true" to get a list of available commands and their parameters.
+ """
+ }
+ ]
+ };
+ }
+ }
+
+ private async Task InvokeToolLearn(RequestContext request, string? intent, string namespaceName, CancellationToken cancellationToken)
+ {
+ Activity.Current?.SetTag(TagName.IsServerCommandInvoked, false);
+ var toolsJson = GetChildToolListJson(request, namespaceName);
+
+ var learnResponse = new CallToolResult
+ {
+ Content =
+ [
+ new TextContentBlock {
+ Text = $"""
+ Here are the available command and their parameters for '{namespaceName}' tool.
+ If you do not find a suitable command, run again with the "learn=true" to get a list of available commands and their parameters.
+ Next, identify the command you want to execute and run again with the "command" and "parameters" arguments.
+
+ {toolsJson}
+ """
+ }
+ ],
+ IsError = false
+ };
+ var response = learnResponse;
+ if (SupportsSampling(request.Server) && !string.IsNullOrWhiteSpace(intent))
+ {
+ var availableTools = GetChildToolList(request, namespaceName);
+ (string? commandName, IReadOnlyDictionary parameters) = await GetCommandAndParametersFromIntentAsync(request, intent, namespaceName, availableTools, cancellationToken);
+ if (commandName != null)
+ {
+ response = await InvokeChildToolAsync(request, intent, namespaceName, commandName, parameters, cancellationToken);
+ }
+ }
+ return response;
+ }
+
+ ///
+ /// Gets the available tools from the namespace commands and caches the result for subsequent requests.
+ ///
+ private List GetChildToolList(RequestContext request, string namespaceName)
+ {
+ // Check cache first
+ if (_cachedToolLists.TryGetValue(namespaceName, out var cachedList))
+ {
+ return cachedList;
+ }
+
+ if (string.IsNullOrWhiteSpace(request.Params?.Name))
+ {
+ throw new ArgumentNullException(nameof(request.Params.Name), "Tool name cannot be null or empty.");
+ }
+
+ var namespaces = _availableNamespaces.Value;
+ if (!namespaces.Any(ns => string.Equals(ns, namespaceName, StringComparison.OrdinalIgnoreCase)))
+ {
+ var availableList = string.Join(", ", namespaces);
+ throw new KeyNotFoundException($"The namespace '{namespaceName}' was not found. Available namespaces: {availableList}");
+ }
+
+ var namespaceCommands = _commandFactory.GroupCommands([namespaceName]);
+ if (namespaceCommands == null)
+ {
+ _logger.LogWarning("No commands found for namespace: {Namespace}", namespaceName);
+ return [];
+ }
+
+ var list = namespaceCommands
+ .Where(kvp => !(_options.Value.ReadOnly ?? false) || kvp.Value.Metadata.ReadOnly)
+ .Select(kvp => CreateToolFromCommand(kvp.Key, kvp.Value))
+ .ToList();
+
+ // Cache for subsequent requests
+ _cachedToolLists[namespaceName] = list;
+
+ return list;
+ }
+
+ private string GetChildToolListJson(RequestContext request, string namespaceName)
+ {
+ var listTools = GetChildToolList(request, namespaceName);
+ return JsonSerializer.Serialize(listTools, ServerJsonContext.Default.ListTool);
+ }
+
+ private string GetChildToolJson(RequestContext request, string namespaceName, string commandName)
+ {
+ var tools = GetChildToolList(request, namespaceName);
+ var tool = tools.First(t => string.Equals(t.Name, commandName, StringComparison.OrdinalIgnoreCase));
+ return JsonSerializer.Serialize(tool, ServerJsonContext.Default.Tool);
+ }
+
+ ///
+ /// Creates a tool definition from a command (same logic as CommandFactoryToolLoader).
+ ///
+ private static Tool CreateToolFromCommand(string fullName, IBaseCommand command)
+ {
+ var underlyingCommand = command.GetCommand();
+ var tool = new Tool
+ {
+ Name = fullName,
+ Description = underlyingCommand.Description,
+ };
+
+ var metadata = command.Metadata;
+ tool.Annotations = new ToolAnnotations()
+ {
+ DestructiveHint = metadata.Destructive,
+ IdempotentHint = metadata.Idempotent,
+ OpenWorldHint = metadata.OpenWorld,
+ ReadOnlyHint = metadata.ReadOnly,
+ Title = command.Title,
+ };
+
+ if (metadata.Secret)
+ {
+ tool.Meta = new JsonObject { ["SecretHint"] = metadata.Secret };
+ }
+
+ var schema = new ToolInputSchema();
+ var options = command.GetCommand().Options;
+
+ if (options?.Count > 0)
+ {
+ if (options.Count == 1 && IsRawMcpToolInputOption(options[0]))
+ {
+ var arguments = JsonNode.Parse(options[0].Description ?? "{}") as JsonObject ?? new JsonObject();
+ tool.InputSchema = JsonSerializer.SerializeToElement(arguments, ServerJsonContext.Default.JsonObject);
+ return tool;
+ }
+ else
+ {
+ foreach (var option in options)
+ {
+ var propName = NameNormalization.NormalizeOptionName(option.Name);
+ schema.Properties.Add(propName, TypeToJsonTypeMapper.CreatePropertySchema(option.ValueType, option.Description));
+ }
+ schema.Required = [.. options.Where(p => p.Required).Select(p => NameNormalization.NormalizeOptionName(p.Name))];
+ }
+ }
+
+ tool.InputSchema = JsonSerializer.SerializeToElement(schema, ServerJsonContext.Default.ToolInputSchema);
+ return tool;
+ }
+
+ private static bool IsRawMcpToolInputOption(Option option)
+ {
+ const string RawMcpToolInputOptionName = "raw-mcp-tool-input";
+ if (string.Equals(NameNormalization.NormalizeOptionName(option.Name), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase))
+ {
+ return true;
+ }
+
+ return option.Aliases.Any(alias =>
+ string.Equals(NameNormalization.NormalizeOptionName(alias), RawMcpToolInputOptionName, StringComparison.OrdinalIgnoreCase));
+ }
+
+ private static IReadOnlyDictionary GetParametersFromArgs(IReadOnlyDictionary? args)
+ {
+ if (args == null || !args.TryGetValue("parameters", out var paramsElem))
+ {
+ return new Dictionary();
+ }
+
+ if (paramsElem.ValueKind == JsonValueKind.Object)
+ {
+ return paramsElem.EnumerateObject()
+ .ToDictionary(prop => prop.Name, prop => prop.Value);
+ }
+
+ return new Dictionary();
+ }
+
+ private static bool SupportsSampling(McpServer server)
+ {
+ return server?.ClientCapabilities?.Sampling != null;
+ }
+
+ private static async Task NotifyProgressAsync(RequestContext request, string message, CancellationToken cancellationToken)
+ {
+ var progressToken = request.Params?.ProgressToken;
+ if (progressToken == null)
+ {
+ return;
+ }
+
+ await request.Server.NotifyProgressAsync(progressToken.Value,
+ new ProgressNotificationValue
+ {
+ Progress = 0f,
+ Message = message,
+ }, cancellationToken);
+ }
+
+ private async Task<(string? commandName, IReadOnlyDictionary parameters)> GetCommandAndParametersFromIntentAsync(
+ RequestContext request,
+ string intent,
+ string namespaceName,
+ List availableTools,
+ CancellationToken cancellationToken)
+ {
+ await NotifyProgressAsync(request, $"Learning about {namespaceName} capabilities...", cancellationToken);
+
+ JsonElement toolParams = GetParametersJsonElement(request);
+ var toolParamsJson = toolParams.GetRawText();
+ var availableToolsJson = JsonSerializer.Serialize(availableTools, ServerJsonContext.Default.ListTool);
+
+ var samplingRequest = new CreateMessageRequestParams
+ {
+ Messages = [
+ new SamplingMessage
+ {
+ Role = Role.Assistant,
+ Content = new TextContentBlock{
+ Text = $"""
+ This is a list of available commands for the {namespaceName} server.
+
+ Your task:
+ - Select the single command that best matches the user's intent.
+ - Return a valid JSON object that matches the provided result schema.
+ - Map the user's intent and known parameters to the command's input schema, ensuring parameter names and types match the schema exactly (no extra or missing parameters).
+ - Only include parameters that are defined in the selected command's input schema.
+ - Do not guess or invent parameters.
+ - If no command matches, return JSON schema with "Unknown" tool name.
+
+ Result Schema:
+ {ToolCallProxySchema}
+
+ Intent:
+ {intent ?? "No specific intent provided"}
+
+ Known Parameters:
+ {toolParamsJson}
+
+ Available Commands:
+ {availableToolsJson}
+ """
+ }
+ }
+ ],
+ };
+ try
+ {
+ var samplingResponse = await request.Server.SampleAsync(samplingRequest, cancellationToken);
+ var samplingContent = samplingResponse.Content as TextContentBlock;
+ var toolCallJson = samplingContent?.Text?.Trim();
+ string? commandName = null;
+ IReadOnlyDictionary parameters = new Dictionary();
+
+ if (!string.IsNullOrEmpty(toolCallJson))
+ {
+ using var jsonDoc = JsonDocument.Parse(toolCallJson);
+ var root = jsonDoc.RootElement;
+ if (root.TryGetProperty("tool", out var toolProp) && toolProp.ValueKind == JsonValueKind.String)
+ {
+ commandName = toolProp.GetString();
+ }
+ if (root.TryGetProperty("parameters", out var parametersElem) && parametersElem.ValueKind == JsonValueKind.Object)
+ {
+ parameters = parametersElem.EnumerateObject().ToDictionary(prop => prop.Name, prop => prop.Value.Clone()) ?? new Dictionary();
+ }
+ }
+
+ if (commandName != null && commandName != "Unknown")
+ {
+ return (commandName, parameters);
+ }
+ }
+ catch
+ {
+ _logger.LogError("Failed to get command and parameters from intent: {Intent} for namespace: {Namespace}", intent, namespaceName);
+ }
+
+ return (null, new Dictionary());
+ }
+
+ ///
+ /// Disposes resources owned by this tool loader.
+ /// Clears the cached tool lists dictionary.
+ ///
+ protected override ValueTask DisposeAsyncCore()
+ {
+ _cachedToolLists.Clear();
+ return ValueTask.CompletedTask;
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs
new file mode 100644
index 0000000000..41a220cdb9
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/RegistryToolLoader.cs
@@ -0,0 +1,246 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics;
+using Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading;
+
+///
+/// RegistryToolLoader is a tool loader that retrieves tools from a registry.
+/// Tools are loaded from each MCP server and exposed through the MCP server.
+/// It handles tool call proxying and provides a unified interface for tool operations.
+///
+public sealed class RegistryToolLoader(
+ IMcpDiscoveryStrategy discoveryStrategy,
+ IOptions options,
+ ILogger logger) : BaseToolLoader(logger)
+{
+ private readonly IMcpDiscoveryStrategy _serverDiscoveryStrategy = discoveryStrategy;
+ private readonly IOptions _options = options;
+ private Dictionary _toolClientMap = new();
+ private List _discoveredClients = new();
+ private readonly SemaphoreSlim _initializationSemaphore = new(1, 1);
+ private bool _isInitialized = false;
+
+ ///
+ /// Gets or sets the client options used when creating MCP clients.
+ ///
+ public McpClientOptions ClientOptions { get; set; } = new McpClientOptions();
+
+ ///
+ /// Lists all tools available from registered MCP servers.
+ ///
+ /// The request context containing parameters and metadata.
+ /// A cancellation token.
+ /// A result containing the list of available tools.
+ public override async ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ await InitializeAsync(cancellationToken);
+
+ var allToolsResponse = new ListToolsResult
+ {
+ Tools = new List()
+ };
+
+ // Use cached discovered clients instead of re-discovering servers
+ foreach (var mcpClient in _discoveredClients)
+ {
+ var toolsResponse = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
+ var filteredTools = toolsResponse
+ .Select(t => t.ProtocolTool)
+ .Where(t => !_options.Value.ReadOnly || (t.Annotations?.ReadOnlyHint == true));
+
+ // Filter by specific tools if provided
+ if (_options.Value.Tool != null && _options.Value.Tool.Length > 0)
+ {
+ filteredTools = filteredTools.Where(t => _options.Value.Tool.Any(tool => tool.Contains(t.Name, StringComparison.OrdinalIgnoreCase)));
+ }
+
+ foreach (var tool in filteredTools)
+ {
+ allToolsResponse.Tools.Add(tool);
+ }
+ }
+
+ return allToolsResponse;
+ }
+
+ ///
+ /// Handles tool calls by routing them to the appropriate MCP client.
+ ///
+ /// The request context containing parameters and metadata.
+ /// A cancellation token.
+ /// The result of the tool call operation.
+ public override async ValueTask CallToolHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ if (request.Params == null)
+ {
+ var content = new TextContentBlock
+ {
+ Text = "Cannot call tools with null parameters.",
+ };
+
+ _logger.LogWarning(content.Text);
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+
+ // Initialize the tool client map if not already done
+ await InitializeAsync(cancellationToken);
+
+ // Check if tool filtering is enabled and validate the requested tool
+ if (_options.Value.Tool != null && _options.Value.Tool.Length > 0)
+ {
+ if (!_options.Value.Tool.Any(tool => tool.Contains(request.Params.Name, StringComparison.OrdinalIgnoreCase)))
+ {
+ var content = new TextContentBlock
+ {
+ Text = $"Tool '{request.Params.Name}' is not available. This server is configured to only expose the tools: {string.Join(", ", _options.Value.Tool.Select(t => $"'{t}'"))}",
+ };
+
+ _logger.LogWarning(content.Text);
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+ }
+
+ if (!_toolClientMap.TryGetValue(request.Params.Name, out var kvp) || kvp.Client == null)
+ {
+ var content = new TextContentBlock
+ {
+ Text = $"The tool {request.Params.Name} was not found in the tool registry.",
+ };
+
+ _logger.LogWarning(content.Text);
+
+ return new CallToolResult
+ {
+ Content = [content],
+ IsError = true,
+ };
+ }
+
+ // For MCP servers loaded from registry.json, the ToolArea is also its "server name".
+ Activity.Current?.SetTag(TagName.ToolArea, kvp.ServerName);
+
+ var parameters = TransformArgumentsToDictionary(request.Params.Arguments);
+ return await kvp.Client.CallToolAsync(request.Params.Name, parameters, cancellationToken: cancellationToken);
+ }
+
+ ///
+ /// Transforms tool call arguments to a parameters dictionary.
+ /// This transformation is used because McpClientExtensions.CallToolAsync expects parameters as Dictionary<string, object?>.
+ ///
+ /// The arguments to transform to parameters.
+ /// A dictionary of parameter names and values compatible with McpClientExtensions.CallToolAsync.
+ private static Dictionary TransformArgumentsToDictionary(IReadOnlyDictionary? args)
+ {
+ if (args == null)
+ {
+ return [];
+ }
+
+ return args.ToDictionary(kvp => kvp.Key, kvp => (object?)kvp.Value);
+ }
+
+ ///
+ /// Initializes the tool client map by discovering servers and populating tools.
+ ///
+ /// A cancellation token.
+ /// A task representing the asynchronous operation.
+ private async Task InitializeAsync(CancellationToken cancellationToken)
+ {
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ await _initializationSemaphore.WaitAsync(cancellationToken);
+ try
+ {
+ // Double-check pattern: verify we're still not initialized after acquiring the lock
+ if (_isInitialized)
+ {
+ return;
+ }
+
+ var serverList = await _serverDiscoveryStrategy.DiscoverServersAsync(cancellationToken);
+
+ foreach (var server in serverList)
+ {
+ var serverMetadata = server.CreateMetadata();
+ McpClient? mcpClient;
+ try
+ {
+ mcpClient = await _serverDiscoveryStrategy.GetOrCreateClientAsync(serverMetadata.Name, ClientOptions, cancellationToken);
+ }
+ catch (InvalidOperationException ex)
+ {
+ _logger.LogWarning("Failed to create client for provider {ProviderName}: {Error}", serverMetadata.Name, ex.Message);
+ continue;
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning("Failed to start client for provider {ProviderName}: {Error}", serverMetadata.Name, ex.Message);
+ continue;
+ }
+
+ if (mcpClient == null)
+ {
+ _logger.LogWarning("Failed to get MCP client for provider {ProviderName}.", serverMetadata.Name);
+ continue;
+ }
+
+ // Add to discovered clients list for caching
+ _discoveredClients.Add(mcpClient);
+
+ var toolsResponse = await mcpClient.ListToolsAsync(cancellationToken: cancellationToken);
+ var filteredTools = toolsResponse
+ .Select(t => t.ProtocolTool)
+ .Where(t => !_options.Value.ReadOnly || (t.Annotations?.ReadOnlyHint == true));
+
+ foreach (var tool in filteredTools)
+ {
+ _toolClientMap[tool.Name] = (serverMetadata.Name, mcpClient);
+ }
+ }
+
+ _isInitialized = true;
+ }
+ finally
+ {
+ _initializationSemaphore.Release();
+ }
+ }
+
+ ///
+ /// Disposes resources owned by this tool loader.
+ /// Clears collections and disposes the initialization semaphore.
+ /// Note: MCP clients are owned by the discovery strategy, not disposed here.
+ ///
+ protected override async ValueTask DisposeAsyncCore()
+ {
+ // Only dispose resources we own, not the MCP clients
+ _initializationSemaphore?.Dispose();
+
+ // Clear references to clients (but don't dispose them - discovery strategy owns them)
+ _discoveredClients.Clear();
+ _toolClientMap.Clear();
+
+ await ValueTask.CompletedTask;
+ }
+}
diff --git a/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs
new file mode 100644
index 0000000000..a6b22eea91
--- /dev/null
+++ b/core/Azure.Mcp.Core/src/Areas/Server/Commands/ToolLoading/ServerToolLoader.cs
@@ -0,0 +1,533 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Diagnostics;
+using Azure.Mcp.Core.Areas.Server.Commands.Discovery;
+using Microsoft.Extensions.Logging;
+using Microsoft.Extensions.Options;
+using ModelContextProtocol;
+using ModelContextProtocol.Client;
+using ModelContextProtocol.Protocol;
+using static Azure.Mcp.Core.Services.Telemetry.TelemetryConstants;
+
+namespace Azure.Mcp.Core.Areas.Server.Commands.ToolLoading;
+
+public sealed class ServerToolLoader(IMcpDiscoveryStrategy serverDiscoveryStrategy, IOptions options, ILogger logger) : BaseToolLoader(logger)
+{
+ private readonly IMcpDiscoveryStrategy _serverDiscoveryStrategy = serverDiscoveryStrategy ?? throw new ArgumentNullException(nameof(serverDiscoveryStrategy));
+ private readonly Dictionary> _cachedToolLists = new(StringComparer.OrdinalIgnoreCase);
+
+ private const string ToolCallProxySchema = """
+ {
+ "type": "object",
+ "properties": {
+ "tool": {
+ "type": "string",
+ "description": "The name of the tool to call."
+ },
+ "parameters": {
+ "type": "object",
+ "description": "A key/value pair of parameters names nad values to pass to the tool call command."
+ }
+ },
+ "additionalProperties": false
+ }
+ """;
+
+ private static readonly JsonElement ToolSchema = JsonSerializer.Deserialize("""
+ {
+ "type": "object",
+ "properties": {
+ "intent": {
+ "type": "string",
+ "description": "The intent of the azure operation to perform."
+ },
+ "command": {
+ "type": "string",
+ "description": "The command to execute against the specified tool."
+ },
+ "parameters": {
+ "type": "object",
+ "description": "The parameters to pass to the tool command."
+ },
+ "learn": {
+ "type": "boolean",
+ "description": "To learn about the tool and its supported child tools and parameters.",
+ "default": false
+ }
+ },
+ "required": ["intent"],
+ "additionalProperties": false
+ }
+ """, ServerJsonContext.Default.JsonElement);
+
+ public override async ValueTask ListToolsHandler(RequestContext request, CancellationToken cancellationToken)
+ {
+ var serverList = await _serverDiscoveryStrategy.DiscoverServersAsync(cancellationToken);
+ var allToolsResponse = new ListToolsResult
+ {
+ Tools = new List