# Add Azure MCP Tools

> Add a new tool/command to any Azure MCP toolset. Full lifecycle from scaffolding through PR submission. USE WHEN: add new command, create tool, new MCP tool, scaffold command, implement operation, add azure service tool, create new toolset.

- Skill: `microsoft/add-azure-mcp-tools` (Agent Skill)
- Install (CLI): `npx skillmds@latest add microsoft/add-azure-mcp-tools`
- Raw SKILL.md: https://api.skillmd.com/api/skills/microsoft/add-azure-mcp-tools/raw
- Safety review: pending (external: skill-scanner PASS, skillspector PASS)
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: DevOps & Infra
- Author: Microsoft (https://skillmd.com/u/microsoft)
- Updated: 2026-09-10
- Page: https://skillmd.com/skills/microsoft/add-azure-mcp-tools

---


# Add a New Tool to Azure MCP

## Purpose

Step-by-step workflow for adding a new command to any Azure MCP toolset.
Each phase has an explicit **gate** — do not proceed until the gate passes.

## Decision: New Toolset or Existing?

Before starting, determine:

- **Adding to existing toolset?** → Skip to Phase 1.
- **Creating a new toolset?** → Complete Phase 0 first.

**⚠️ CRITICAL: Does your command interact with Azure resources?**

| | Azure Service Commands | Non-Azure Commands |
|---|---|---|
| **Examples** | ACR Registry List, SQL Database List, Storage Account Get | CLI wrappers, Best Practices, Documentation tools |
| **test-resources.bicep** | ✅ Required | ❌ Skip |
| **test-resources-post.ps1** | ✅ Required (even if basic) | ❌ Skip |
| **RBAC role assignments** | ✅ Required | ❌ Skip |
| **Live tests** | ✅ Required (recorded) | ❌ Skip |
| **Unit tests** | ✅ Required | ✅ Required |

---

## Security Requirements

> **Terminology note:** In this repo, _tool_ refers to the MCP-exposed capability and _command_ refers to the underlying C# command class implementation. Both terms appear in the codebase and docs.

**All tool inputs are untrusted.** These requirements apply at every phase and are not optional.

### Input Validation
- Validate inputs against the **specific naming rules of the Azure resource** being targeted (length, allowed characters, casing). Do not apply a generic blocklist — Azure resource naming rules vary significantly by service.
  - Example: Storage account names are 3–24 lowercase alphanumeric characters only.
  - Example: Resource group names allow letters, digits, underscores, hyphens, and periods up to 90 characters.
  - Reference: [Azure naming rules and restrictions](https://learn.microsoft.com/azure/azure-resource-manager/management/resource-name-rules)
- Use `ValidateOptions` for semantic constraints beyond nullability (name length, format, mutual exclusivity, and allowed value sets). Only reject characters that are provably invalid for the specific resource type. This applies to both the new two-generic `SubscriptionCommand` pattern and the legacy one-generic pattern — see the `ValidateOptions` override guidance in [Phase 1d](#1d-command-class).
- Prefer SDK/runtime validators and deterministic checks first (`Length`, explicit allowed-value sets, character/category checks).

### Secure Logging
- **Never log raw option objects** (`{@Options}`) — they may contain secrets, connection strings, or PII.
- Log only individually named, known-safe parameters. For example: `options.Subscription`, `options.ResourceGroup`, `Name`.
- Do not include sensitive field values in error messages returned to callers.
- Strip or redact secret values before surfacing exception details.

```csharp
// ✅ Log only known-safe, individually named fields
_logger.LogError(ex, "Error in {Operation}. Subscription: {Subscription}, ResourceGroup: {ResourceGroup}",
    Name, options.Subscription, options.ResourceGroup);

// ❌ Never log the whole options object — it may contain keys, connection strings, or PII
_logger.LogError(ex, "Error in {Operation}. Options: {@Options}", Name, options);

// ❌ Never surface raw exception bodies to callers — they may contain tokens or account metadata
return $"Request failed: {requestFailedException.Message}"; // may include auth headers
```

### Safe Downstream Interactions
- **Never concatenate user input directly without prior validation** into URLs, shell commands, resource identifiers, query strings, etc.
- Use `EndpointValidator` from `Microsoft.Mcp.Core.Helpers` to guard all endpoint usage — choose the method that matches your scenario:
  - **Azure service data-plane endpoint** (endpoint derived from a resource name, e.g. storage account, ACR, App Config): call `EndpointValidator.ValidateAzureServiceEndpoint(endpoint, serviceType, AzureService.CloudConfiguration.ArmEnvironment)` before constructing the client. This enforces the correct per-cloud domain suffix (e.g. `.blob.core.windows.net` / `.blob.core.chinacloudapi.cn`) and HTTPS.
  - **User-supplied URL to a known external service** (e.g. a GitHub URL the user provides): call `EndpointValidator.ValidateExternalUrl(url, allowedHosts)` with an explicit allowlist of permitted hosts.
  - **User-supplied target URL with no known domain** (e.g. a load-test target the user controls): call `EndpointValidator.ValidatePublicTargetUrl(url)`, which enforces HTTPS/HTTP-only schemes, rejects private/reserved IP ranges, rejects reserved hostnames, and resolves DNS to catch hostnames that map to internal IPs.
- For services that *construct* the endpoint internally (not from user input), use the cloud-type switch pattern (see [Phase 1c: Service Implementation](#1c-service-interface-and-implementation)) — `EndpointValidator` is not required in that case but `ValidateAzureServiceEndpoint` can be added as a defense-in-depth layer.
- **Control-plane operations** (ARM resource creation, RBAC assignments, policy etc.) do not need `EndpointValidator` because they go through the typed Azure SDK ARM client. Construct ARM resource IDs using `ResourceIdentifier` or collection helpers — never by string-interpolating subscription/resource-group/resource-name directly into a raw ARM path.
- For new commands and services, always pass `CancellationToken` as the final parameter to all async downstream calls and propagate it throughout — never substitute `CancellationToken.None` or `default` at call sites.
- Fail closed: if tenant, subscription, or resource context is ambiguous, return an explicit validation error and require the caller to specify the value. Do not silently pick a default.

### MCP-Specific Threat Patterns

| Threat | Established mitigation in this project |
|--------|----------------------------------------|
| Input abuse (oversized/malformed names) | Override `ValidateOptions` with resource-specific length and format checks using deterministic validation first (length bounds, allowed-value sets, character/category checks). For query inputs, use a dedicated validator class — see `CosmosQueryValidator.EnsureReadOnlySelect` (`tools/Azure.Mcp.Tools.Cosmos/src/Validation/CosmosQueryValidator.cs`) as a reference for length cap, keyword blocking, and injection pattern detection. |
| Injection into downstream systems | For user-supplied queries: use a validator class that enforces a single read-only statement, caps length, strips/blocks dangerous tokens, and detects tautology patterns. Do not interpolate user input into query strings directly — prefer parameterized APIs where available. For blob/resource URIs: call `EndpointValidator.ValidateAzureServiceEndpoint` before constructing any client (see `tools/Azure.Mcp.Tools.Compute/src/Services/ComputeService.cs` blob URI handling as a reference). |
| Secret leakage via logs or error responses | Log only individually named, non-sensitive fields: `options.Subscription`, `options.ResourceGroup`, `Name`. Never use `{@Options}` or log connection strings, keys, or endpoint values. Override `GetErrorMessage` to return actionable but non-revealing messages — strip raw `RequestFailedException` bodies that may contain tokens or account metadata. |
| Cross-tenant/resource confusion | `SubscriptionCommand` base class enforces that `--subscription` is always present and resolved via `ISubscriptionResolver` before `ExecuteAsync` is called. Pass `options.Tenant` to all service calls so `IAzureService` can validate tenant context per-request. Fail explicitly if tenant context is ambiguous — do not fall back silently. |
| SSRF-like endpoint misuse | Use `EndpointValidator` from `Microsoft.Mcp.Core.Helpers`: `ValidateAzureServiceEndpoint(endpoint, serviceType, armEnvironment)` for Azure data-plane endpoints, `ValidateExternalUrl(url, allowedHosts)` for user-supplied URLs to known hosts, `ValidatePublicTargetUrl(url)` for arbitrary user-controlled targets (DNS-resolves and blocks private/reserved IPs). |

### Using AI to Generate Tool Code

When using an AI assistant (such as GitHub Copilot) to scaffold or generate command, service, or test code, include the following in every prompt:

```
Requirements:
- Validate user-controlled inputs in `ValidateOptions` using resource-specific rules (naming rules, allowed values, length caps) where applicable; do not use one generic rule for all options.
- Prefer SDK/runtime validators and deterministic checks for user input validation.
- Log only individually named, known-safe parameters; never log option objects, credentials, keys, connection strings, or other secret-bearing fields.
- For endpoint/URL inputs, use `EndpointValidator` methods appropriate to the scenario (`ValidateAzureServiceEndpoint`, `ValidateExternalUrl`, or `ValidatePublicTargetUrl`). Avoid direct interpolation of unvalidated input into URLs or downstream queries.
- Add negative tests for relevant security cases introduced by the command (for example malformed names, invalid endpoint hosts, or unsafe query text) rather than a one-size-fits-all set of tests.
- Keep error messages actionable but non-revealing: avoid exposing stack traces, raw backend payloads, or sensitive values to callers.
```

Review every AI-generated snippet for these properties before committing. Generated code that omits them must be corrected before the security gate in [Phase 7](#phase-7-pr-checklist) can be met.

---

## Phase 0: New Toolset Setup (skip if adding to existing toolset)

Create the toolset directory structure:

```
tools/Azure.Mcp.Tools.{Toolset}/
├── src/
│   ├── Azure.Mcp.Tools.{Toolset}.csproj
│   ├── {Toolset}Setup.cs
│   ├── Commands/
│   │   ├── {Resource}/
│   │   │   └── {Resource}{Operation}Command.cs
│   │   └── {Toolset}JsonContext.cs
│   ├── Options/
│   │   └── {Resource}/
│   │       └── {Resource}{Operation}Options.cs
│   ├── Services/
│   │   ├── I{Toolset}Service.cs
│   │   └── {Toolset}Service.cs
│   └── Models/
└── tests/
    ├── Azure.Mcp.Tools.{Toolset}.Tests/
    │   └── Azure.Mcp.Tools.{Toolset}.Tests.csproj
    ├── test-resources.bicep          (Azure service commands only)
    └── test-resources-post.ps1       (Azure service commands only)
```

Required setup steps:

1. Add package version to `Directory.Packages.props` (if Azure SDK needed)
 2. Register the project in solution files by running:
    `pwsh eng/scripts/Update-Solutions.ps1 -All`
3. Register the new toolset in `servers/Azure.Mcp.Server/src/Program.cs` `RegisterAreas()` (alphabetical order)
 4. Choose the appropriate base class:
    - **Commands that need an Azure subscription** (most Azure service tools) → inherit from `SubscriptionCommand<TOptions, TResult>` and inject `ISubscriptionResolver`.
    - **Commands that do NOT need a subscription** (CLI wrappers, documentation tools, best-practice advisors) → inherit from `BaseCommand<TOptions, TResult>` directly.
 
    Only add a shared intermediate base command if you have real cross-command logic shared by multiple commands in the same toolset.
5. Register both the service **and the command** as singletons in `{Toolset}Setup.cs` `ConfigureServices`:
   ```csharp
   public void ConfigureServices(IServiceCollection services)
   {
       services.AddSingleton<I{Toolset}Service, {Toolset}Service>();
       services.AddSingleton<{Resource}{Operation}Command>();
   }
   ```

**GATE:** `dotnet build servers/Azure.Mcp.Server/src` must pass.

---

## Phase 1: Implement Command

Create these files in order:

### 1a. Options Class (with `[Option]` attributes)

File: `src/Options/{Resource}/{Resource}{Operation}Options.cs`

```csharp
using Azure.Mcp.Core.Options;
using Microsoft.Mcp.Core.Models;
using Microsoft.Mcp.Core.Options;

namespace Azure.Mcp.Tools.{Toolset}.Options.{Resource};

public class {Resource}{Operation}Options : ISubscriptionOption
{
    [Option("Description of what this option does (e.g., 'The name of the resource').")]
    public string? MyOption { 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; }
}
```

Rules:
- Implement `ISubscriptionOption` for commands that need subscription resolution
- Use `[Option("description")]` for the description — property name auto-converts to `--kebab-case`
- Use `[Option(Name = "custom")]` only when the default kebab-case conversion is wrong (e.g., when property is named `FooBar` and has `[Option(Name = "foobar")]` you get `--foobar` instead of `--foo-bar`)
- Use `[Option(OptionDescriptions.X)]` for shared descriptions (`Subscription`, `Tenant`, `ResourceGroup`, `AuthMethod`)
- Use `[OptionContainer<TContainer>(Prefix = "prefix")]` for model types which contain nested parameters. `"prefix"` will be prepended to the `[Option]`s in the model type (e.g., when `[OptionContainer<TContainer>(Prefix = "foo")]`'s model contains `[Option(Name = "bar")]` the parameter name is `--foo-bar`).
- Use `subscription` (never `subscriptionId`) — supports both IDs and names
- Use `resourceGroup` (never `resourceGroupName`)
- Use singular nouns for resources (`server` not `serverName`)
- Remove unnecessary `name` suffixes (`Account` / `--account` not `AccountName` / `--account-name`)
- Use `required` on required options; use nullable types (`?`) for optional options.
- Non-nullable value types (e.g., `public int Count { get; set; }`) are always valid without `required` — they default to `0`. Use `required` if the caller must explicitly provide a value, or use `int?` if the parameter should be truly optional.
- Order: command-specific options first, then `ResourceGroup`, `Subscription`, `Tenant`, `AuthMethod`
- Keep parameter names consistent with Azure SDK parameters when possible

 > **Note:** Options are defined entirely via `[Option]` attributes.
 > A static `{Toolset}OptionDefinitions` class is not needed

### 1c. Service Interface and Implementation

File: `src/Services/I{Toolset}Service.cs`

Return type depends on operation type:
- **Resource Graph queries** → `Task<ResourceQueryResults<MyModel>>` (includes `AreResultsTruncated` flag)
- **Data plane operations** → `Task<List<MyModel>>`
- **Write operations** → `Task<MyResultModel>`

```csharp
public interface I{Toolset}Service
{
    // Resource Graph read operation
    Task<ResourceQueryResults<MyModel>> GetResourcesAsync(
        string? myOption,
        string subscription,
        string? resourceGroup = null,
        string? tenant = null,
        CancellationToken cancellationToken = default);

    // Data plane operation (returns simple List)
    Task<List<MyDetail>> GetDetailsAsync(
        string resourceName,
        string subscription,
        string? tenant = null,
        CancellationToken cancellationToken = default);
}
```

File: `src/Services/{Toolset}Service.cs`

Choose base class:
 - **Operations that need Resource Graph (ARG) queries:** inherit `BaseAzureResourceService`
 - **All other operations (ARM, data plane):** inherit `BaseAzureService`
 
 > `BaseAzureResourceService` extends `BaseAzureService` — neither is
 > inherently read-only or write-only. The distinction is whether you
 > need ARG querying functionality.

```csharp
public class {Toolset}Service(IAzureService azureService)
    : BaseAzureResourceService(azureService), I{Toolset}Service
{
    public async Task<ResourceQueryResults<MyModel>> GetResourcesAsync(
        string? myOption,
        string subscription,
        string? resourceGroup = null,
        string? tenant = null,
        CancellationToken cancellationToken = default)
    {
        return await ExecuteResourceQueryAsync(
            "Microsoft.{Provider}/{resourceType}",
            resourceGroup,
            subscription,
            null,
            ConvertToModel,
            tenant: tenant,
            cancellationToken: cancellationToken);
    }

    private static MyModel ConvertToModel(JsonElement item)
    {
        var data = MyModelData.FromJson(item);
        return new MyModel(
            Name: data.ResourceName,
            Id: data.ResourceId,
            Location: data.Location.ToString(),
            Tags: data.Tags as IReadOnlyDictionary<string, string>
        );
    }
}
```

For **write operations** (using direct ARM clients):
```csharp
public class {Toolset}Service(IAzureService azureService)
    : BaseAzureService(azureService), I{Toolset}Service
{
    public async Task<MyResource> CreateResourceAsync(
        string resourceName,
        string resourceGroup,
        string subscription,
        string? tenant = null,
        CancellationToken cancellationToken = default)
    {
        var subscriptionResource = await AzureService.GetSubscription(subscription, tenant, cancellationToken: cancellationToken);

        // CRITICAL: Use GetResourceGroupAsync with await
        var rgResource = await subscriptionResource.GetResourceGroupAsync(resourceGroup, cancellationToken);
        var resource = await rgResource.Value
            .GetMyResources()
            .GetAsync(resourceName, cancellationToken: cancellationToken);
        return resource.Value;
    }
}
```

Sovereign cloud rules:
- ARM/Resource Graph operations: cloud-aware automatically, no extra work
- Data plane endpoints: use `AzureService.CloudConfiguration.CloudType` switch — never hardcode URLs

**Data plane endpoint pattern** (required for services like Storage, Cosmos, Search):

```csharp
public class MyService(IAzureService azureService)
    : BaseAzureResourceService(azureService), IMyService
{

    private async Task<MyDataPlaneClient> CreateDataPlaneClientAsync(
        string resourceName,
        string? tenant = null,
        CancellationToken cancellationToken = default)
    {
        var endpoint = GetResourceEndpoint(resourceName);
        var options = AddDefaultPolicies(new MyClientOptions());
        options.Transport = new HttpClientTransport(AzureService.GetClient());
        return new MyDataPlaneClient(
            new Uri(endpoint),
            await GetCredential(tenant, cancellationToken),
            options);
    }

    private string GetResourceEndpoint(string resourceName)
    {
        return AzureService.CloudConfiguration.CloudType switch
        {
            AzureCloudConfiguration.AzureCloud.AzurePublicCloud =>
                $"https://{resourceName}.service.core.windows.net",
            AzureCloudConfiguration.AzureCloud.AzureChinaCloud =>
                $"https://{resourceName}.service.core.chinacloudapi.cn",
            AzureCloudConfiguration.AzureCloud.AzureUSGovernmentCloud =>
                $"https://{resourceName}.service.core.usgovcloudapi.net",
            _ => $"https://{resourceName}.service.core.windows.net"
        };
    }
}
```

Patterns and anti-patterns:
```csharp
// ❌ Hardcoded public-cloud endpoint
var client = new BlobServiceClient(new($"https://{account}.blob.core.windows.net"), credential, options);

// ❌ Hardcoded connection string
var connectionString = $"AccountEndpoint=https://{server}.documents.azure.com:443/;...";

// ✅ Cloud-aware endpoint via switch expression
var endpoint = GetBlobEndpoint(account);
var client = new BlobServiceClient(new(endpoint), credential, options);
```

Reference implementations: `StorageService`, `CosmosService`, `SearchService`, `ConfidentialLedgerService`.

### 1d. Long-running operations

Long-running operations (at this time) don't offer the ability to configure polling intervals, and even if they were
able to there is a limit on how small of a polling interval can be used. Due to this, to prevent long-running operations
with a significant number of polls from wasting CPU time waiting during testing, all long-running operations should use
a two call pattern. The first call is the service method starting the polling operation, that should pass
`WaitUntil.Started` to simply begin the operation. Then waiting for completion should call
`BaseAzureService.WaitForLroCompletionAsync` to wait for completion in a way that testing can ignore the polling
interval to prevent CPU wait loops that aren't necessary when playback testing.

```csharp
var lroOperation = Service.LroAsync(WaitUntil.Started, cancellationToken);
await WaitForLroCompletionAsync(lroOperation, cancellationToken);
```

### 1e. Command Class

File: `src/Commands/{Resource}/{Resource}{Operation}Command.cs`

**Required using statements:**
```csharp
using Azure.Mcp.Core.Commands.Subscription;
using Azure.Mcp.Core.Services.Azure.Subscription;
using Azure.Mcp.Tools.{Toolset}.Models;
using Azure.Mcp.Tools.{Toolset}.Options.{Resource};
using Azure.Mcp.Tools.{Toolset}.Services;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Commands;
using Microsoft.Mcp.Core.Models.Command;
```

```csharp
[CommandMetadata(
    Id = "<generate-new-guid>",
    Name = "operation",
    Title = "Human Readable Title",
    Description = """
        What this command does. Include required options and return format.
        """,
    Destructive = false,
    Idempotent = true,
    OpenWorld = false,
    ReadOnly = true,
    Secret = false,
    LocalRequired = false)]
public sealed class {Resource}{Operation}Command(
    ILogger<{Resource}{Operation}Command> logger,
    I{Toolset}Service service,
    ISubscriptionResolver subscriptionResolver)
    : SubscriptionCommand<{Resource}{Operation}Options, {Resource}{Operation}Command.{Resource}{Operation}CommandResult>(subscriptionResolver)
{
    private readonly ILogger<{Resource}{Operation}Command> _logger = logger;
    private readonly I{Toolset}Service _service = service;

    public override async Task<CommandResponse> ExecuteAsync(
        CommandContext context, {Resource}{Operation}Options options, CancellationToken cancellationToken)
    {
        try
        {
            var results = await _service.GetResourcesAsync(
                options.MyOption,
                options.Subscription!,
                options.ResourceGroup,
                options.Tenant,
                cancellationToken);

            context.Response.Results = ResponseResult.Create(
                new {Resource}{Operation}CommandResult(results?.Results ?? [], results?.AreResultsTruncated ?? false),
                {Toolset}JsonContext.Default.{Resource}{Operation}CommandResult);
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Error in {Operation}. Subscription: {Subscription}",
                Name, options.Subscription);
            HandleException(context, ex);
        }

        return context.Response;
    }

    public record {Resource}{Operation}CommandResult(List<MyModel> Items, bool AreResultsTruncated);
}
```

**Key points (two-generic pattern from `docs/option-conversion.md`):**
- Two generic parameters: `SubscriptionCommand<TOptions, TResult>` — `TResult` is the command's result record
- `ISubscriptionResolver` injected via primary constructor and passed to base
- `ExecuteAsync` receives **pre-bound `TOptions options`** — no `ParseResult` parameter
- No `RegisterOptions()`/`BindOptions()` overrides needed — `OptionBinder` handles binding via `[Option]` attributes
- No manual `Validate()` call — framework validates based on nullability and `ValidateOptions()` override
- Result record is `public` (for JSON serialization context visibility) and declared inside the command class
- **DO NOT** log `{@Options}` — may expose sensitive information

**Custom validation** (required for semantic and security constraints beyond nullability):
```csharp
public override void ValidateOptions({Resource}{Operation}Options options, ValidationResult validationResult)
{
    base.ValidateOptions(options, validationResult);  // checks --subscription

    // Required-field check
    if (string.IsNullOrEmpty(options.MyRequiredField))
    {
        validationResult.Errors.Add("--my-required-field is required.");
    }

    // Security: validate against the specific Azure resource's naming rules.
    // Prefer deterministic checks first (length + character/category checks).
    // Look up exact constraints at:
    // https://learn.microsoft.com/azure/azure-resource-manager/management/resource-name-rules
    //
    // Example for a Storage account name (3–24 lowercase alphanumeric only):
    if (options.Account is not null &&
        !IsValidStorageAccountName(options.Account))
    {
        validationResult.Errors.Add("--account must be 3–24 lowercase alphanumeric characters (storage account naming rule).");
    }
}

private static bool IsValidStorageAccountName(string value)
{
    if (value.Length is < 3 or > 24)
        return false;

    foreach (var ch in value)
    {
        if (!((ch >= 'a' && ch <= 'z') || (ch >= '0' && ch <= '9')))
            return false;
    }

    return true;
}
```

**Intermediate base commands** (only if you have shared cross-command logic):
```csharp
// Use interface constraints for type-safe access to shared options
public abstract class Base{Toolset}Command<
    [DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions, TResult>(
    ISubscriptionResolver subscriptionResolver)
    : SubscriptionCommand<TOptions, TResult>(subscriptionResolver)
    where TOptions : class, ISubscriptionOption, I{Toolset}Option
{
    public override void ValidateOptions(TOptions options, ValidationResult validationResult)
    {
        base.ValidateOptions(options, validationResult);
        // Shared validation using options.SharedProperty
    }
}
```

### 1f. JSON Serialization Context

File: `src/Commands/{Toolset}JsonContext.cs`

```csharp
[JsonSerializable(typeof({Resource}{Operation}Command.{Resource}{Operation}CommandResult))]
[JsonSerializable(typeof(MyModel))]
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull)]
internal partial class {Toolset}JsonContext : JsonSerializerContext;
```

Guidelines:
- Only include types actually serialized as top-level result payloads
- Keep `[JsonSerializable]` attributes sorted by `typeof` model name
- Use one context per toolset always
- Filename must match class name (`{Toolset}JsonContext.cs`)
- Use `{Toolset}JsonContext.Default.{CommandResult}` when serializing — never `JsonSerializer.Deserialize<T>()` without a context

### 1g. Register Command

File: `src/{Toolset}Setup.cs`

```csharp
public class {Toolset}Setup : IAreaSetup
{
    public string Name => "{toolset}";

    public string Title => "Manage Azure {Toolset}";

    public void ConfigureServices(IServiceCollection services)
    {
        services.AddSingleton<I{Toolset}Service, {Toolset}Service>();

        // Register all commands as singletons
        services.AddSingleton<{Resource}{Operation}Command>();
    }

    public CommandGroup RegisterCommands(IServiceProvider serviceProvider)
    {
        var root = new CommandGroup(Name,
            """
            {Toolset} operations - description of what this toolset covers.
            """,
            Title);

        var resource = new CommandGroup("{resource}", "{Resource} operations description");
        root.AddSubGroup(resource);
        resource.AddCommand<{Resource}{Operation}Command>(serviceProvider);

        return root;
    }
}
```

Also register the toolset in `servers/Azure.Mcp.Server/src/Program.cs`:
```csharp
private static IAreaSetup[] RegisterAreas()
{
    return [
        // ... existing toolsets (alphabetical order) ...
        new Azure.Mcp.Tools.{Toolset}.{Toolset}Setup(),
        // ... more toolsets ...
    ];
}
```

The `RegisterAreas()` list **must remain alphabetically sorted** (excluding the `#if !BUILD_NATIVE` block).

Command group naming: concatenated lowercase or dash-separated. Never underscores.
- ✅ Good: `"entraadmin"`, `"resourcegroup"`, `"storageaccount"`, `"entra-admin"`
- ❌ Bad: `"entra_admin"`, `"resource_group"`, `"storage_account"`

Command hierarchy patterns and anti-patterns:
- ✅ Good: `azmcp postgres server param set` (command groups: server → param, operation: set)
- ❌ Bad: `azmcp postgres server setparam` (mixed operation `setparam` at same level)
- ✅ Good: `azmcp storage blob upload permission set`
- ❌ Bad: `azmcp storage blobupload`

This pattern improves discoverability and allows grouping related operations.

**GATE:** `dotnet build tools/Azure.Mcp.Tools.{Toolset}/src` must pass with 0 errors.

---

## Phase 2: Unit Tests

File: `tests/Azure.Mcp.Tools.{Toolset}.Tests/{Resource}/{Resource}{Operation}CommandTests.cs`

### Required test methods

```csharp
using System.Net;
using Azure.Mcp.Core.Services.Azure;
using Azure.Mcp.Tests.Commands;
using Azure.Mcp.Tools.{Toolset}.Commands;
using Azure.Mcp.Tools.{Toolset}.Commands.{Resource};
using Azure.Mcp.Tools.{Toolset}.Models;
using Azure.Mcp.Tools.{Toolset}.Services;
using Microsoft.Mcp.Core.Options;
using NSubstitute;
using NSubstitute.ExceptionExtensions;
using Xunit;

namespace Azure.Mcp.Tools.{Toolset}.Tests.{Resource};

public class {Resource}{Operation}CommandTests
    : SubscriptionCommandUnitTestsBase<{Resource}{Operation}Command, I{Toolset}Service>
{
    [Fact]
    public void Constructor_InitializesCommandCorrectly()
    {
        var command = Command.GetCommand();
        Assert.Equal("operation", command.Name);
        Assert.NotNull(command.Description);
        Assert.NotEmpty(command.Description);
    }

    [Theory]
    [InlineData("--my-option val --subscription sub123", true)]
    [InlineData("--subscription sub123", true)]  // my-option is optional
    [InlineData("", false)]  // missing args
    public async Task ExecuteAsync_ValidatesInputCorrectly(string args, bool shouldSucceed)
    {
        if (shouldSucceed)
        {
            Service.GetResourcesAsync(
                Arg.Any<string?>(),
                Arg.Any<string>(),
                Arg.Any<string?>(),
                Arg.Any<string?>(),
                Arg.Any<CancellationToken>())
                .Returns(new ResourceQueryResults<MyModel>([], false));
        }

        var response = await ExecuteCommandAsync(args);

        Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status);
        if (!shouldSucceed)
            Assert.Contains("required", response.Message.ToLower());
    }

    [Fact]
    public async Task ExecuteAsync_DeserializationValidation()
    {
        Service.GetResourcesAsync(
            Arg.Any<string?>(),
            Arg.Any<string>(),
            Arg.Any<string?>(),
            Arg.Any<string?>(),
            Arg.Any<CancellationToken>())
            .Returns(new ResourceQueryResults<MyModel>([], false));

        var response = await ExecuteCommandAsync("--subscription", "sub123");

        var result = ValidateAndDeserializeResponse(
            response, {Toolset}JsonContext.Default.{Resource}{Operation}CommandResult);
        Assert.Empty(result.Items);
    }

    [Fact]
    public async Task ExecuteAsync_HandlesServiceErrors()
    {
        Service.GetResourcesAsync(
            Arg.Any<string?>(),
            Arg.Any<string>(),
            Arg.Any<string?>(),
            Arg.Any<string?>(),
            Arg.Any<CancellationToken>())
            .ThrowsAsync(new Exception("Test error"));

        var response = await ExecuteCommandAsync("--subscription", "sub123", "--my-option", "val");

        Assert.Equal(HttpStatusCode.InternalServerError, response.Status);
        Assert.Contains("Test error", response.Message);
        Assert.Contains("troubleshooting", response.Message);
    }

    [Fact]
    public async Task ExecuteAsync_HandlesNotFound()
    {
        Service.GetResourcesAsync(
            Arg.Any<string?>(),
            Arg.Any<string>(),
            Arg.Any<string?>(),
            Arg.Any<string?>(),
            Arg.Any<CancellationToken>())
            .ThrowsAsync(new RequestFailedException((int)HttpStatusCode.NotFound, "Resource not found"));

        var response = await ExecuteCommandAsync("--subscription", "sub123", "--my-option", "val");

        Assert.Equal(HttpStatusCode.NotFound, response.Status);
        Assert.Contains("Resource not found", response.Message);
    }
}
```

**Critical: Choose the correct test base class:**
 - Commands extending `SubscriptionCommand` → use `SubscriptionCommandUnitTestsBase<TCommand, TService>`
 - Commands extending `BaseCommand` directly (no subscription) → use `CommandUnitTestsBase<TCommand, TService>`
 
 Using the wrong base class will cause DI failures.

**Prefer string args over constructing options directly.** Using `ExecuteCommandAsync("--account", ...)` tests the full pipeline: `[Option]` attribute registration, `OptionBinder` parsing, and `SubscriptionResolver` post-processing.

Mock rules:
- Use `Arg.Any<CancellationToken>()` for CancellationToken in mocks
- Use `TestContext.Current.CancellationToken` when invoking real code
- Use `Arg.Is(value)` or the value directly for specific match assertions
- Never pass `CancellationToken.None` or `default` in test code

Deserialization rules:
- Use `{Toolset}JsonContext.Default.{Operation}CommandResult` for deserialization — never define custom test models
  - ✅ `ValidateAndDeserializeResponse(response, {Toolset}JsonContext.Default.{Operation}CommandResult)`
  - ❌ `JsonSerializer.Deserialize<TestModel>(json)`

**GATE:** `dotnet test tools/Azure.Mcp.Tools.{Toolset}/tests --filter "FullyQualifiedName~{Resource}{Operation}CommandTests"` must pass.

---

## Phase 3: Live Tests (Azure service commands only)

Skip this phase for non-Azure commands (CLI wrappers, best practices, documentation tools).

### 3a. Test Infrastructure

File: `tests/test-resources.bicep`

```bicep
targetScope = 'resourceGroup'

@minLength(3)
@maxLength(17)
param baseName string = resourceGroup().name

param testApplicationOid string = deployer().objectId
param location string = resourceGroup().location

resource myResource 'Microsoft.{Provider}/{type}@{api-version}' = {
  name: baseName
  location: location
  properties: { /* minimal config */ }
}

resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = {
  name: guid(roleDefinition.id, testApplicationOid, myResource.id)
  scope: myResource
  properties: {
    principalId: testApplicationOid
    roleDefinitionId: roleDefinition.id
  }
}

output resourceName string = myResource.name
```

File: `tests/test-resources-post.ps1` (required even if empty logic)

```powershell
[CmdletBinding()]
param (
    [Parameter(Mandatory)] [hashtable] $DeploymentOutputs,
    [Parameter(Mandatory)] [hashtable] $AdditionalParameters
)
Write-Host "{Toolset} post-deployment setup completed."
```

Validate: `az bicep build --file tools/Azure.Mcp.Tools.{Toolset}/tests/test-resources.bicep`

### 3b. Live Test Class

File: `tests/Azure.Mcp.Tools.{Toolset}.Tests/{Toolset}CommandTests.cs`

```csharp
public class {Toolset}CommandTests(ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture)
    : RecordedCommandTestsBase(output, fixture, liveServerFixture)
{
    [Fact]
    public async Task {Resource}{Operation}_ReturnsExpectedResult()
    {
        var result = await CallToolAsync(
            "{toolset}_{resource}_{operation}",
            new()
            {
                ["subscription"] = SubscriptionId,
                ["resource-group"] = ResourceGroupName,
            });

        Assert.NotNull(result);
        var items = result.Value.AssertProperty("items");
        Assert.Equal(JsonValueKind.Array, items.ValueKind);
    }
}
```

#### `LocalRequired` tools

Remote HTTP mode intentionally excludes tools marked `LocalRequired = true`. Every recorded test for such a tool must verify that exclusion and return before exercising local-only behavior:

```csharp
if (await AssertLocalToolIsUnavailableInHttpMode("{toolset}_{resource}_{operation}"))
{
    return;
}
```

Use the inherited helper in every applicable test in a class extending `RecordedCommandTestsBase`; do not duplicate the transport check or unavailable-tool assertions.

### 3c. Record and Verify

 #### Create assets.json

Create `assets.json` if it doesn't exist:

```json
{
  "AssetsRepo": "Azure/azure-sdk-assets",
  "AssetsRepoPrefixPath": "",
  "TagPrefix": "Azure.Mcp.Tools.{Toolset}.Tests",
  "Tag": ""
}
```

 #### Deploy
 ```powershell
eng/common/TestResources/New-TestResources.ps1 `
   -TestResourcesDirectory tools/Azure.Mcp.Tools.{Toolset}
 ```

 #### Record tests
 ```powershell
 dotnet test tools\Azure.Mcp.Tools.{Toolset}\tests\Azure.Mcp.Tools.{Toolset}.Tests `
   --filter "FullyQualifiedName~{Resource}{Operation}"
```

 #### Push recordings
 ```powershell
.proxy\Azure.Sdk.Tools.TestProxy push `
  -a tools\Azure.Mcp.Tools.{Toolset}\tests\Azure.Mcp.Tools.{Toolset}.Tests\assets.json
```

 #### Verify playback
Change TestMode to "Playback" in .testsettings.json, then re-run tests

### 3c-1. Recorded Test Pitfalls

**These are common causes of recorded test failures. Always verify playback passes after recording.**

#### Always pass `Settings.TenantId` in live test calls

If the test subscription lives in a non-default tenant, the command will fail with `InvalidAuthenticationTokenTenant`. Include tenant when your subscription requires it:
```csharp
var result = await CallToolAsync(
    "{toolset}_{resource}_{operation}",
    new()
    {
        { "subscription", Settings.SubscriptionId },
        { "resource-group", Settings.ResourceGroupName },
        { "tenant", Settings.TenantId }  // Always include
    });
```

#### Use `RegisterOrRetrieveVariable` for all dynamic values

Any non-deterministic value (`Guid.NewGuid()`, `DateTime.Now`) must be wrapped so the same value is used in both Record and Playback runs:
```csharp
// ✅ Value is recorded and replayed deterministically
var topicName = RegisterOrRetrieveVariable("create_topic_name", $"topic-{Guid.NewGuid():N}"[..24]);

// ❌ Different GUID each run — breaks playback request matching
var topicName = $"topic-{Guid.NewGuid():N}"[..24];
```

#### Assertions must survive sanitization

Recording sanitizers replace sensitive values (resource names, IDs, endpoints) with placeholders like `"Sanitized"`. Your assertion strategy depends on your test class sanitizer configuration:

| Approach | When to use | Example toolsets |
|----------|-------------|-----------------|
| Exact name assert | Your sanitizers do NOT replace the resource name | KeyVault, FunctionApp |
| Structural assert (`AssertProperty`) | Your sanitizers DO replace the name | EventGrid |
| `SanitizeAndRecord` helper | You need exact asserts AND have aggressive sanitizers | ManagedLustre |

**How to check:** After recording, inspect the session recording JSON (use `.proxy/Azure.Sdk.Tools.TestProxy.exe config locate -a <assets.json>`). If the `"name"` field shows `"Sanitized"`, you cannot use exact name asserts without the `SanitizeAndRecord` pattern.

```csharp
// Safe assertions that survive any sanitizer configuration:
topic.AssertProperty("name");  // Checks existence only
Assert.Equal("Succeeded", topic.GetProperty("provisioningState").GetString());  // Enum values aren't sanitized
Assert.Equal(JsonValueKind.Object, topic.ValueKind);  // Type checks
```

#### Credential type in `.testsettings.json`

`Deploy-TestResources.ps1` sets `AZURE_TOKEN_CREDENTIALS=AzurePowerShellCredential`. If the MCP server subprocess cannot access the PowerShell credential cache (common on some machines), switch to `AzureCliCredential`:
```json
"EnvironmentVariables": {
    "AZURE_TOKEN_CREDENTIALS": "AzureCliCredential"
}
```
Ensure `az login --tenant <tenant-id>` is active. If recording fails with credential errors from the subprocess, this is the likely fix.

#### Redeploy if resource group is missing

Test resource groups are auto-deleted after 12 hours. If tests fail with `ResourceGroupNotFound`, redeploy:
```powershell
./eng/scripts/Deploy-TestResources.ps1 -Paths {Toolset}
```

### 3d. Test Project Configuration (Critical)

The test 

…(truncated)
