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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -118,5 +118,7 @@
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.0" />
<PackageVersion Include="coverlet.collector" Version="6.0.4" />
<PackageVersion Include="GitHub.Copilot.SDK" Version="1.0.0-beta.3" />
<PackageVersion Include="Azure.Monitor.Query" Version="1.7.1" />
<PackageVersion Include="Azure.ResourceManager.IotHub" Version="1.2.0-beta.2" />
</ItemGroup>
</Project>
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
55 changes: 33 additions & 22 deletions core/Azure.Mcp.Core/src/Areas/Group/Commands/GroupListCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<GroupListCommand> logger, IResourceGroupService resourceGroupService) : SubscriptionCommand<BaseGroupOptions>()
public sealed class GroupListCommand(ILogger<GroupListCommand> logger) : SubscriptionCommand<BaseGroupOptions>()
{
private const string CommandTitle = "List Resource Groups";
private readonly ILogger<GroupListCommand> _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<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
{
Expand All @@ -42,13 +50,16 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,

try
{
var groups = await _resourceGroupService.GetResourceGroups(
var resourceGroupService = context.GetService<IResourceGroupService>();
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)
{
Expand Down
17 changes: 4 additions & 13 deletions core/Azure.Mcp.Core/src/Areas/Group/GroupSetup.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<GroupListCommand>();
services.AddSingleton<ResourceListCommand>();
}

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<GroupListCommand>(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<ResourceListCommand>(serviceProvider);
var listCommand = serviceProvider.GetRequiredService<GroupListCommand>();
group.AddCommand(listCommand.Name, listCommand);

return group;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand Down
31 changes: 31 additions & 0 deletions core/Azure.Mcp.Core/src/Areas/IAreaSetup.cs
Original file line number Diff line number Diff line change
@@ -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
{
/// <summary>
/// Gets the name of the area.
/// </summary>
string Name { get; }

/// <summary>
/// Gets the user-friendly title of the area for display purposes.
/// </summary>
string Title { get; }

/// <summary>
/// Configure any dependencies.
/// </summary>
void ConfigureServices(IServiceCollection services);

/// <summary>
/// Gets a tree whose root node represents the area.
/// </summary>
CommandGroup RegisterCommands(IServiceProvider serviceProvider);
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Base class for MCP server discovery strategies that provides common functionality.
/// Implements client caching and server provider lookup by name.
/// </summary>
public abstract class BaseDiscoveryStrategy(ILogger logger) : IMcpDiscoveryStrategy
{
/// <summary>
/// Logger instance for this discovery strategy.
/// </summary>
protected readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger));
/// <summary>
/// Cache of MCP clients created by this discovery strategy, keyed by server name (case-insensitive).
/// </summary>
protected readonly Dictionary<string, McpClient> _clientCache = new(StringComparer.OrdinalIgnoreCase);

private bool _disposed = false;

/// <inheritdoc/>
public abstract Task<IEnumerable<IMcpServerProvider>> DiscoverServersAsync(CancellationToken cancellationToken);

/// <inheritdoc/>
public async Task<IMcpServerProvider> 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}'.");
}

/// <inheritdoc/>
public async Task<McpClient> 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;
}

/// <summary>
/// Disposes all cached MCP clients with double disposal protection.
/// </summary>
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;
}
}

/// <summary>
/// Override this method in derived classes to implement disposal logic.
/// This method is called exactly once during disposal.
/// </summary>
/// <returns>A task representing the asynchronous disposal operation.</returns>
protected virtual ValueTask DisposeAsyncCore()
{
// Default implementation does nothing - derived classes override to add their specific cleanup
return ValueTask.CompletedTask;
}
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// 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.
/// </summary>
/// <param name="commandFactory">The command factory used to access available command groups.</param>
/// <param name="options">Options for configuring the service behavior.</param>
/// <param name="logger">Logger instance for this discovery strategy.</param>
public sealed class CommandGroupDiscoveryStrategy(CommandFactory commandFactory, IOptions<ServiceStartOptions> options, ILogger<CommandGroupDiscoveryStrategy> logger) : BaseDiscoveryStrategy(logger)
{
private readonly CommandFactory _commandFactory = commandFactory;
private readonly IOptions<ServiceStartOptions> _options = options;

/// <summary>
/// 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.
/// </summary>
public string? EntryPoint { get; set; } = null;

/// <inheritdoc/>
public override Task<IEnumerable<IMcpServerProvider>> 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<IMcpServerProvider>();

return Task.FromResult(providers);
}
}
Loading
Loading