mirror of
https://github.com/microsoft/mcp.git
synced 2026-11-04 12:01:31 +00:00
Convert Storage toolset commands to BaseCommand<TOptions, TResult> pattern (#2656)
This commit is contained in:
1 parent
72ba91acf2
commit
7a1e374ff5
34 files changed
+1084
-373
No files matched your search
+7
@@ -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()
|
||||
{
|
||||
|
||||
+21
@@ -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)]
|
||||
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
+166
@@ -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
-14
@@ -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;
|
||||
}
|
||||
}
|
||||
+9
-13
@@ -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
-2
@@ -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()
|
||||
|
||||
+2
-2
@@ -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
-2
@@ -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()
|
||||
|
||||
+138
@@ -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
-2
@@ -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
-2
@@ -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
-2
@@ -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";
|
||||
|
||||
Reference in new issue
Block a user