Convert Storage toolset commands to BaseCommand<TOptions, TResult> pattern (#2656)

This commit is contained in:
Patrick Hallisey authored and GitHub committed 2026-05-19 21:22:59 -07:00
1 parent 72ba91acf2
commit 7a1e374ff5
34 files changed
+1084 -373

No files matched your search

@@ -2,9 +2,11 @@
// Licensed under the MIT License.
using Azure.Mcp.Core.Services.Azure;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Tools.Storage.Commands.Account;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Mcp.Core.Helpers;
using Microsoft.Mcp.Core.Options;
using Microsoft.Mcp.Tests.Client;
@@ -16,6 +18,11 @@ namespace Azure.Mcp.Core.Tests.Areas.Subscription;
public class SubscriptionCommandTests : CommandUnitTestsBase<AccountGetCommand, IStorageService>
{
public SubscriptionCommandTests()
{
Services.AddSingleton<ISubscriptionResolver, SubscriptionResolver>();
}
[Fact]
public void Validate_WithEnvironmentVariableOnly_PassesValidation()
{
@@ -0,0 +1,21 @@
using Azure.Mcp.Core.Services.Azure.Subscription;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Tests.Client;
using NSubstitute;
namespace Azure.Mcp.Tests.Commands;
public abstract class SubscriptionCommandUnitTestsBase<TCommand, TService> : CommandUnitTestsBase<TCommand, TService>
where TCommand : class, IBaseCommand
where TService : class
{
protected ISubscriptionResolver SubscriptionResolver { get; }
public SubscriptionCommandUnitTestsBase()
{
SubscriptionResolver = Substitute.For<ISubscriptionResolver>();
SubscriptionResolver.ResolveSubscription(Arg.Any<string?>()).Returns(args => args[0]);
Services.AddSingleton(SubscriptionResolver);
}
}
@@ -22,7 +22,7 @@ public class RetryPolicyOptions : IComparable<RetryPolicyOptions>, IEquatable<Re
public double? MaxDelaySeconds { get; set; }
[JsonPropertyName(OptionDefinitions.RetryPolicy.MaxRetriesName)]
[Option(Name = "max-retries", Description = "Maximum number of retry attempts before giving up.")]
[Option(Name = "max-retries", Description = "Maximum number of retry attempts for failed operations before giving up.")]
public int? MaxRetries { get; set; }
[JsonPropertyName(OptionDefinitions.RetryPolicy.ModeName)]
+431
View File
@@ -0,0 +1,431 @@
# Converting Commands to Strongly-Typed Options (Two-Generic Pattern)
This guide describes how to convert commands from the legacy **one-generic** `BaseCommand<TOptions>` pattern to the **two-generic** `BaseCommand<TOptions, TResult>` pattern with attribute-driven option binding.
> **Cardinal rule: We're not changing the API, just removing boilerplate.**
>
> The `tools list` output and `--help` for every converted command must be identical before and after conversion. Same option names, same descriptions, same required flags. If a diff shows any change, it's either a bug to fix or an intentional cleanup that needs team review.
## Why Convert?
The old pattern requires manual `RegisterOptions` / `BindOptions` overrides at every level of the command hierarchy. Each base command class adds its options imperatively and binds them by hand:
```csharp
// OLD: every level manually adds and binds options
protected override void RegisterOptions(Command command)
{
base.RegisterOptions(command);
command.Options.Add(StorageOptionDefinitions.Account);
}
protected override TOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.Account = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.Account.Name);
return options;
}
```
The new pattern uses `[Option]` attributes on a flat options POCO. `OptionBinder` handles registration and binding automatically:
```csharp
// NEW: options are just a POCO with attributes
public class BlobUploadOptions : ISubscriptionOption
{
[Option("The name of the Azure Storage account.")]
public required string Account { get; set; }
[Option("The name of the container within the storage account.")]
public required string Container { get; set; }
[Option("The blob name/path within the container.")]
public required string Blob { get; set; }
[Option("The local file path to read content from.")]
public required string LocalFilePath { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
[Option(Name = "retry")]
public RetryPolicyOptions? RetryPolicy { get; set; }
}
```
## Conversion Steps
### Step 1: Identify the Full Hierarchy
Before conversion, generate a baseline of user facing tools and options. Run `eng/scripts/New-ToolsListFile.ps1 -Suffix before` to generate clean `*-tools-before.json` files in `.work/`
Also generate `.work/tool-conversion-checklist.json` by running the `CommandTypeInventoryTests` test. For each command you'll see:
```json
{
"commandClass": "BlobUploadCommand",
"pattern": "one-generic",
"commandHierarchy": [
"BlobUploadCommand",
"BaseBlobCommand<BlobUploadOptions>",
"BaseContainerCommand<BlobUploadOptions>",
"BaseStorageCommand<BlobUploadOptions>",
"SubscriptionCommand<BlobUploadOptions>",
"GlobalCommand<BlobUploadOptions>",
"BaseCommand<BlobUploadOptions>"
],
"optionsHierarchy": [
"BlobUploadOptions",
"BaseBlobOptions",
"BaseContainerOptions",
"BaseStorageOptions",
"SubscriptionOptions",
"GlobalOptions"
],
"exposedOptions": [
{ "name": "--tenant", "required": false },
{ "name": "--subscription", "required": false },
{ "name": "--account", "required": true },
{ "name": "--container", "required": true },
{ "name": "--blob", "required": true },
{ "name": "--local-file-path", "required": true }
]
}
```
Each level in the old hierarchy adds options. You need to flatten them all into a single options class.
### Step 2: Create the New Flat Options Class
Replace the options class hierarchy with a single flat POCO that implements `ISubscriptionOption` (if the command needs a subscription). Use `[Option]` attributes to declare each option.
> **Important: Reproduce the exposed API, not the inherited properties.**
>
> Old options classes inherited many properties from base classes (`GlobalOptions`, `SubscriptionOptions`, etc.) that were never registered or bound for a given command. The new flat options class should contain **only the properties that correspond to actually exposed CLI options** for that specific command — i.e., the `exposedOptions` from the checklist.
>
> This also means nullability may change. A property like `Account` was `string?` in the old model because `BaseStorageOptions` was shared across commands that registered it as both required and optional. In the new model, if every use of `--account` for a given command is required, declare it as `required string Account` — no `?`. **Favor accuracy per-command over reusability across commands.**
**Conventions:**
- **Name**: Derived automatically from the property name in kebab-case (e.g., `LocalFilePath` → `--local-file-path`). Only use `[Option(Name = "...")]` when the convention doesn't produce the desired name (e.g., `RetryPolicy` → `--retry` instead of `--retry-policy`). **Do not** specify `Name =` when it matches the default.
- **Required**: Determined by nullability. Use `required` keyword or non-nullable types for required options. Use `?` for optional.
- **Description**: Pass as the constructor argument `[Option("description")]` or via `[Option(Description = "...")]`.
- **Shared descriptions**: Use constants from `OptionDescriptions` (e.g., `OptionDescriptions.Subscription`, `OptionDescriptions.Tenant`).
- **Nested objects**: Use `[Option(Name = "prefix")]` on a property of a complex type. Its child properties become `--prefix-child-name`. Example: `RetryPolicyOptions` with `[Option(Name = "retry")]` produces `--retry-delay`, `--retry-max-retries`, etc.
- **Property ordering**: List command-specific options first, then sink common/infrastructure options to the bottom in this order: `ResourceGroup`, `Subscription`, `Tenant`, `AuthMethod`, `RetryPolicy`. This keeps the most relevant options visible at a glance.
**Before (hierarchy of 6 classes):**
```
GlobalOptions → Tenant, AuthMethod, RetryPolicy
└─ SubscriptionOptions → Subscription
└─ BaseStorageOptions → Account
└─ BaseContainerOptions → Container
└─ BaseBlobOptions → Blob
└─ BlobUploadOptions → LocalFilePath
```
**After (single flat class):**
```csharp
public class BlobUploadOptions : ISubscriptionOption
{
[Option("The name of the Azure Storage account.")]
public required string Account { get; set; }
[Option("The name of the container within the storage account.")]
public required string Container { get; set; }
[Option("The blob name/path within the container.")]
public required string Blob { get; set; }
[Option("The local file path to read content from.")]
public required string LocalFilePath { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
[Option(Name = "retry")]
public RetryPolicyOptions? RetryPolicy { get; set; }
}
```
> **Tip**: Copy option descriptions from the `option` array in `.work/azure-tools-before.json` or from the old `StorageOptionDefinitions` / `OptionDefinitions.Common` static fields to keep them identical.
### Step 3: Change the Command Base Class
**Old:** Chain of `BaseCommand<T>` → `GlobalCommand<T>` → `SubscriptionCommand<T>` → `BaseXxxCommand<T>`
**New:** `SubscriptionCommand<TOptions, TResult>` (which extends `AuthenticatedCommand<TOptions, TResult>` → `BaseCommand<TOptions, TResult>`)
```csharp
// OLD
public sealed class BlobUploadCommand : BaseBlobCommand<BlobUploadOptions>
// NEW
public sealed class BlobUploadCommand(
ILogger<BlobUploadCommand> logger,
IStorageService storageService,
ISubscriptionResolver subscriptionResolver)
: SubscriptionCommand<BlobUploadOptions, BlobUploadCommand.BlobUploadCommandResult>(subscriptionResolver)
```
Key changes:
- Add `ISubscriptionResolver` to the constructor and pass it to the base.
- Specify the `TResult` type — this can be an existing model (e.g., `BlobUploadResult`) or a new nested record defined in the command class.
- Remove all `RegisterOptions` and `BindOptions` overrides — `OptionBinder` handles this automatically.
### Step 4: Update ExecuteAsync Signature
**Old:** `ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)`
**New:** `ExecuteAsync(CommandContext context, TOptions options, CancellationToken cancellationToken)`
Options are pre-bound and pre-validated. No need to call `BindOptions(parseResult)` or `Validate(parseResult.CommandResult, context.Response)`.
```csharp
// OLD
public override async Task<CommandResponse> ExecuteAsync(
CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
{
if (!Validate(parseResult.CommandResult, context.Response).IsValid)
return context.Response;
var options = BindOptions(parseResult);
// ...
}
// NEW
public override async Task<CommandResponse> ExecuteAsync(
CommandContext context, BlobUploadOptions options, CancellationToken cancellationToken)
{
// options are already bound and validated — just use them
// ...
}
```
### Step 5: Convert Intermediate Base Command Classes Using Interface Constraints
The old pattern uses intermediate base command classes (e.g., `BaseStorageCommand<T>`, `BaseContainerCommand<T>`) both for option registration **and** for shared behavior. In the new pattern, option registration is gone — but shared validation, error handling, or post-bind logic may still be valuable.
**If a base command class only existed to add `RegisterOptions` / `BindOptions`**, remove it entirely. The concrete command directly extends `SubscriptionCommand<TOptions, TResult>`.
**If a base command class provides real shared behavior**, keep it and convert it using the **interface constraint pattern**. This mirrors how `SubscriptionCommand<TOptions, TResult>` constrains `TOptions : ISubscriptionOption` to access `options.Subscription`.
#### Define option interfaces per shared concern
Each layer defines a small interface for the options it needs access to:
```csharp
// Defined in the tool's Options/ folder
public interface IStorageAccountOption
{
string Account { get; }
}
public interface IContainerOption : IStorageAccountOption
{
string Container { get; }
}
public interface IBlobOption : IContainerOption
{
string Blob { get; }
}
```
Interfaces can inherit when it makes sense (every container operation also needs an account), but they don't have to — keep them independent if the concerns are truly separate.
#### Constrain base commands to their interface
```csharp
// Base command can access options.Account with type safety
public abstract class BaseStorageCommand<
[DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions, TResult>(
ISubscriptionResolver subscriptionResolver)
: SubscriptionCommand<TOptions, TResult>(subscriptionResolver)
where TOptions : class, ISubscriptionOption, IStorageAccountOption
{
// ValidateOptions receives the options instance — interface gives type-safe access
public override void ValidateOptions(TOptions options, ValidationResult validationResult)
{
base.ValidateOptions(options, validationResult);
// Shared validation using options.Account
}
}
// Deeper layer adds its own interface constraint
public abstract class BaseContainerCommand<
[DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions, TResult>(
ISubscriptionResolver subscriptionResolver)
: BaseStorageCommand<TOptions, TResult>(subscriptionResolver)
where TOptions : class, ISubscriptionOption, IContainerOption
{
public override void ValidateOptions(TOptions options, ValidationResult validationResult)
{
base.ValidateOptions(options, validationResult);
// Shared container-level validation using options.Container
}
}
```
#### Concrete options implement the interfaces
The options class stays flat — no inheritance. It just implements the interfaces its command chain requires:
```csharp
public class BlobUploadOptions : ISubscriptionOption, IBlobOption
{
[Option("The name of the Azure Storage account.")]
public required string Account { get; set; }
[Option("The name of the container within the storage account.")]
public required string Container { get; set; }
[Option("The blob name/path within the container.")]
public required string Blob { get; set; }
[Option("The local file path to read content from.")]
public required string LocalFilePath { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
[Option(Name = "retry")]
public RetryPolicyOptions? RetryPolicy { get; set; }
}
```
#### Why interfaces instead of class inheritance for options?
| Approach | Options hierarchy | Constraint pattern |
|---|---|---|
| **Old (class inheritance)** | `BlobUploadOptions : BaseBlobOptions : BaseContainerOptions : ...` | `where T : BaseStorageOptions` |
| **New (interface constraint)** | `BlobUploadOptions` (flat, no base class) | `where TOptions : IStorageAccountOption` |
Benefits of the interface approach:
- **Options stay flat** — every `[Option]` attribute is visible in one file, no hunting through a class chain
- **Composable** — a command can require `IStorageAccountOption + ISomeOtherConcern` without a rigid single-inheritance tree
- **Consistent** — same pattern as `SubscriptionCommand` using `ISubscriptionOption`
- **OptionBinder-friendly** — `OptionBinder` reflects over the concrete class, so all properties are discovered regardless of which interfaces they satisfy
### Step 6: Update Validation
**Old:** `command.Validators.Add(...)` in `RegisterOptions` + `Validate()` call in `ExecuteAsync`
**New:** Override `ValidateOptions(TOptions options, ValidationResult validationResult)` on the command class:
```csharp
public override void ValidateOptions(BlobUploadOptions options, ValidationResult validationResult)
{
base.ValidateOptions(options, validationResult); // checks --subscription
if (!File.Exists(options.LocalFilePath))
{
validationResult.Errors.Add($"File not found: {options.LocalFilePath}");
}
}
```
For `required` properties, `OptionBinder` automatically validates presence and throws `CommandValidationException` — no explicit check needed.
### Step 7: Verify Option Parity
After conversion, compare the tool's exposed options against the pre-conversion baseline. Run `eng/scripts/New-ToolsListFile.ps1` to generate clean `*-tools-after.json` files, then diff against the originals (e.g., `.work/azure-tools-before.json`):
```powershell
$before = (Get-Content .work/azure-tools-before.json -Raw | ConvertFrom-Json).results |
Where-Object { $_.command -like '*blob upload*' } | ConvertTo-Json -Depth 5
$after = (Get-Content .work/azure-tools-after.json -Raw | ConvertFrom-Json).results |
Where-Object { $_.command -like '*blob upload*' } | ConvertTo-Json -Depth 5
Compare-Object ($before -split "`n") ($after -split "`n")
```
You can also use `--help` on a specific command:
```
dotnet run --project servers/Azure.Mcp.Server/src -- storage blob upload --help
```
**Option names and required status must not change** — this is a public API.
#### Known pre-existing inconsistencies
Some commands on `main` already have minor description differences due to older one-generic commands using locally instantiated `RetryPolicyOptions` with slightly different description text. These show up in before/after diffs but are **not** caused by conversion:
| Option | Command | Description on `main` |
|---|---|---|
| `--retry-max-retries` | `appconfig kv lock set` | "Maximum number of retry attempts before giving up." |
| `--retry-max-retries` | All other commands (240+) | "Maximum number of retry attempts for failed operations before giving up." |
If your diff flags only these known discrepancies, it's safe to proceed. The inconsistency lives in the unconverted command's own `RetryPolicyOptions` instance (not in shared infrastructure) and will be resolved when that command is converted to the two-generic pattern.
#### Distinguishing unused options from removable inherited properties
During comparison you may find two categories of discrepancies:
- **Unused options** — The option was registered, bound, and visible to the user in the old command, but the command never actually consumed the value (e.g., `--auth-method` on Storage commands — `GlobalCommand` registered it, but `StorageService` never reads it). **Preserve these** in the new options class with a `// TODO: Remove unused option` comment. They are part of the exposed API and removing them requires team review.
- **Removable inherited properties** — The property existed on the old options class via inheritance (e.g., a field on `GlobalOptions`) but was **never registered** on the command and never appeared in `--help` or `tools list`. These are not part of the exposed API. **Do not carry them forward** — they were implementation artifacts of the class hierarchy, not user-facing options.
### Step 8: Update Tests
Tests for commands that extend `SubscriptionCommand<TOptions, TResult>` must inherit from `SubscriptionCommandUnitTestsBase<TCommand, TService>` instead of the plain `CommandUnitTestsBase`. This base class automatically registers a mock `ISubscriptionResolver` in DI. The existing `ExecuteCommandAsync(params string[] args)` continues to work — the two-generic base class internally binds options from `ParseResult`.
```csharp
public class AccountGetCommandTests : SubscriptionCommandUnitTestsBase<AccountGetCommand, IStorageService>
{
[Fact]
public async Task ExecuteAsync_NoParameters_ReturnsSubscriptions()
{
// Tests still use string args — this exercises option registration AND binding
var response = await ExecuteCommandAsync("--subscription", "sub123");
// Assert...
}
}
```
`SubscriptionCommandUnitTestsBase` lives in `Azure.Mcp.Tests.Commands` and exposes a `SubscriptionResolver` property if you need to configure custom resolution behavior.
> **Without `SubscriptionCommandUnitTestsBase`**, DI will fail at runtime with "Unable to resolve service for type `ISubscriptionResolver`". The plain `CommandUnitTestsBase` only registers the service mock and logger.
> **Prefer string args over constructing options directly.** Using `ExecuteCommandAsync("--account", ...)` tests the full pipeline: `[Option]` attribute registration, `OptionBinder` parsing, and `SubscriptionResolver` post-processing. Constructing `TOptions` by hand only tests `ExecuteAsync` logic.
## Quick Reference: Old vs New
| Aspect | Old (one-generic) | New (two-generic) |
|---|---|---|
| Base class | `BaseCommand<TOptions>` | `BaseCommand<TOptions, TResult>` |
| Options constraint | `TOptions : class, new()` | `TOptions : class` |
| Subscription base | `SubscriptionCommand<TOptions>` inherits `GlobalCommand` inherits `BaseCommand` | `SubscriptionCommand<TOptions, TResult>` inherits `AuthenticatedCommand` inherits `BaseCommand` |
| Options class | Inherits from `SubscriptionOptions` → `GlobalOptions` | Flat POCO implementing `ISubscriptionOption` |
| Option registration | Manual `RegisterOptions(Command)` overrides at each level | Automatic via `[Option]` attributes on TOptions |
| Option binding | Manual `BindOptions(ParseResult)` overrides at each level | Automatic via `OptionBinder.BindOptions<TOptions>()` |
| ExecuteAsync args | `(CommandContext, ParseResult, CancellationToken)` | `(CommandContext, TOptions, CancellationToken)` |
| Validation | `command.Validators.Add(...)` + `Validate()` | Override `ValidateOptions(TOptions, ValidationResult)` |
| Subscription resolution | `CommandHelper.GetSubscription(parseResult)` | `ISubscriptionResolver` injected, called in base `BindOptions` |
## Checklist per Command
- [ ] Flatten options hierarchy into single POCO with `[Option]` attributes
- [ ] Implement `ISubscriptionOption` (if command needs subscription)
- [ ] Match all option names, descriptions, and required flags exactly
- [ ] Change base class to `SubscriptionCommand<TOptions, TResult>`
- [ ] Add `ISubscriptionResolver` constructor parameter
- [ ] Identify or define `TResult` type (existing model or new nested record)
- [ ] Update `ExecuteAsync` signature to take `TOptions` instead of `ParseResult`
- [ ] Remove manual `Validate()` call — use `ValidateOptions()` override if needed
- [ ] Remove `RegisterOptions` / `BindOptions` overrides
- [ ] Convert or remove intermediate base command classes (use interface constraints if keeping)
- [ ] Re-parent test classes to `SubscriptionCommandUnitTestsBase<TCommand, TService>`
- [ ] Verify option parity via `tools list` output
- [ ] Build and run tests
- [ ] Run dotnet format from the repo root
> **Remember: We're not changing the API, just removing boilerplate.** If the `tools list` diff shows any difference, stop and investigate before merging.
+64
View File
@@ -0,0 +1,64 @@
param(
[string]$Suffix
)
# if $suffix wasn't provided, default to "after"
if (-not $Suffix) {
$Suffix = "after"
}
$repoRoot = (Resolve-Path "$PSScriptRoot/../..").Path
New-Item -Path "$repoRoot/.work" -ItemType Directory -ErrorAction SilentlyContinue | Out-Null
$originalLocation = Get-Location
Set-Location $repoRoot
try
{
$serverDirectories = Get-ChildItem -Path "$repoRoot/servers" -Directory
foreach ($serverDirectory in $serverDirectories) {
$name = $serverDirectory.Name.ToLower() -replace '.mcp.server', ''
$outputFile = "$repoRoot/.work/$name-tools-$Suffix.json"
Remove-Item -Path $outputFile -ErrorAction SilentlyContinue -ProgressAction SilentlyContinue
dotnet build "$serverDirectory/src"
if ($LASTEXITCODE -ne 0) {
Write-Warning "Build failed with exit code $LASTEXITCODE"
continue
}
$toolsList = dotnet run --project "$serverDirectory/src" --no-build -- tools list
if ($LASTEXITCODE -ne 0) {
Write-Warning "Unable to get tools list for $name, skipping. dotnet exited with code $LASTEXITCODE"
continue
}
# Remove header text up to the first json object
$pastHeader = $false
$outLines = @()
foreach ($line in $toolsList) {
if (!$pastHeader) {
if ($line -match '^\s*\{') {
$pastHeader = $true
} else {
continue
}
}
$outLines += $line
}
# Parse, sort options within each tool by name, and re-serialize
$json = $outLines -join "`n" | ConvertFrom-Json
foreach ($tool in $json.results) {
if ($tool.option) {
$tool.option = @($tool.option | Sort-Object -Property name)
}
}
$json | ConvertTo-Json -Depth 10 | Out-File $outputFile
}
}
finally {
Set-Location $originalLocation
}
@@ -0,0 +1,166 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Mcp.Core.Commands;
using Xunit;
namespace Azure.Mcp.Server.Tests.Infrastructure;
/// <summary>
/// Reflects over all IBaseCommand implementations to produce a JSON inventory
/// of command/option class hierarchies and exposed options.
/// Run this test to regenerate .work/tool-conversion-checklist.json.
/// </summary>
public sealed class CommandTypeInventoryTests
{
[Fact]
public async Task GenerateToolConversionChecklist()
{
// Use Program.ConfigureServices which registers everything properly
var serviceCollection = new ServiceCollection();
Program.ConfigureServices(serviceCollection);
await using var services = serviceCollection.BuildServiceProvider();
// Resolve all registered IBaseCommand implementations from DI (tools only)
var commandTypes = serviceCollection
.Where(sd => sd.ServiceType != null
&& typeof(IBaseCommand).IsAssignableFrom(sd.ServiceType)
&& (sd.ServiceType.Assembly.GetName().Name?.StartsWith("Azure.Mcp.Tools") == true))
.Select(sd => sd.ServiceType)
.ToList();
var commands = commandTypes
.Select(t => (IBaseCommand)services.GetRequiredService(t))
.ToList();
var inventory = new SortedDictionary<string, List<Dictionary<string, object>>>();
foreach (var command in commands)
{
var commandType = command.GetType();
var assemblyName = commandType.Assembly.GetName().Name ?? "Unknown";
if (!assemblyName.StartsWith("Azure.Mcp.Tools"))
continue;
if (!inventory.TryGetValue(assemblyName, out var entries))
{
entries = [];
inventory[assemblyName] = entries;
}
var commandHierarchy = GetTypeHierarchy(commandType);
var optionsType = ExtractOptionsType(commandType);
var optionsHierarchy = optionsType != null ? GetTypeHierarchy(optionsType) : [];
// Get exposed options from the live System.CommandLine.Command instance
var sclCommand = command.GetCommand();
var exposedOptions = sclCommand.Options
.Where(static o => o is not System.CommandLine.Help.HelpOption && o.Name != "learn")
.Select(static o => new Dictionary<string, object>
{
["name"] = o.Name.StartsWith("--") ? o.Name : $"--{o.Name}",
["required"] = o.Required,
})
.ToList();
entries.Add(new Dictionary<string, object>
{
["commandClass"] = commandType.Name,
["id"] = command.Id,
["pattern"] = IsNewPattern(commandType) ? "two-generic" : "one-generic",
["commandHierarchy"] = commandHierarchy,
["optionsClass"] = optionsType?.Name ?? "unknown",
["optionsHierarchy"] = optionsHierarchy,
["exposedOptions"] = exposedOptions,
});
}
var json = JsonSerializer.Serialize(inventory, new JsonSerializerOptions { WriteIndented = true });
var workDir = Path.Combine(GetRepoRoot(), ".work");
Directory.CreateDirectory(workDir);
File.WriteAllText(Path.Combine(workDir, "tool-conversion-checklist.json"), json);
Assert.NotEmpty(inventory);
Assert.True(commands.Count > 100, $"Expected 100+ commands, found {commands.Count}");
}
private static List<string> GetTypeHierarchy(Type type)
{
var hierarchy = new List<string>();
var current = type;
while (current != null && current != typeof(object))
{
hierarchy.Add(FormatTypeName(current));
current = current.BaseType;
}
return hierarchy;
}
private static string FormatTypeName(Type type)
{
if (!type.IsGenericType)
return type.Name;
var name = type.Name;
var backtick = name.IndexOf('`');
if (backtick > 0)
name = name[..backtick];
var args = type.GetGenericArguments().Select(FormatTypeName);
return $"{name}<{string.Join(", ", args)}>";
}
private static Type? ExtractOptionsType(Type commandType)
{
var current = commandType;
while (current != null && current != typeof(object))
{
if (current.IsGenericType)
{
var genericDef = current.GetGenericTypeDefinition();
if (genericDef.FullName is "Microsoft.Mcp.Core.Commands.BaseCommand`1"
or "Microsoft.Mcp.Core.Commands.BaseCommand`2")
{
return current.GetGenericArguments()[0];
}
}
current = current.BaseType;
}
return null;
}
private static bool IsNewPattern(Type commandType)
{
var current = commandType;
while (current != null && current != typeof(object))
{
if (current.IsGenericType)
{
var genericDef = current.GetGenericTypeDefinition();
if (genericDef.FullName == "Microsoft.Mcp.Core.Commands.BaseCommand`2")
return true;
if (genericDef.FullName == "Microsoft.Mcp.Core.Commands.BaseCommand`1")
return false;
}
current = current.BaseType;
}
return false;
}
private static string GetRepoRoot()
{
var dir = AppContext.BaseDirectory;
while (dir != null)
{
if (File.Exists(Path.Combine(dir, "Microsoft.Mcp.slnx")))
return dir;
dir = Directory.GetParent(dir)?.FullName;
}
throw new InvalidOperationException("Could not find repo root (Microsoft.Mcp.slnx)");
}
}
@@ -2,31 +2,19 @@
// Licensed under the MIT License.
using System.Net;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Tests.Commands;
using Azure.Mcp.Tools.AppConfig.Commands;
using Azure.Mcp.Tools.AppConfig.Commands.KeyValue.Lock;
using Azure.Mcp.Tools.AppConfig.Services;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Mcp.Core.Options;
using Microsoft.Mcp.Tests.Client;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Azure.Mcp.Tools.AppConfig.Tests.KeyValue.Lock;
public class KeyValueLockSetCommandTests : CommandUnitTestsBase<KeyValueLockSetCommand, IAppConfigService>
public class KeyValueLockSetCommandTests : SubscriptionCommandUnitTestsBase<KeyValueLockSetCommand, IAppConfigService>
{
private readonly ISubscriptionResolver _subscriptionResolver;
public KeyValueLockSetCommandTests()
{
_subscriptionResolver = Substitute.For<ISubscriptionResolver>();
// Return the input subscription for testing
_subscriptionResolver.ResolveSubscription(Arg.Any<string?>()).Returns(args => args[0]);
Services.AddSingleton(_subscriptionResolver);
}
[Fact]
public async Task ExecuteAsync_LocksKeyValue_WhenValidParametersProvided()
{
@@ -3,15 +3,13 @@
using System.Net;
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Options;
using Azure.Mcp.Tools.Storage.Options.Account;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Extensions;
using Microsoft.Mcp.Core.Models.Command;
using Microsoft.Mcp.Core.Models.Option;
namespace Azure.Mcp.Tools.Storage.Commands.Account;
@@ -29,51 +27,20 @@ namespace Azure.Mcp.Tools.Storage.Commands.Account;
ReadOnly = false,
Secret = false,
LocalRequired = false)]
public sealed class AccountCreateCommand(ILogger<AccountCreateCommand> logger, IStorageService storageService) : SubscriptionCommand<AccountCreateOptions>()
public sealed class AccountCreateCommand(ILogger<AccountCreateCommand> logger, IStorageService storageService, ISubscriptionResolver subscriptionResolver)
: SubscriptionCommand<AccountCreateOptions, AccountCreateCommand.AccountCreateCommandResult>(subscriptionResolver)
{
private readonly ILogger<AccountCreateCommand> _logger = logger;
private readonly IStorageService _storageService = storageService;
protected override void RegisterOptions(Command command)
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, AccountCreateOptions options, CancellationToken cancellationToken)
{
base.RegisterOptions(command);
command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsRequired());
command.Options.Add(StorageOptionDefinitions.AccountCreate);
command.Options.Add(StorageOptionDefinitions.Location);
command.Options.Add(StorageOptionDefinitions.Sku);
command.Options.Add(StorageOptionDefinitions.AccessTier);
command.Options.Add(StorageOptionDefinitions.EnableHierarchicalNamespace);
}
protected override AccountCreateOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.ResourceGroup ??= parseResult.GetValueOrDefault<string>(OptionDefinitions.Common.ResourceGroup.Name);
options.Account = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.AccountCreate.Name);
options.Location = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.Location.Name);
options.Sku = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.Sku.Name);
options.AccessTier = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.AccessTier.Name);
options.EnableHierarchicalNamespace = parseResult.GetValueOrDefault<bool>(StorageOptionDefinitions.EnableHierarchicalNamespace.Name);
return options;
}
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
{
if (!Validate(parseResult.CommandResult, context.Response).IsValid)
{
return context.Response;
}
var options = BindOptions(parseResult);
try
{
// Call service to create storage account
var account = await _storageService.CreateStorageAccount(
options.Account!,
options.ResourceGroup!,
options.Location!,
options.Account,
options.ResourceGroup,
options.Location,
options.Subscription!,
options.Sku,
options.AccessTier,
@@ -82,12 +49,10 @@ public sealed class AccountCreateCommand(ILogger<AccountCreateCommand> logger, I
options.RetryPolicy,
cancellationToken);
// Set results
context.Response.Results = ResponseResult.Create(new(account), StorageJsonContext.Default.AccountCreateCommandResult);
context.Response.Results = ResponseResult.Create(new AccountCreateCommandResult(account), StorageJsonContext.Default.AccountCreateCommandResult);
}
catch (Exception ex)
{
// Log error with all relevant context
_logger.LogError(ex,
"Error creating storage account. Account: {Account}, ResourceGroup: {ResourceGroup}, Location: {Location}.",
options.Account, options.ResourceGroup, options.Location);
@@ -97,7 +62,6 @@ public sealed class AccountCreateCommand(ILogger<AccountCreateCommand> logger, I
return context.Response;
}
// Implementation-specific error handling
protected override string GetErrorMessage(Exception ex) => ex switch
{
KeyNotFoundException => $"Storage account not found. Verify the account name, subscription, and that you have access.",
@@ -111,6 +75,5 @@ public sealed class AccountCreateCommand(ILogger<AccountCreateCommand> logger, I
_ => base.GetErrorMessage(ex)
};
// Strongly-typed result record
internal record AccountCreateCommandResult(StorageAccountResult Account);
public record AccountCreateCommandResult(StorageAccountResult Account);
}
@@ -2,15 +2,13 @@
// Licensed under the MIT License.
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Options;
using Azure.Mcp.Tools.Storage.Options.Account;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Extensions;
using Microsoft.Mcp.Core.Models.Command;
using Microsoft.Mcp.Core.Models.Option;
namespace Azure.Mcp.Tools.Storage.Commands.Account;
@@ -25,38 +23,16 @@ namespace Azure.Mcp.Tools.Storage.Commands.Account;
ReadOnly = true,
Secret = false,
LocalRequired = false)]
public sealed class AccountGetCommand(ILogger<AccountGetCommand> logger, IStorageService storageService) : SubscriptionCommand<AccountGetOptions>()
public sealed class AccountGetCommand(ILogger<AccountGetCommand> logger, IStorageService storageService, ISubscriptionResolver subscriptionResolver)
: SubscriptionCommand<AccountGetOptions, AccountGetCommand.AccountGetCommandResult>(subscriptionResolver)
{
private readonly ILogger<AccountGetCommand> _logger = logger;
private readonly IStorageService _storageService = storageService;
protected override void RegisterOptions(Command command)
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, AccountGetOptions options, CancellationToken cancellationToken)
{
base.RegisterOptions(command);
command.Options.Add(StorageOptionDefinitions.Account.AsOptional());
command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional());
}
protected override AccountGetOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.Account = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.Account.Name);
options.ResourceGroup = parseResult.GetValueOrDefault<string>(OptionDefinitions.Common.ResourceGroup.Name);
return options;
}
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
{
if (!Validate(parseResult.CommandResult, context.Response).IsValid)
{
return context.Response;
}
var options = BindOptions(parseResult);
try
{
// Call service operation with required parameters
var accounts = await _storageService.GetAccountDetails(
options.Account,
options.Subscription!,
@@ -65,8 +41,7 @@ public sealed class AccountGetCommand(ILogger<AccountGetCommand> logger, IStorag
options.RetryPolicy,
cancellationToken);
// Set results
context.Response.Results = ResponseResult.Create(new(accounts?.Results ?? [], accounts?.AreResultsTruncated ?? false), StorageJsonContext.Default.AccountGetCommandResult);
context.Response.Results = ResponseResult.Create(new AccountGetCommandResult(accounts?.Results ?? [], accounts?.AreResultsTruncated ?? false), StorageJsonContext.Default.AccountGetCommandResult);
}
catch (Exception ex)
{
@@ -85,6 +60,5 @@ public sealed class AccountGetCommand(ILogger<AccountGetCommand> logger, IStorag
return context.Response;
}
// Strongly-typed result record
internal record AccountGetCommandResult(List<StorageAccountInfo> Accounts, bool AreResultsTruncated);
public record AccountGetCommandResult(List<StorageAccountInfo> Accounts, bool AreResultsTruncated);
}
@@ -1,29 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics.CodeAnalysis;
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Tools.Storage.Options;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Extensions;
namespace Azure.Mcp.Tools.Storage.Commands;
public abstract class BaseStorageCommand<
[DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] T>
: SubscriptionCommand<T>
where T : BaseStorageOptions, new()
{
protected override void RegisterOptions(Command command)
{
base.RegisterOptions(command);
command.Options.Add(StorageOptionDefinitions.Account);
}
protected override T BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.Account = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.Account.Name);
return options;
}
}
@@ -1,33 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics.CodeAnalysis;
using Azure.Mcp.Tools.Storage.Commands.Blob.Container;
using Azure.Mcp.Tools.Storage.Options;
using Azure.Mcp.Tools.Storage.Options.Blob;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Extensions;
namespace Azure.Mcp.Tools.Storage.Commands.Blob;
public abstract class BaseBlobCommand<
[DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions>
: BaseContainerCommand<TOptions> where TOptions : BaseBlobOptions, new()
{
protected BaseBlobCommand()
{
}
protected override void RegisterOptions(Command command)
{
base.RegisterOptions(command);
command.Options.Add(StorageOptionDefinitions.Blob);
}
protected override TOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.Blob = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.Blob.Name);
return options;
}
}
@@ -1,16 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Mcp.Tools.Storage.Commands.Blob.Container;
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Options;
using Azure.Mcp.Tools.Storage.Options.Blob;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Extensions;
using Microsoft.Mcp.Core.Models.Command;
using Microsoft.Mcp.Core.Models.Option;
namespace Azure.Mcp.Tools.Storage.Commands.Blob;
@@ -35,40 +33,19 @@ namespace Azure.Mcp.Tools.Storage.Commands.Blob;
ReadOnly = true,
Secret = false,
LocalRequired = false)]
public sealed class BlobGetCommand(ILogger<BlobGetCommand> logger, IStorageService storageService) : BaseContainerCommand<BlobGetOptions>()
public sealed class BlobGetCommand(ILogger<BlobGetCommand> logger, IStorageService storageService, ISubscriptionResolver subscriptionResolver)
: SubscriptionCommand<BlobGetOptions, BlobGetCommand.BlobGetCommandResult>(subscriptionResolver)
{
private readonly ILogger<BlobGetCommand> _logger = logger;
private readonly IStorageService _storageService = storageService;
protected override void RegisterOptions(Command command)
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, BlobGetOptions options, CancellationToken cancellationToken)
{
base.RegisterOptions(command);
command.Options.Add(StorageOptionDefinitions.Blob.AsOptional());
command.Options.Add(StorageOptionDefinitions.BlobPrefix.AsOptional());
}
protected override BlobGetOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.Blob = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.Blob.Name);
options.Prefix = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.BlobPrefix.Name);
return options;
}
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
{
if (!Validate(parseResult.CommandResult, context.Response).IsValid)
{
return context.Response;
}
var options = BindOptions(parseResult);
try
{
var details = await _storageService.GetBlobDetails(
options.Account!,
options.Container!,
options.Account,
options.Container,
options.Blob,
options.Subscription!,
options.Prefix,
@@ -77,7 +54,7 @@ public sealed class BlobGetCommand(ILogger<BlobGetCommand> logger, IStorageServi
cancellationToken
);
context.Response.Results = ResponseResult.Create(new(details ?? []), StorageJsonContext.Default.BlobGetCommandResult);
context.Response.Results = ResponseResult.Create(new BlobGetCommandResult(details ?? []), StorageJsonContext.Default.BlobGetCommandResult);
return context.Response;
}
catch (Exception ex)
@@ -95,5 +72,5 @@ public sealed class BlobGetCommand(ILogger<BlobGetCommand> logger, IStorageServi
}
}
internal record BlobGetCommandResult(List<BlobInfo> Blobs);
public record BlobGetCommandResult(List<BlobInfo> Blobs);
}
@@ -1,12 +1,13 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Mcp.Tools.Storage.Options;
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Options.Blob;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Extensions;
using Microsoft.Mcp.Core.Models.Command;
namespace Azure.Mcp.Tools.Storage.Commands.Blob;
@@ -25,40 +26,21 @@ namespace Azure.Mcp.Tools.Storage.Commands.Blob;
ReadOnly = false,
Secret = false,
LocalRequired = true)]
public sealed class BlobUploadCommand(ILogger<BlobUploadCommand> logger, IStorageService storageService) : BaseBlobCommand<BlobUploadOptions>
public sealed class BlobUploadCommand(ILogger<BlobUploadCommand> logger, IStorageService storageService, ISubscriptionResolver subscriptionResolver)
: SubscriptionCommand<BlobUploadOptions, BlobUploadResult>(subscriptionResolver)
{
private readonly ILogger<BlobUploadCommand> _logger = logger;
private readonly IStorageService _storageService = storageService;
protected override void RegisterOptions(Command command)
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, BlobUploadOptions options, CancellationToken cancellationToken)
{
base.RegisterOptions(command);
command.Options.Add(StorageOptionDefinitions.LocalFilePath);
}
protected override BlobUploadOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.LocalFilePath = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.LocalFilePath.Name);
return options;
}
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
{
if (!Validate(parseResult.CommandResult, context.Response).IsValid)
{
return context.Response;
}
var options = BindOptions(parseResult);
try
{
var result = await _storageService.UploadBlob(
options.Account!,
options.Container!,
options.Blob!,
options.LocalFilePath!,
options.Account,
options.Container,
options.Blob,
options.LocalFilePath,
options.Subscription!,
options.Tenant,
options.RetryPolicy,
@@ -1,32 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Diagnostics.CodeAnalysis;
using Azure.Mcp.Tools.Storage.Options;
using Azure.Mcp.Tools.Storage.Options.Blob;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Extensions;
namespace Azure.Mcp.Tools.Storage.Commands.Blob.Container;
public abstract class BaseContainerCommand<
[DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions>
: BaseStorageCommand<TOptions> where TOptions : BaseContainerOptions, new()
{
protected BaseContainerCommand()
{
}
protected override void RegisterOptions(Command command)
{
base.RegisterOptions(command);
command.Options.Add(StorageOptionDefinitions.Container);
}
protected override TOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.Container = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.Container.Name);
return options;
}
}
@@ -1,6 +1,8 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Options.Blob.Container;
using Azure.Mcp.Tools.Storage.Services;
@@ -29,31 +31,25 @@ namespace Azure.Mcp.Tools.Storage.Commands.Blob.Container;
ReadOnly = false,
Secret = false,
LocalRequired = false)]
public sealed class ContainerCreateCommand(ILogger<ContainerCreateCommand> logger, IStorageService storageService) : BaseContainerCommand<ContainerCreateOptions>()
public sealed class ContainerCreateCommand(ILogger<ContainerCreateCommand> logger, IStorageService storageService, ISubscriptionResolver subscriptionResolver)
: SubscriptionCommand<ContainerCreateOptions, ContainerCreateCommand.ContainerCreateCommandResult>(subscriptionResolver)
{
private readonly ILogger<ContainerCreateCommand> _logger = logger;
private readonly IStorageService _storageService = storageService;
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ContainerCreateOptions options, CancellationToken cancellationToken)
{
if (!Validate(parseResult.CommandResult, context.Response).IsValid)
{
return context.Response;
}
var options = BindOptions(parseResult);
try
{
var containerInfo = await _storageService.CreateContainer(
options.Account!,
options.Container!,
options.Account,
options.Container,
options.Subscription!,
options.Tenant,
options.RetryPolicy,
cancellationToken);
context.Response.Results = ResponseResult.Create(new(containerInfo), StorageJsonContext.Default.ContainerCreateCommandResult);
context.Response.Results = ResponseResult.Create(new ContainerCreateCommandResult(containerInfo), StorageJsonContext.Default.ContainerCreateCommandResult);
}
catch (Exception ex)
{
@@ -65,5 +61,5 @@ public sealed class ContainerCreateCommand(ILogger<ContainerCreateCommand> logge
return context.Response;
}
internal record ContainerCreateCommandResult(ContainerInfo Container);
public record ContainerCreateCommandResult(ContainerInfo Container);
}
@@ -1,15 +1,14 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Options;
using Azure.Mcp.Tools.Storage.Options.Blob.Container;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Extensions;
using Microsoft.Mcp.Core.Models.Command;
using Microsoft.Mcp.Core.Models.Option;
namespace Azure.Mcp.Tools.Storage.Commands.Blob.Container;
@@ -34,39 +33,18 @@ namespace Azure.Mcp.Tools.Storage.Commands.Blob.Container;
ReadOnly = true,
Secret = false,
LocalRequired = false)]
public sealed class ContainerGetCommand(ILogger<ContainerGetCommand> logger, IStorageService storageService) : BaseStorageCommand<ContainerGetOptions>()
public sealed class ContainerGetCommand(ILogger<ContainerGetCommand> logger, IStorageService storageService, ISubscriptionResolver subscriptionResolver)
: SubscriptionCommand<ContainerGetOptions, ContainerGetCommand.ContainerGetCommandResult>(subscriptionResolver)
{
private readonly ILogger<ContainerGetCommand> _logger = logger;
private readonly IStorageService _storageService = storageService;
protected override void RegisterOptions(Command command)
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ContainerGetOptions options, CancellationToken cancellationToken)
{
base.RegisterOptions(command);
command.Options.Add(StorageOptionDefinitions.Container.AsOptional());
command.Options.Add(StorageOptionDefinitions.ContainerPrefix.AsOptional());
}
protected override ContainerGetOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.Container = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.Container.Name);
options.Prefix = parseResult.GetValueOrDefault<string>(StorageOptionDefinitions.ContainerPrefix.Name);
return options;
}
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
{
if (!Validate(parseResult.CommandResult, context.Response).IsValid)
{
return context.Response;
}
var options = BindOptions(parseResult);
try
{
var containers = await _storageService.GetContainerDetails(
options.Account!,
options.Account,
options.Container,
options.Subscription!,
options.Prefix,
@@ -75,7 +53,7 @@ public sealed class ContainerGetCommand(ILogger<ContainerGetCommand> logger, ISt
cancellationToken
);
context.Response.Results = ResponseResult.Create(new(containers ?? []), StorageJsonContext.Default.ContainerGetCommandResult);
context.Response.Results = ResponseResult.Create(new ContainerGetCommandResult(containers ?? []), StorageJsonContext.Default.ContainerGetCommandResult);
return context.Response;
}
catch (Exception ex)
@@ -93,5 +71,5 @@ public sealed class ContainerGetCommand(ILogger<ContainerGetCommand> logger, ISt
}
}
internal record ContainerGetCommandResult(List<ContainerInfo> Containers);
public record ContainerGetCommandResult(List<ContainerInfo> Containers);
}
@@ -1,8 +1,10 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Tools.Storage.Commands;
using Azure.Mcp.Tools.Storage.Options;
using Azure.Mcp.Tools.Storage.Options.Table;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
@@ -21,30 +23,24 @@ namespace Azure.Mcp.Tools.Storage.Table.Commands;
ReadOnly = true,
Secret = false,
LocalRequired = false)]
public sealed class TableListCommand(ILogger<TableListCommand> logger, IStorageService storageService) : BaseStorageCommand<BaseStorageOptions>()
public sealed class TableListCommand(ILogger<TableListCommand> logger, IStorageService storageService, ISubscriptionResolver subscriptionResolver)
: SubscriptionCommand<TableListOptions, TableListCommand.TableListCommandResult>(subscriptionResolver)
{
private readonly ILogger<TableListCommand> _logger = logger;
private readonly IStorageService _storageService = storageService;
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, ParseResult parseResult, CancellationToken cancellationToken)
public override async Task<CommandResponse> ExecuteAsync(CommandContext context, TableListOptions options, CancellationToken cancellationToken)
{
if (!Validate(parseResult.CommandResult, context.Response).IsValid)
{
return context.Response;
}
var options = BindOptions(parseResult);
try
{
var tables = await _storageService.ListTables(
options.Account!,
options.Account,
options.Subscription!,
options.Tenant,
options.RetryPolicy,
cancellationToken);
context.Response.Results = ResponseResult.Create(new(tables ?? []), StorageJsonContext.Default.TableListCommandResult);
context.Response.Results = ResponseResult.Create(new TableListCommandResult(tables ?? []), StorageJsonContext.Default.TableListCommandResult);
}
catch (Exception ex)
{
@@ -55,5 +51,5 @@ public sealed class TableListCommand(ILogger<TableListCommand> logger, IStorageS
return context.Response;
}
internal record TableListCommandResult(List<string> Tables);
public record TableListCommandResult(List<string> Tables);
}
@@ -1,25 +1,42 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
using Azure.Mcp.Core.Options;
using Microsoft.Mcp.Core.Models;
using Microsoft.Mcp.Core.Options;
namespace Azure.Mcp.Tools.Storage.Options.Account;
public class AccountCreateOptions : SubscriptionOptions
public class AccountCreateOptions : ISubscriptionOption
{
[JsonPropertyName(StorageOptionDefinitions.AccountCreateName)]
public string? Account { get; set; }
[Option("The name of the Azure Storage account to create. Must be globally unique, 3-24 characters, lowercase letters and numbers only.")]
public required string Account { get; set; }
[JsonPropertyName(StorageOptionDefinitions.LocationName)]
public string? Location { get; set; }
[Option("The Azure region where the storage account will be created (e.g., 'eastus', 'westus2').")]
public required string Location { get; set; }
[JsonPropertyName(StorageOptionDefinitions.SkuName)]
[Option("The storage account SKU. Valid values: Standard_LRS, Standard_GRS, Standard_RAGRS, Standard_ZRS, Premium_LRS, Premium_ZRS, Standard_GZRS, Standard_RAGZRS.")]
public string? Sku { get; set; }
[JsonPropertyName(StorageOptionDefinitions.AccessTierName)]
[Option("The default access tier for blob storage. Valid values: Hot, Cool.")]
public string? AccessTier { get; set; }
[JsonPropertyName(StorageOptionDefinitions.EnableHierarchicalNamespaceName)]
[Option("Whether to enable hierarchical namespace (Data Lake Storage Gen2) for the storage account.")]
public bool? EnableHierarchicalNamespace { get; set; }
[Option(OptionDescriptions.ResourceGroup)]
public required string ResourceGroup { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
// TODO: Remove unused option — registered and visible to the user but never consumed by the command
[Option(OptionDescriptions.AuthMethod)]
public AuthMethod? AuthMethod { get; set; }
[Option(Name = "retry")]
public RetryPolicyOptions? RetryPolicy { get; set; }
}
@@ -1,6 +1,30 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Mcp.Core.Options;
using Microsoft.Mcp.Core.Models;
using Microsoft.Mcp.Core.Options;
namespace Azure.Mcp.Tools.Storage.Options.Account;
public class AccountGetOptions : BaseStorageOptions;
public class AccountGetOptions : ISubscriptionOption
{
[Option("The name of the Azure Storage account. This is the unique name you chose for your storage account (e.g., 'mystorageaccount').")]
public string? Account { get; set; }
[Option(OptionDescriptions.ResourceGroup)]
public string? ResourceGroup { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
// TODO: Remove unused option — registered and visible to the user but never consumed by the command
[Option(OptionDescriptions.AuthMethod)]
public AuthMethod? AuthMethod { get; set; }
[Option(Name = "retry")]
public RetryPolicyOptions? RetryPolicy { get; set; }
}
@@ -1,13 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
using Microsoft.Mcp.Core.Options;
namespace Azure.Mcp.Tools.Storage.Options;
public class BaseStorageOptions : SubscriptionOptions
{
[JsonPropertyName(StorageOptionDefinitions.AccountName)]
public string? Account { get; set; }
}
@@ -1,12 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace Azure.Mcp.Tools.Storage.Options.Blob;
public class BaseBlobOptions : BaseContainerOptions
{
[JsonPropertyName(StorageOptionDefinitions.BlobName)]
public string? Blob { get; set; }
}
@@ -1,12 +0,0 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
namespace Azure.Mcp.Tools.Storage.Options.Blob;
public class BaseContainerOptions : BaseStorageOptions
{
[JsonPropertyName(StorageOptionDefinitions.ContainerName)]
public string? Container { get; set; }
}
@@ -1,12 +1,36 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
using Azure.Mcp.Core.Options;
using Microsoft.Mcp.Core.Models;
using Microsoft.Mcp.Core.Options;
namespace Azure.Mcp.Tools.Storage.Options.Blob;
public class BlobGetOptions : BaseBlobOptions
public class BlobGetOptions : ISubscriptionOption
{
[JsonPropertyName(StorageOptionDefinitions.PrefixName)]
[Option("The name of the blob to access within the container. This should be the full path within the container (e.g., 'file.txt' or 'folder/file.txt').")]
public string? Blob { get; set; }
[Option("The prefix to filter blobs when listing blobs in a container. Only blobs whose names start with the specified prefix will be listed.")]
public string? Prefix { get; set; }
[Option("The name of the Azure Storage account. This is the unique name you chose for your storage account (e.g., 'mystorageaccount').")]
public required string Account { get; set; }
[Option("The name of the container to access within the storage account.")]
public required string Container { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
// TODO: Remove unused option — registered and visible to the user but never consumed by the command
[Option(OptionDescriptions.AuthMethod)]
public AuthMethod? AuthMethod { get; set; }
[Option(Name = "retry")]
public RetryPolicyOptions? RetryPolicy { get; set; }
}
@@ -1,12 +1,36 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
using Azure.Mcp.Core.Options;
using Microsoft.Mcp.Core.Models;
using Microsoft.Mcp.Core.Options;
namespace Azure.Mcp.Tools.Storage.Options.Blob;
public class BlobUploadOptions : BaseBlobOptions
public class BlobUploadOptions : ISubscriptionOption
{
[JsonPropertyName(StorageOptionDefinitions.LocalFilePathName)]
public string? LocalFilePath { get; set; }
[Option("The local file path to read content from or to write content to. This should be the full path to the file on your local system.")]
public required string LocalFilePath { get; set; }
[Option("The name of the Azure Storage account. This is the unique name you chose for your storage account (e.g., 'mystorageaccount').")]
public required string Account { get; set; }
[Option("The name of the container to access within the storage account.")]
public required string Container { get; set; }
[Option("The name of the blob to access within the container. This should be the full path within the container (e.g., 'file.txt' or 'folder/file.txt').")]
public required string Blob { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
// TODO: Remove unused option — registered and visible to the user but never consumed by the command
[Option(OptionDescriptions.AuthMethod)]
public AuthMethod? AuthMethod { get; set; }
[Option(Name = "retry")]
public RetryPolicyOptions? RetryPolicy { get; set; }
}
@@ -1,6 +1,30 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Mcp.Core.Options;
using Microsoft.Mcp.Core.Models;
using Microsoft.Mcp.Core.Options;
namespace Azure.Mcp.Tools.Storage.Options.Blob.Container;
public class ContainerCreateOptions : BaseContainerOptions;
public class ContainerCreateOptions : ISubscriptionOption
{
[Option("The name of the Azure Storage account. This is the unique name you chose for your storage account (e.g., 'mystorageaccount').")]
public required string Account { get; set; }
[Option("The name of the container to access within the storage account.")]
public required string Container { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
// TODO: Remove unused option — registered and visible to the user but never consumed by the command
[Option(OptionDescriptions.AuthMethod)]
public AuthMethod? AuthMethod { get; set; }
[Option(Name = "retry")]
public RetryPolicyOptions? RetryPolicy { get; set; }
}
@@ -1,12 +1,33 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Text.Json.Serialization;
using Azure.Mcp.Core.Options;
using Microsoft.Mcp.Core.Models;
using Microsoft.Mcp.Core.Options;
namespace Azure.Mcp.Tools.Storage.Options.Blob.Container;
public class ContainerGetOptions : BaseContainerOptions
public class ContainerGetOptions : ISubscriptionOption
{
[JsonPropertyName(StorageOptionDefinitions.PrefixName)]
[Option("The name of the Azure Storage account. This is the unique name you chose for your storage account (e.g., 'mystorageaccount').")]
public required string Account { get; set; }
[Option("The name of the container to access within the storage account.")]
public string? Container { get; set; }
[Option("The prefix to filter containers when listing containers in a storage account. Only containers whose names start with the specified prefix will be listed.")]
public string? Prefix { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
// TODO: Remove unused option — registered and visible to the user but never consumed by the command
[Option(OptionDescriptions.AuthMethod)]
public AuthMethod? AuthMethod { get; set; }
[Option(Name = "retry")]
public RetryPolicyOptions? RetryPolicy { get; set; }
}
@@ -0,0 +1,27 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using Azure.Mcp.Core.Options;
using Microsoft.Mcp.Core.Models;
using Microsoft.Mcp.Core.Options;
namespace Azure.Mcp.Tools.Storage.Options.Table;
public class TableListOptions : ISubscriptionOption
{
[Option("The name of the Azure Storage account. This is the unique name you chose for your storage account (e.g., 'mystorageaccount').")]
public required string Account { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
public string? Tenant { get; set; }
// TODO: Remove unused option — registered and visible to the user but never consumed by the command
[Option(OptionDescriptions.AuthMethod)]
public AuthMethod? AuthMethod { get; set; }
[Option(Name = "retry")]
public RetryPolicyOptions? RetryPolicy { get; set; }
}
@@ -2,19 +2,19 @@
// Licensed under the MIT License.
using System.Net;
using Azure.Mcp.Tests.Commands;
using Azure.Mcp.Tools.Storage.Commands;
using Azure.Mcp.Tools.Storage.Commands.Account;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Mcp.Core.Options;
using Microsoft.Mcp.Tests.Client;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Azure.Mcp.Tools.Storage.Tests.Account;
public class AccountCreateCommandTests : CommandUnitTestsBase<AccountCreateCommand, IStorageService>
public class AccountCreateCommandTests : SubscriptionCommandUnitTestsBase<AccountCreateCommand, IStorageService>
{
[Fact]
public void Constructor_InitializesCommandCorrectly()
@@ -3,19 +3,19 @@
using System.Net;
using Azure.Mcp.Core.Services.Azure;
using Azure.Mcp.Tests.Commands;
using Azure.Mcp.Tools.Storage.Commands;
using Azure.Mcp.Tools.Storage.Commands.Account;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Mcp.Core.Options;
using Microsoft.Mcp.Tests.Client;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Azure.Mcp.Tools.Storage.Tests.Account;
public class AccountGetCommandTests : CommandUnitTestsBase<AccountGetCommand, IStorageService>
public class AccountGetCommandTests : SubscriptionCommandUnitTestsBase<AccountGetCommand, IStorageService>
{
[Fact]
public async Task ExecuteAsync_NoParameters_ReturnsSubscriptions()
@@ -2,19 +2,19 @@
// Licensed under the MIT License.
using System.Net;
using Azure.Mcp.Tests.Commands;
using Azure.Mcp.Tools.Storage.Commands;
using Azure.Mcp.Tools.Storage.Commands.Blob;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Mcp.Core.Options;
using Microsoft.Mcp.Tests.Client;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Azure.Mcp.Tools.Storage.Tests.Blob;
public class BlobGetCommandTests : CommandUnitTestsBase<BlobGetCommand, IStorageService>
public class BlobGetCommandTests : SubscriptionCommandUnitTestsBase<BlobGetCommand, IStorageService>
{
[Fact]
public async Task ExecuteAsync_NoParameters_ReturnsBlobs()
@@ -0,0 +1,138 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
using System.Net;
using Azure.Mcp.Tests.Commands;
using Azure.Mcp.Tools.Storage.Commands;
using Azure.Mcp.Tools.Storage.Commands.Blob;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Mcp.Core.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Azure.Mcp.Tools.Storage.Tests.Blob;
public class BlobUploadCommandTests : SubscriptionCommandUnitTestsBase<BlobUploadCommand, IStorageService>
{
[Fact]
public void Constructor_InitializesCommandCorrectly()
{
Assert.Equal("upload", CommandDefinition.Name);
Assert.NotNull(CommandDefinition.Description);
Assert.NotEmpty(CommandDefinition.Description);
}
[Fact]
public async Task ExecuteAsync_UploadsBlob_WhenValidParametersProvided()
{
// Arrange
var subscription = "sub123";
var account = "testaccount";
var container = "testcontainer";
var blob = "testblob.txt";
var localFilePath = "/tmp/file.txt";
var expectedResult = new BlobUploadResult(blob, container, localFilePath,
DateTimeOffset.UtcNow, "\"etag\"", "md5hash");
Service.UploadBlob(
Arg.Is(account),
Arg.Is(container),
Arg.Is(blob),
Arg.Is(localFilePath),
Arg.Is(subscription),
Arg.Any<string?>(),
Arg.Any<RetryPolicyOptions?>(),
Arg.Any<CancellationToken>())
.Returns(expectedResult);
// Act
var response = await ExecuteCommandAsync(
"--subscription", subscription,
"--account", account,
"--container", container,
"--blob", blob,
"--local-file-path", localFilePath);
// Assert
var result = ValidateAndDeserializeResponse(response, StorageJsonContext.Default.BlobUploadResult);
Assert.Equal(blob, result.Blob);
Assert.Equal(container, result.Container);
Assert.Equal(localFilePath, result.UploadedFile);
}
[Fact]
public async Task ExecuteAsync_HandlesException()
{
// Arrange
var expectedError = "Upload failed";
Service.UploadBlob(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<RetryPolicyOptions?>(),
Arg.Any<CancellationToken>())
.ThrowsAsync(new Exception(expectedError));
// Act
var response = await ExecuteCommandAsync(
"--subscription", "sub123",
"--account", "testaccount",
"--container", "testcontainer",
"--blob", "testblob",
"--local-file-path", "/tmp/file.txt");
// Assert
Assert.NotNull(response);
Assert.Equal(HttpStatusCode.InternalServerError, response.Status);
Assert.Contains(expectedError, response.Message);
}
[Theory]
[InlineData("--subscription sub123 --account acct --container cont --blob b --local-file-path /f", true)]
[InlineData("--account acct --container cont --blob b --local-file-path /f", false)] // Missing subscription
[InlineData("--subscription sub123 --container cont --blob b --local-file-path /f", false)] // Missing account
[InlineData("--subscription sub123 --account acct --blob b --local-file-path /f", false)] // Missing container
[InlineData("--subscription sub123 --account acct --container cont --local-file-path /f", false)] // Missing blob
[InlineData("--subscription sub123 --account acct --container cont --blob b", false)] // Missing local-file-path
[InlineData("", false)] // No parameters
public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed)
{
if (shouldSucceed)
{
var expectedResult = new BlobUploadResult("b", "cont", "/f",
DateTimeOffset.UtcNow, "\"etag\"", "md5hash");
Service.UploadBlob(
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<string?>(),
Arg.Any<RetryPolicyOptions?>(),
Arg.Any<CancellationToken>())
.Returns(expectedResult);
}
// Act
var response = await ExecuteCommandAsync(args);
// Assert
Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status);
if (shouldSucceed)
{
Assert.NotNull(response.Results);
Assert.Equal("Success", response.Message);
}
else
{
Assert.Contains("required", response.Message, StringComparison.OrdinalIgnoreCase);
}
}
}
@@ -2,19 +2,19 @@
// Licensed under the MIT License.
using System.Net;
using Azure.Mcp.Tests.Commands;
using Azure.Mcp.Tools.Storage.Commands;
using Azure.Mcp.Tools.Storage.Commands.Blob.Container;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Mcp.Core.Options;
using Microsoft.Mcp.Tests.Client;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Azure.Mcp.Tools.Storage.Tests.Blob.Container;
public class ContainerCreateCommandTests : CommandUnitTestsBase<ContainerCreateCommand, IStorageService>
public class ContainerCreateCommandTests : SubscriptionCommandUnitTestsBase<ContainerCreateCommand, IStorageService>
{
[Fact]
public void Constructor_InitializesCommandCorrectly()
@@ -2,19 +2,19 @@
// Licensed under the MIT License.
using System.Net;
using Azure.Mcp.Tests.Commands;
using Azure.Mcp.Tools.Storage.Commands;
using Azure.Mcp.Tools.Storage.Commands.Blob.Container;
using Azure.Mcp.Tools.Storage.Models;
using Azure.Mcp.Tools.Storage.Services;
using Microsoft.Mcp.Core.Options;
using Microsoft.Mcp.Tests.Client;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Azure.Mcp.Tools.Storage.Tests.Blob.Container;
public class ContainerGetCommandTests : CommandUnitTestsBase<ContainerGetCommand, IStorageService>
public class ContainerGetCommandTests : SubscriptionCommandUnitTestsBase<ContainerGetCommand, IStorageService>
{
[Fact]
public async Task ExecuteAsync_NoParameters_ReturnsContainers()
@@ -2,18 +2,18 @@
// Licensed under the MIT License.
using System.Net;
using Azure.Mcp.Tests.Commands;
using Azure.Mcp.Tools.Storage.Commands;
using Azure.Mcp.Tools.Storage.Services;
using Azure.Mcp.Tools.Storage.Table.Commands;
using Microsoft.Mcp.Core.Options;
using Microsoft.Mcp.Tests.Client;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;
namespace Azure.Mcp.Tools.Storage.Tests.Table;
public class TableListCommandTests : CommandUnitTestsBase<TableListCommand, IStorageService>
public class TableListCommandTests : SubscriptionCommandUnitTestsBase<TableListCommand, IStorageService>
{
private readonly string _knownStorageAccount = "storage123";
private readonly string _knownSubscription = "sub123";