| name | add-azure-mcp-tools |
|---|---|
| description | 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. |
| argument-hint | Describe the new tool (e.g., "add storage container delete command" or "create new KeyVault toolset with secret get command") |
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.
Before starting, determine:
- Adding to existing toolset? → Skip to Phase 1.
- Creating a new toolset? → Complete Phase 0 first.
| 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 |
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.
- 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
- Use
ValidateOptionsfor 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-genericSubscriptionCommandpattern and the legacy one-generic pattern — see theValidateOptionsoverride guidance in Phase 1d. - Prefer SDK/runtime validators and deterministic checks first (
Length, explicit allowed-value sets, character/category checks).
- 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.
// ✅ 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- Never concatenate user input directly without prior validation into URLs, shell commands, resource identifiers, query strings, etc.
- Use
EndpointValidatorfromMicrosoft.Mcp.Core.Helpersto 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.
- Azure service data-plane endpoint (endpoint derived from a resource name, e.g. storage account, ACR, App Config): call
- For services that construct the endpoint internally (not from user input), use the cloud-type switch pattern (see Phase 1c: Service Implementation) —
EndpointValidatoris not required in that case butValidateAzureServiceEndpointcan be added as a defense-in-depth layer. - Control-plane operations (ARM resource creation, RBAC assignments, policy etc.) do not need
EndpointValidatorbecause they go through the typed Azure SDK ARM client. Construct ARM resource IDs usingResourceIdentifieror collection helpers — never by string-interpolating subscription/resource-group/resource-name directly into a raw ARM path. - For new commands and services, always pass
CancellationTokenas the final parameter to all async downstream calls and propagate it throughout — never substituteCancellationToken.Noneordefaultat 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.
| 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). |
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 can be met.
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:
-
Add package version to
Directory.Packages.props(if Azure SDK needed) -
Register the project in solution files by running:
pwsh eng/scripts/Update-Solutions.ps1 -All -
Register the new toolset in
servers/Azure.Mcp.Server/src/Program.csRegisterAreas()(alphabetical order) -
Choose the appropriate base class:
- Commands that need an Azure subscription (most Azure service tools) → inherit from
SubscriptionCommand<TOptions, TResult>and injectISubscriptionResolver. - 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.
- Commands that need an Azure subscription (most Azure service tools) → inherit from
-
Register both the service and the command as singletons in
{Toolset}Setup.csConfigureServices: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.
Create these files in order:
File: src/Options/{Resource}/{Resource}{Operation}Options.cs
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
ISubscriptionOptionfor 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 namedFooBarand has[Option(Name = "foobar")]you get--foobarinstead 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(neversubscriptionId) — supports both IDs and names - Use
resourceGroup(neverresourceGroupName) - Use singular nouns for resources (
servernotserverName) - Remove unnecessary
namesuffixes (Account/--accountnotAccountName/--account-name) - Use
requiredon required options; use nullable types (?) for optional options. - Non-nullable value types (e.g.,
public int Count { get; set; }) are always valid withoutrequired— they default to0. Userequiredif the caller must explicitly provide a value, or useint?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}OptionDefinitionsclass is not needed
File: src/Services/I{Toolset}Service.cs
Return type depends on operation type:
- Resource Graph queries →
Task<ResourceQueryResults<MyModel>>(includesAreResultsTruncatedflag) - Data plane operations →
Task<List<MyModel>> - Write operations →
Task<MyResultModel>
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
BaseAzureResourceServiceextendsBaseAzureService— neither is inherently read-only or write-only. The distinction is whether you need ARG querying functionality.
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):
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.CloudTypeswitch — never hardcode URLs
Data plane endpoint pattern (required for services like Storage, Cosmos, Search):
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:
// ❌ 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.
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.
var lroOperation = Service.LroAsync(WaitUntil.Started, cancellationToken);
await WaitForLroCompletionAsync(lroOperation, cancellationToken);File: src/Commands/{Resource}/{Resource}{Operation}Command.cs
Required using statements:
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;[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>—TResultis the command's result record ISubscriptionResolverinjected via primary constructor and passed to baseExecuteAsyncreceives pre-boundTOptions options— noParseResultparameter- No
RegisterOptions()/BindOptions()overrides needed —OptionBinderhandles binding via[Option]attributes - No manual
Validate()call — framework validates based on nullability andValidateOptions()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):
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):
// 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
}
}File: src/Commands/{Toolset}JsonContext.cs
[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 bytypeofmodel name - Use one context per toolset always
- Filename must match class name (
{Toolset}JsonContext.cs) - Use
{Toolset}JsonContext.Default.{CommandResult}when serializing — neverJsonSerializer.Deserialize<T>()without a context
File: src/{Toolset}Setup.cs
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:
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 operationsetparamat 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.
File: tests/Azure.Mcp.Tools.{Toolset}.Tests/{Resource}/{Resource}{Operation}CommandTests.cs
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→ useSubscriptionCommandUnitTestsBase<TCommand, TService> - Commands extending
BaseCommanddirectly (no subscription) → useCommandUnitTestsBase<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.CancellationTokenwhen invoking real code - Use
Arg.Is(value)or the value directly for specific match assertions - Never pass
CancellationToken.Noneordefaultin test code
Deserialization rules:
- Use
{Toolset}JsonContext.Default.{Operation}CommandResultfor 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.
Skip this phase for non-Azure commands (CLI wrappers, best practices, documentation tools).
File: tests/test-resources.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.nameFile: tests/test-resources-post.ps1 (required even if empty logic)
[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
File: tests/Azure.Mcp.Tools.{Toolset}.Tests/{Toolset}CommandTests.cs
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);
}
}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:
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.
Create assets.json if it doesn't exist:
{
"AssetsRepo": "Azure/azure-sdk-assets",
"AssetsRepoPrefixPath": "",
"TagPrefix": "Azure.Mcp.Tools.{Toolset}.Tests",
"Tag": ""
}eng/common/TestResources/New-TestResources.ps1 `
-TestResourcesDirectory tools/Azure.Mcp.Tools.{Toolset}dotnet test tools\Azure.Mcp.Tools.{Toolset}\tests\Azure.Mcp.Tools.{Toolset}.Tests `
--filter "FullyQualifiedName~{Resource}{Operation}".proxy\Azure.Sdk.Tools.TestProxy push `
-a tools\Azure.Mcp.Tools.{Toolset}\tests\Azure.Mcp.Tools.{Toolset}.Tests\assets.jsonChange TestMode to "Playback" in .testsettings.json, then re-run tests
These are common causes of recorded test failures. Always verify playback passes after recording.
If the test subscription lives in a non-default tenant, the command will fail with InvalidAuthenticationTokenTenant. Include tenant when your subscription requires it:
var result = await CallToolAsync(
"{toolset}_{resource}_{operation}",
new()
{
{ "subscription", Settings.SubscriptionId },
{ "resource-group", Settings.ResourceGroupName },
{ "tenant", Settings.TenantId } // Always include
});Any non-deterministic value (Guid.NewGuid(), DateTime.Now) must be wrapped so the same value is used in both Record and Playback runs:
// ✅ 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];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.
// 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 checksDeploy-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:
"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.
Test resource groups are auto-deleted after 12 hours. If tests fail with ResourceGroupNotFound, redeploy:
./eng/scripts/Deploy-TestResources.ps1 -Paths {Toolset}The test .csproj must have these specific settings or tests will fail with "azmcp.exe not found":
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
<HasLiveTests>true</HasLiveTests>
<HasUnitTests>true</HasUnitTests>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Azure.Mcp.Tools.{Toolset}.csproj" />
<ProjectReference Include="$(RepoRoot)servers\Azure.Mcp.Server\src\Azure.Mcp.Server.csproj" />
</ItemGroup>
</Project>Azure.Mcp.Server.csproj.
If your live test class needs IAsyncLifetime or overrides Dispose, you must call base.Dispose():
public class MyCommandTests(ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture)
: RecordedCommandTestsBase(output, fixture, liveServerFixture), IAsyncLifetime
{
public ValueTask DisposeAsync()
{
base.Dispose();
return ValueTask.CompletedTask;
}
}Failure to call base.Dispose() prevents request/response data from being written to failing test results.
GATE: Tests pass in both Record and Playback modes.
Run all checks in order. All must pass.
# 1. Build
dotnet build tools/Azure.Mcp.Tools.{Toolset}/src
# 2. Format
dotnet format Microsoft.Mcp.slnx --verify-no-changes --include "tools/Azure.Mcp.Tools.{Toolset}/**"
# 3. All unit tests (including existing — no regressions)
dotnet test tools/Azure.Mcp.Tools.{Toolset}/tests
# 4. Spell check
.\eng\common\spelling\Invoke-Cspell.ps1
# 5. Full verification
./eng/scripts/Build-Local.ps1 -VerifyNpx
# 6. AOT/Native build (required for AOT-compatible toolsets)
./eng/scripts/Build-Local.ps1 -BuildNativeIf AOT fails (common for new Azure SDK dependencies):
- Move toolset setup in
Program.csunder#if !BUILD_NATIVE - Add
ProjectReference-Removecondition inAzure.Mcp.Server.csproj
GATE: All 6 checks green.
File: servers/Azure.Mcp.Server/docs/azmcp-commands.md
Add command in alphabetical order within service section. Then regenerate metadata:
./eng/scripts/Update-AzCommandsMetadata.ps1File: servers/Azure.Mcp.Server/docs/e2eTestPrompts.md
Add 2-3 natural language prompts in alphabetical order:
| {toolset}_{resource}_{operation} | Natural language prompt |Follow docs/changelog-entries.md. Create entry using ./eng/scripts/New-ChangelogEntry.ps1 or manually. Use -ChangelogPath servers/Azure.Mcp.Server/CHANGELOG.md.
servers/Azure.Mcp.Server/README.md: Update the supported services table (line ~1189) and add example prompts in the "What can you do" section (line ~898). This file is processed byeng/scripts/Process-PackageReadMe.ps1into package-specific outputs (NuGet, VSIX, npm, PyPI) so a single update covers all distribution channels.
File: .github/CODEOWNERS
Add your new toolset path with appropriate team ownership:
/tools/Azure.Mcp.Tools.{Toolset}/ @your-team
File: servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json
Add your new tool(s) to the consolidated tools JSON. Use the following command to find the correct tool name:
cd servers/Azure.Mcp.Server/src/bin/Debug/net10.0
./azmcp[.exe] tools list --name --namespace <tool_area>Documentation Standards:
- Use consistent command paths in all documentation (e.g.,
azmcp sql db show) - Always run
.\eng\scripts\Update-AzCommandsMetadata.ps1after updating azmcp-commands.md (CI will fail if skipped) - Organize example prompts by service in README.md under service-specific sections
- Maintain alphabetical sorting in e2eTestPrompts.md (service sections AND tool names within each table)
- Include parameter descriptions and required vs optional indicators in azmcp-commands.md
GATE: ./eng/scripts/Update-AzCommandsMetadata.ps1 succeeds.
Now that test prompts are written (Phase 5b), validate your command description against them using the ToolDescriptionEvaluator.
Full documentation: See
eng/tools/ToolDescriptionEvaluator/Quickstart.mdfor setup details.
Set your Azure OpenAI endpoint and API key as environment variables:
$env:AOAI_ENDPOINT = "https://<your-resource>.openai.azure.com/openai/deployments/<embeddings-deployment-name>/embeddings?api-version=<api-version>"
$env:TEXT_EMBEDDING_API_KEY = "your_api_key_here"For internal contributors, refer to the Before creating a pull request section of this document to use our team's deployment and credentials.
Use --test-single-tool mode to validate your description without building the full server:
# Test a single tool description against one prompt
dotnet run --project eng/tools/ToolDescriptionEvaluator/src -- --test-single-tool `
--tool-description "Your command description" `
--prompt "user query"
# Test against multiple prompts (recommended — test 2-3 phrasings)
dotnet run --project eng/tools/ToolDescriptionEvaluator/src -- --test-single-tool `
--tool-description "Lists all user-assigned managed identities in a subscription" `
--prompt "show me my managed identities" `
--prompt "list managed identities in my subscription" `
--prompt "what identities do I have"This builds the server and tests all tools in your area against the e2eTestPrompts.md file:
# Run evaluator for your specific service area
pushd eng/tools/ToolDescriptionEvaluator
./scripts/Run-ToolDescriptionEvaluator.ps1 -Area "{Toolset}"
# Build the Azure.Mcp.Server as part of the run
./scripts/Run-ToolDescriptionEvaluator.ps1 -Area "{Toolset}" -BuildAzureMcp
# Run for all Azure MCP Server tools (slower)
./scripts/Run-ToolDescriptionEvaluator.ps1
popdTarget: Top 3 ranking and confidence score ≥ 0.4.
- Score
>= 0.6: Excellent — tool will be reliably selected - Score
0.4 - 0.6: Acceptable — tool should be selected in most cases - Score
< 0.4: Poor — description needs improvement
If score is low, improve the Description in [CommandMetadata]:
- Include verbs users would say ("list", "get", "show", "configure")
- Mention specific resource types and Azure service names
- Describe what the output contains
- Consider common synonyms and alternative phrasings
- Avoid overly generic descriptions that could match many tools
Custom prompts file formats:
- Markdown: Same table format as
servers/Azure.Mcp.Server/docs/e2eTestPrompts.md - JSON:
{ "azmcp-your-command": ["prompt1", "prompt2"] }
GATE: Score meets threshold (≥ 0.4, top 3 ranking). If the evaluator is not available (no Azure OpenAI credentials), manually verify the description is specific and action-oriented.
Before creating the PR, verify all of these:
- Options class is flat POCO with
[Option]attributes implementingISubscriptionOption - Command inherits
SubscriptionCommand<TOptions, TResult>withISubscriptionResolver -
ExecuteAsynctakes(CommandContext, TOptions, CancellationToken)— noParseResult - Service interface and implementation complete
- All async methods include
CancellationTokenparameter as final argument - Unit tests cover all paths (using
SubscriptionCommandUnitTestsBase) - Integration/live tests added
- Command registered as singleton in
{Toolset}Setup.csConfigureServices - Command added to group in
{Toolset}Setup.csRegisterCommands - Follows file structure exactly
- Error handling implemented with
HandleException(context, ex) - New tools added to
consolidated-tools.json - Documentation complete
- Azure SDK package added to both
Directory.Packages.propsAND.csproj - Package version consistency (same version in both files)
- Projects added to
Microsoft.Mcp.slnxandAzure.Mcp.Server.slnx - Toolset registered in
Program.csRegisterAreas()(alphabetical) - JSON serialization context includes all new model types
- Build passes (
dotnet build) - No compiler warnings
- Format clean (
dotnet format --verify-no-changes) - All unit tests pass (no regressions)
- All live tests pass in Playback mode
- Recordings pushed and
assets.jsoncommitted - Spell check passes (
.\eng\common\spelling\Invoke-Cspell.ps1) - ToolDescriptionEvaluator score ≥ 0.4
- AOT compilation verified (
./eng/scripts/Build-Local.ps1 -BuildNative) - One tool per PR
- All Azure SDK property names verified and correct
- Resource access patterns use collections (e.g.,
.GetSqlServers().GetAsync()) -
CancellationTokenpassed to all async SDK calls - Subscription resolution uses
ISubscriptionResolver(injected in constructor) - Service constructor includes
IAzureServiceinjection
-
azmcp-commands.mdupdated with command documentation -
Update-AzCommandsMetadata.ps1executed (CI will fail if skipped) -
e2eTestPrompts.mdupdated (alphabetical order maintained) - Changelog entry created (use
-ChangelogPath) -
servers/Azure.Mcp.Server/README.mdupdated with example prompts and service listing -
.github/CODEOWNERSentry added for new toolset
- Commands are stateless — no per-request state in instance fields
- Commands are thread-safe for multi-user concurrency
- No transport checks (
Environment.GetEnvironmentVariable("ASPNETCORE_URLS"),HttpContext) - Error messages are context-aware (include OBO-specific guidance where applicable)
- Uses
IAzureTokenCredentialProviderfor all authentication (not directDefaultAzureCredential)
-
ValidateOptionsenforces format, length, and allowed-value constraints on all inputs — not only nullability - No raw option objects logged — only individually named, known-safe parameters (e.g.,
options.Subscription,Name) - No user input concatenated directly into URLs, resource identifiers, or command strings without prior allowlist validation
- Data-plane endpoints validated with
EndpointValidator.ValidateAzureServiceEndpoint(Azure services),ValidateExternalUrl(known external hosts), orValidatePublicTargetUrl(arbitrary user-supplied targets) — never derived from raw user input without validation - Error messages are actionable but do not expose internal state, stack traces, or sensitive field values to callers
- Sensitive fields (keys, secrets, connection strings) are not returned in standard list/get responses unless the command is explicitly marked
Secret = true - Negative unit tests included for malformed, oversized, or hostile inputs
- If AI was used to generate any code in this PR, the AI prompt included security requirements and the output was reviewed for compliance with the Security Requirements section
Verify all files exist for your command:
src/Options/{Resource}/{Resource}{Operation}Options.cs(flat POCO with[Option]attributes)src/Commands/{Resource}/{Resource}{Operation}Command.cssrc/Services/I{Toolset}Service.cssrc/Services/{Toolset}Service.cssrc/Commands/{Toolset}JsonContext.cssrc/{Toolset}Setup.cs(implementsIAreaSetup, registers commands + services)tests/Azure.Mcp.Tools.{Toolset}.Tests/{Resource}/{Resource}{Operation}CommandTests.cstests/Azure.Mcp.Tools.{Toolset}.Tests/{Toolset}CommandTests.cs(live tests, Azure only)tests/test-resources.bicep(Azure service commands only)tests/test-resources-post.ps1(Azure service commands only)
| Element | Pattern | Example |
|---|---|---|
| Command class | {Resource}{SubResource?}{Operation}Command |
StorageAccountGetCommand |
| Options class | {Resource}{Operation}Options |
StorageAccountGetOptions |
| Test class | {Resource}{Operation}CommandTests |
StorageAccountGetCommandTests |
| CLI command | azmcp {service} {resource} {operation} |
azmcp storage account get |
| Command group | Concatenated lowercase | "resourcegroup", "storageaccount" |
| Option flag | --kebab-case |
--resource-group, --account |
Naming rules:
- Resource = top-level domain entity (
Server,Database,FileSystem) - SubResource (optional) = nested concept (
Config,Param,SubnetSize) - Operation = action or computed intent (
List,Get,Set,Show,Delete,Calculate) - ✅
ServerListCommand,ServerConfigGetCommand,FileSystemSubnetSizeCommand - ❌
GetConfigCommand(missing resource),ListServerCommand(verb precedes resource)
| Property | true |
false |
|---|---|---|
Destructive |
Deletes/modifies resources | Read-only or safe operations |
Idempotent |
Same result on repeated calls | Accumulates effects |
OpenWorld |
Unpredictable external systems | Well-defined Azure APIs |
ReadOnly |
Only queries data | Creates/updates/deletes |
Secret |
Returns credentials/keys | Returns non-sensitive data |
LocalRequired |
Needs local tools/files | Remote API calls only |
Detailed ToolMetadata guidance:
-
OpenWorld: Most Azure resource commands use
falsebecause they operate within the well-defined domain of Azure Resource Manager APIs. Only usetruefor commands interacting with truly unpredictable external systems outside Azure's control.false: Storage accounts, databases, VMs, schema definitions, best practices guidestrue: External web scraping, unstructured third-party data sources (rare)
-
Destructive: Set
truefor commands that delete, modify, or could cause data loss.true: Delete database, reset keys, purge storage, modify critical settingsfalse: List resources, show configuration, query data, get status
-
Idempotent: Can it be safely called multiple times with same params?
true: Set config to specific value, create named resource (with "already exists" handled)false: Generate new keys, create resources with auto-generated names, append logs
-
Secret: Does it return sensitive data?
true: Get storage account keys, show connection strings, retrieve certificatesfalse: List public resources, show non-sensitive config
-
LocalRequired: Does it need local tools/files?
true: Azure CLI wrappers, local file operations, tools requiring local installationfalse: Pure cloud API commands (most Azure resource commands)
After setting [CommandMetadata] properties, cross-check each value against these heuristics. Do not proceed if any check fails — correct the metadata first.
Destructive:
- If the command name contains
delete,remove,purge,reset,revoke, orupdate→ must betrue - If the command name contains
list,get,show,query, ordescribe→ must befalse - If the command creates resources that replace existing ones → should be
true
Idempotent:
- If calling the command twice with the same inputs produces different results → must be
false - If the command generates new keys, rotates secrets, or creates auto-named resources → must be
false - If the command returns the same data or sets the same state regardless of repetition → must be
true
OpenWorld:
- If the command only calls Azure Resource Manager, Microsoft Graph, or other well-defined Microsoft APIs → must be
false - Only set
trueif the command interacts with user-controlled external systems, arbitrary URLs, or unpredictable third-party services
ReadOnly:
- If the command can modify state (create, update, delete, write, upload) → must be
false - If the command only retrieves information → must be
true - Must be the logical inverse of
Destructivefor most commands (both can befalsefor create operations)
Secret:
- If the command name or resource contains
credential,secret,key,password,certificate,token, orconnectionstring→ default totrueunless the command provably cannot expose any sensitive information - If the command returns access keys, connection strings, secret values, or credential metadata (IDs, expiry, types) → must be
true - If the command only lists resource names or non-sensitive configuration →
false - When in doubt, set
true— it is safer to over-classify than to expose credentials without the Secret flag
LocalRequired:
- If the command makes only remote API calls → must be
false - Only set
trueif the command requires local file system access, local CLI tools, or locally installed software
Guidelines:
- Fully declare all
ToolMetadataproperties even if using defaults - Only override
GetErrorMessageandGetStatusCodeif logic differs from base class - Commands returning arrays return empty array
[]for null/empty service results
Never do (new pattern):
- ❌
subscriptionId→ ✅subscription - ❌ Options without
[Option]attribute → ✅ Always add[Option("description")]or[Option(OptionDescriptions.X)] - ❌ Inherit options from base class → ✅ Flat POCO implementing
ISubscriptionOption - ❌ Manual
RegisterOptions/BindOptionsin new commands → ✅ Use[Option]attributes (automatic) - ❌
ExecuteAsync(context, parseResult, ct)→ ✅ExecuteAsync(context, options, ct) - ❌ Call
Validate(parseResult.CommandResult, ...)→ ✅ OverrideValidateOptions(options, result)if needed - ❌ Hardcoded cloud URLs → ✅
CloudConfiguration.CloudTypeswitch - ❌ Logging
{@Options}→ ✅ Log only safe parameters individually - ❌ Underscores in group names → ✅ Concatenated lowercase or dash-separated
- ❌ Missing
CancellationToken→ ✅ Always the final parameter - ❌
CancellationToken.Nonein tests → ✅TestContext.Current.CancellationToken - ❌ Skip
base.Dispose()in tests → ✅ Always call when overriding - ❌ Skip live test infrastructure for Azure commands → ✅ Create
test-resources.bicepearly - ❌
CommandUnitTestsBasefor subscription commands → ✅ UseSubscriptionCommandUnitTestsBase - ❌
[Option(Name = "my-option")]when default matches → ✅ Only useName =when kebab-case conversion is wrong - ❌ Forget to register command as singleton → ✅
services.AddSingleton<MyCommand>()inConfigureServices
Always do:
- Use
[Option]attributes on flat options POCO (implementsISubscriptionOption) - Use
SubscriptionCommand<TOptions, TResult>withISubscriptionResolverinjection - Inherit
SubscriptionCommandUnitTestsBase<TCommand, TService>for unit tests - Create test infrastructure before implementing live tests
- Follow exact file structure and naming
- Make command classes
sealed - Use primary constructors
- Register commands AND services in
{Toolset}Setup.csConfigureServices - Register toolset in
Program.csRegisterAreas() - Handle all error cases with
HandleException - Use consistent resource naming patterns
- Reference
docs/option-conversion.mdwhen working with legacy one-generic commands
Azure SDK property names frequently differ from documentation or expected names. Always verify actual property names before implementation.
- Use IntelliSense First: Let the IDE show you what's actually available
- Inspect Assemblies When Needed: If you get compilation errors about missing properties:
$dll = Get-ChildItem -Path "." -Recurse -Filter "Azure.ResourceManager.*.dll" | Select-Object -First 1 -ExpandProperty FullName Add-Type -Path $dll [Azure.ResourceManager.Compute.Models.VirtualMachineExtensionInstanceView].GetProperties() | Select-Object Name, PropertyType
- Extension types:
VirtualMachineExtensionInstanceViewType(notTypeHandlerType) - Time properties: Often use
StartOn/LastActionOn(notStartTime/LastActionTime) - Date properties: May use
CreatedOn(notCreationDateorCreateDate) - Location: Usually
Location.NameorLocation.ToString()(Location is an object, not a string)
- Some properties shown in REST API may not exist in .NET SDK models
- Set values to
nullif the property truly doesn't exist in the data model - Don't try to derive missing data from other sources unless explicitly required
- Document why a property is set to null in comments
// ✅ Correct: Cast to IReadOnlyDictionary
Tags: data.Tags as IReadOnlyDictionary<string, string>
// ❌ Wrong: Direct assignment causes CS1503
Tags: data.Tagscannot convert from 'CancellationToken' to 'string'
- Check method parameter order. Many Azure SDK methods use named parameters.
- Fix:
.GetAsync(resourceName, cancellationToken: cancellationToken)
'SqlDatabaseData' does not contain a definition for 'X'
- Property names differ from expected. Common fixes:
CreationDate→CreatedOnEarliestRestoreDate→EarliestRestoreOnEdition→CurrentSku?.Name
Operator '?' cannot be applied to operand of type 'AzureLocation'
AzureLocationis a struct. UseLocation.ToString()instead ofLocation?.Name
Wrong resource access pattern
- ❌
.GetSqlServerAsync(name, cancellationToken) - ✅
.GetSqlServers().GetAsync(name, cancellationToken: cancellationToken) - Pattern: Always access through collections, not direct async methods
Missing package references
- Add
<PackageVersion Include="Azure.ResourceManager.{Service}" Version="{version}" />toDirectory.Packages.props - Then add
<PackageReference Include="Azure.ResourceManager.{Service}" />to project.csproj
- Always add to
Directory.Packages.propsfirst
// ✅ Rolling upgrade status for VMSS
var upgradeStatus = await vmssResource.Value
.GetVirtualMachineScaleSetRollingUpgrade()
.GetAsync(cancellationToken);
// ✅ VMSS instances
var vms = await vmssResource.Value
.GetVirtualMachineScaleSetVms()
.GetAllAsync(cancellationToken: cancellationToken);
// Pattern: Get{ResourceType}() returns collection,
// then .GetAsync(name, CancellationToken) or .GetAllAsync(CancellationToken)// ✅ Correct: use IAzureService
var subscriptionResource = await _azureService.GetSubscription(subscription, tenant, cancellationToken);
// ❌ Wrong: manual ARM client creation
var armClient = await CreateArmClientAsync(tenant, cancellationToken);
var subscriptionResource = armClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{subscription}"));The project has <ImplicitUsings>enable</ImplicitUsings>, so these are automatically available — do NOT add them manually:
SystemSystem.Collections.GenericSystem.IOSystem.LinqSystem.Net.HttpSystem.ThreadingSystem.Threading.Tasks
- Start with minimal using statements — add as needed
- Don't copy using blocks from other files
- Run
dotnet format --include="tools/Azure.Mcp.Tools.{Toolset}/**/*.cs"before committing
# Format specific toolset
dotnet format --include="tools/Azure.Mcp.Tools.{Toolset}/**/*.cs" --verbosity normal
# Format entire solution
dotnet format ./Microsoft.Mcp.slnx --verbosity normal
# Check for warnings
dotnet build --verbosity normal | Select-String "warning"If any test mutates environment variables, the test project must:
- Reference
core/Azure.Mcp.Core/tests/Azure.Mcp.Tests/Azure.Mcp.Tests.csproj - Include
AssemblyAttributes.cs:[assembly: Azure.Mcp.Tests.Helpers.ClearEnvironmentVariablesBeforeTest] [assembly: Xunit.CollectionBehavior(Xunit.CollectionBehavior.CollectionPerAssembly)]
IBaseCommand
└── BaseCommand<TOptions, TResult>
└── AuthenticatedCommand<TOptions, TResult>
└── SubscriptionCommand<TOptions, TResult> (where TOptions : ISubscriptionOption)
└── {Resource}{Operation}Command (sealed, concrete)
For toolsets with shared cross-command logic, add an intermediate base:
SubscriptionCommand<TOptions, TResult>
└── Base{Toolset}Command<TOptions, TResult> (where TOptions : I{Toolset}Option)
└── Base{Resource}Command<TOptions, TResult> (where TOptions : I{Resource}Option)
└── {Resource}{Operation}Command (sealed, concrete)
IBaseCommand provides:
Name: Command name for CLI displayDescription: Detailed command descriptionTitle: Human-readable command titleMetadata: Behavioral characteristics (ToolMetadata)GetCommand(): Retrieves System.CommandLine command definitionExecuteAsync(): Executes command logic
Important rules:
- Commands use primary constructors with
ILogger, service interface, andISubscriptionResolverinjection - Classes are always
sealedunless explicitly intended for inheritance SubscriptionCommandhandles subscription validation and resolution viaISubscriptionResolver- Options binding is automatic via
[Option]attributes — no manualRegisterOptions/BindOptions
Use interface constraints for type-safe shared behavior (see docs/option-conversion.md Step 5):
// Define interface for shared option access
public interface I{Toolset}Option
{
string Account { get; }
}
// Base command constrains TOptions to the interface
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.Account
}
}
// Options class implements the interface (stays flat, no inheritance)
public class MyOptions : ISubscriptionOption, I{Toolset}Option
{
[Option("The account name.")]
public required string Account { get; set; }
[Option(OptionDescriptions.Subscription)]
public string? Subscription { get; set; }
// ...
}The Id in [CommandMetadata] is a unique GUID for each tool. Generate a new one for every command — it uniquely identifies the tool across the entire system.
⚠️ LEGACY: This section documents the one-generic pattern used by unconverted toolsets (e.g., KeyVault, some older tools). New commands should use the two-generic pattern with[Option]attributes as shown in Phase 1. Only reference this when maintaining or converting existing one-generic commands. Seedocs/option-conversion.mdfor the full migration guide.
// For OptionDefinition<T> instances
.AsRequired() // Creates a required option instance
.AsOptional() // Creates an optional option instance
// For existing Option<T> instances
.AsRequired() // Creates a new required version
.AsOptional() // Creates a new optional version- Commands explicitly register options in
RegisterOptions - Each command controls whether each option is required or optional
- Binding is explicit using
parseResult.GetValueOrDefault(Option<T>) - No shared state between commands — each gets its own option instance
- Only use
.AsRequired()/.AsOptional()if changing the defaultRequiredsetting
Commands requiring specific options:
protected override void RegisterOptions(Command command)
{
base.RegisterOptions(command);
command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsRequired());
command.Options.Add(ServiceOptionDefinitions.Account.AsRequired());
command.Options.Add(ServiceOptionDefinitions.Database); // uses default
}
protected override MyCommandOptions BindOptions(ParseResult parseResult)
{
var options = base.BindOptions(parseResult);
options.ResourceGroup ??= parseResult.GetValueOrDefault(OptionDefinitions.Common.ResourceGroup);
options.Account = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Account);
options.Database = parseResult.GetValueOrDefault(ServiceOptionDefinitions.Database);
return options;
}Commands using options optionally:
protected override void RegisterOptions(Command command)
{
base.RegisterOptions(command);
command.Options.Add(ServiceOptionDefinitions.Account.AsOptional());
command.Options.Add(OptionDefinitions.Common.ResourceGroup.AsOptional());
}Commands with exclusive/validation options (legacy approach):
protected override void RegisterOptions(Command command)
{
base.RegisterOptions(command);
command.Options.Add(ServiceOptionDefinitions.EitherThis);
command.Options.Add(ServiceOptionDefinitions.OrThat);
command.Validators.Add(commandResult =>
{
var eitherThis = commandResult.GetOrDefaultValue(ServiceOptionDefinitions.EitherThis);
var orThat = commandResult.GetOrDefaultValue(ServiceOptionDefinitions.OrThat);
if (string.IsNullOrWhiteSpace(eitherThis) && string.IsNullOrWhiteSpace(orThat))
commandResult.AddError("Either --either-this or --or-that must be provided.");
if (!string.IsNullOrWhiteSpace(eitherThis) && !string.IsNullOrWhiteSpace(orThat))
commandResult.AddError("Cannot specify both --either-this and --or-that.");
});
}New pattern equivalent for exclusive validation:
public override void ValidateOptions(MyOptions options, ValidationResult validationResult)
{
base.ValidateOptions(options, validationResult);
if (string.IsNullOrWhiteSpace(options.EitherThis) && string.IsNullOrWhiteSpace(options.OrThat))
validationResult.Errors.Add("Either --either-this or --or-that must be provided.");
if (!string.IsNullOrWhiteSpace(options.EitherThis) && !string.IsNullOrWhiteSpace(options.OrThat))
validationResult.Errors.Add("Cannot specify both --either-this and --or-that.");
}Custom option (making required option optional for specific command):
protected override void RegisterOptions(Command command)
{
base.RegisterOptions(command);
command.Options.Remove(ComputeOptionDefinitions.ResourceGroup);
// ✅ Correct: Use string parameters for Option constructor
var optionalRg = new Option<string>("--resource-group", "-g")
{
Description = "The name of the resource group (optional)"
};
command.Options.Add(optionalRg);
// ❌ Wrong: Don't use array for aliases in constructor
// var wrongOption = new Option<string>(aliases.ToArray(), "Description");
}- Use
??=for options that might be set by base classes (global options) - Use direct assignment for command-specific options
- Use
parseResult.GetValueOrDefault(Option<T>)always - Extension methods create new option instances — no shared state
Base implementation returns InternalServerError for all exceptions. Override for service-specific codes:
protected override HttpStatusCode GetStatusCode(Exception ex) => ex switch
{
Azure.RequestFailedException reqEx => (HttpStatusCode)reqEx.Status,
Azure.Identity.AuthenticationFailedException => HttpStatusCode.Unauthorized,
ValidationException => HttpStatusCode.BadRequest,
_ => base.GetStatusCode(ex)
};Base returns ex.Message. Override for user-actionable messages:
protected override string GetErrorMessage(Exception ex) => ex switch
{
Azure.Identity.AuthenticationFailedException authEx =>
$"Authentication failed. Please run 'az login' to sign in. Details: {authEx.Message}",
Azure.RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.NotFound =>
"Resource not found. Verify the resource name and that you have access.",
Azure.RequestFailedException reqEx when reqEx.Status == (int)HttpStatusCode.Forbidden =>
$"Access denied. Ensure you have appropriate RBAC permissions. Details: {reqEx.Message}",
Azure.RequestFailedException reqEx => reqEx.Message,
_ => base.GetErrorMessage(ex)
};The base HandleException in BaseCommand:
protected virtual void HandleException(CommandContext context, Exception ex)
{
context.Activity?.SetStatus(ActivityStatusCode.Error);
var result = new ExceptionResult(Message: ex.Message, StackTrace: ex.StackTrace, Type: ex.GetType().Name);
response.Status = GetStatusCode(ex);
response.Message = GetErrorMessage(ex) + ". To mitigate this issue, please refer to the troubleshooting guidelines here at https://aka.ms/azmcp/troubleshooting.";
response.Results = ResponseResult.Create(result, JsonSourceGenerationContext.Default.ExceptionResult);
}Always call HandleException(context, ex) in catch blocks.
catch (Exception ex)
{
_logger.LogError(ex, "Error in {Operation}. Subscription: {Subscription}", Name, options.Subscription);
HandleException(context, ex);
}DO NOT log {@Options} — may expose sensitive information. Only log known-safe parameters.
- Authentication/Authorization: Credential expiry, missing RBAC, invalid connection strings
- Validation: Missing required params, invalid formats, conflicting options
- Resource State: Not found, locked/in use, invalid state
- Service Limits: Throttling/rate limits, quota exceeded, capacity
- Network/Connectivity: Service unavailable, timeouts, network failures
// ✅ Correct: parameters aligned with line breaks
Task<List<string>> GetStorageAccounts(
string subscription,
string? tenant = null,
CancellationToken cancellationToken = default);
// ❌ Incorrect: all on single line
Task<List<string>> GetStorageAccounts(string subscription, string? tenant = null, CancellationToken? cancellationToken = default);
// ❌ Incorrect: missing CancellationToken
Task<List<string>> GetStorageAccounts(
string subscription,
string? tenant = null);Rules:
- Parameters indented and aligned
- Blank lines between method declarations
CancellationTokenalways the final parameter- Only use default value
= defaultin the signature if other parameters also have defaults - At call sites, always pass the
CancellationTokenexplicitly — never rely on= defaultto omit it
- Pass to all async calls:
cancellationToken: cancellationToken - Use
.WithCancellation(cancellationToken)forawait foreach:// ✅ Correct await foreach (var rg in subscription.GetResourceGroups().WithCancellation(cancellationToken)) // ❌ Wrong: missing .WithCancellation() await foreach (var rg in subscription.GetResourceGroups())
- Never pass
CancellationToken.Noneordefaultas a value
Study existing services for resource access patterns:
- ✅
.GetSqlServers().GetAsync(serverName, cancellationToken: cancellationToken) - ❌
.GetSqlServerAsync(serverName, cancellationToken)— methods like this don't exist
// Use AssertProperty when the property MUST exist
var items = result.AssertProperty("items");
Assert.Equal(JsonValueKind.Array, items.ValueKind);
// Use TryGetProperty for optional/conditional properties
if (item.TryGetProperty("optional", out var optionalProp))
{
Assert.Equal(JsonValueKind.String, optionalProp.ValueKind);
}- Use
baseNameparameter with appropriate length restrictions - Include
testApplicationOidfor RBAC assignments - Deploy test resources (databases, containers) needed for integration tests
- Assign appropriate built-in roles to the test application
- Output resource names and identifiers for test consumption
- Use minimal SKUs (Basic, Standard S0) for cost efficiency
- Deploy only resources needed for command testing
- Use resource naming that identifies test purposes
Common resource naming patterns:
- Main service:
baseName(most common) or{baseName}{suffix}if disambiguation needed - Child resources:
test{resource}(e.g.,testdb,testcontainer) - Deployments are per-toolset — name collisions across toolsets should not occur
public class {Toolset}CommandTests(ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture)
: RecordedCommandTestsBase(output, fixture, liveServerFixture)
{
[Fact]
public async Task Should_Get{Resource}_Successfully()
{
var serviceName = Settings.ResourceBaseName;
var resourceName = "test{resource}";
var result = await CallToolAsync(
"{toolset}_{resource}_show",
new()
{
{ "subscription", Settings.SubscriptionId },
{ "resource-group", Settings.ResourceGroupName },
{ "service-name", serviceName },
{ "resource-name", resourceName }
});
Assert.NotNull(result);
var resource = result.Value.AssertProperty("{resource}");
Assert.Equal(JsonValueKind.Object, resource.ValueKind);
Assert.Equal(resourceName, resource.GetProperty("name").GetString());
}
}# Deploy test resources
./eng/scripts/Deploy-TestResources.ps1 -Paths "{Toolset}"
# Run live tests
pushd 'tools/Azure.Mcp.Tools.{Toolset}/tests/Azure.Mcp.Tools.{Toolset}.Tests'
dotnet test --filter "Category=Live"If your live test class implements IAsyncLifetime or overrides Dispose, you must call base.Dispose():
public class MyCommandTests(ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture)
: RecordedCommandTestsBase(output, fixture, liveServerFixture), IAsyncLifetime
{
public ValueTask DisposeAsync()
{
base.Dispose();
return ValueTask.CompletedTask;
}
}Failure to call base.Dispose() prevents request/response data from being written to failing test results.
Live test projects must reference the server project and include specific properties:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
<OutputType>Exe</OutputType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Azure.Mcp.Tools.{Toolset}.csproj" />
<ProjectReference Include="..\..\..\..\servers\Azure.Mcp.Server\src\Azure.Mcp.Server.csproj" />
</ItemGroup>
</Project>Common issue: Referencing only the toolset project instead of the server project causes "MCP server process exited unexpectedly" / "azmcp.exe not found" errors.
- Make command classes
sealed - Use primary constructors with
ILogger, service, andISubscriptionResolver - Inherit
SubscriptionCommand<TOptions, TResult> - Use flat options POCO with
[Option]attributes (no hierarchy) - Handle all exceptions with
HandleException - Include
CancellationTokenas final argument in all async methods - Register commands as singletons in
ConfigureServices
- Return
HttpStatusCode.BadRequestfor validation errors - Return
HttpStatusCode.Unauthorizedfor authentication failures - Return
HttpStatusCode.InternalServerErrorfor unexpected errors - Return service-specific status codes from
RequestFailedException - Add troubleshooting URL to error messages
- Log errors with context, override
GetErrorMessage/GetStatusCodefor custom handling
- Always set
Resultsproperty for success - Set
StatusandMessagefor errors - Use consistent JSON property names
- Commands returning arrays return
[]if service returned null/empty
- Clear command description without repeating service name
- ✅ "List and manage clusters"
- ❌ "AKS operations - List and manage AKS clusters"
- List all required options in description
- Describe return format
- Maintain alphabetical sorting in
e2eTestPrompts.md
- Use minimal resource configurations for cost efficiency
- Follow naming conventions:
baseName(most common) - Include proper RBAC assignments for test application
- Output all necessary identifiers for test consumption
- Use appropriate Azure service API versions
- Consider resource location constraints and availability
Commands must be transport-agnostic — they work identically in stdio (local) and HTTP (remote) modes with multiple concurrent users.
Azure MCP Server supports two outgoing auth strategies in remote HTTP mode:
1. On-Behalf-Of (OBO) Flow:
- Client authenticates user with Entra ID, sends bearer token
- Server exchanges user's token for downstream Azure service tokens
- Each API call uses user's identity and RBAC permissions
- Use for: per-user authorization, multi-tenant, audit trails
2. Hosting Environment Identity:
- Server uses its own identity (Managed Identity, Service Principal)
- All downstream calls use server's credentials
- All users share server's permission level
- Use for: simplified deployment, single-tenant, service-level permissions
Command Implementation: No command code changes needed! IAzureTokenCredentialProvider handles the strategy automatically:
// Works in ALL modes — OBO, hosting identity, and stdio
var credential = await _tokenCredentialProvider.GetTokenCredentialAsync(tenant, cancellationToken);✅ DO:
// Authentication provider handles all transport scenarios
var armClient = await CreateArmClientAsync(tenant, cancellationToken: cancellationToken);
// Options are pre-bound by framework, no shared state
// (In ExecuteAsync, 'options' parameter is already bound)
// Service calls are async and don't store request state
var results = await _service.ListAsync(options.Subscription!, cancellationToken: cancellationToken);❌ DON'T:
// ❌ Don't check environment for transport type
if (Environment.GetEnvironmentVariable("ASPNETCORE_URLS") != null) { }
// ❌ Don't access HttpContext directly in commands
var httpContext = _httpContextAccessor.HttpContext;
// ❌ Don't store per-request state in instance fields
private CommandContext? _currentContext; // Race condition!
private MyOptions? _currentOptions; // Race condition!- All commands must be stateless and thread-safe
- Don't store per-request state in command instance fields
- Use constructor injection for singleton services only
- Per-request data flows through
CommandContextand options binding
public async Task<List<Resource>> GetResourcesAsync(
string subscription,
string? tenant,
CancellationToken cancellationToken)
{
// IAzureService handles tenant resolution for all modes:
// - OBO mode: Validates tenant matches user's token
// - Hosting environment: Uses provided tenant or default
// - Stdio mode: Uses Azure CLI/VS Code default tenant
var credential = await GetCredential(tenant, cancellationToken);
var armClient = new ArmClient(credential);
// ...
}protected override string GetErrorMessage(Exception ex) => ex switch
{
RequestFailedException reqEx when reqEx.Status == 401 =>
"Authentication failed. In remote mode, ensure your token has the required " +
"Mcp.Tools.ReadWrite scope and sufficient RBAC permissions on Azure resources.",
RequestFailedException reqEx when reqEx.Status == 403 =>
"Authorization failed. Your user account lacks the required RBAC permissions. " +
"In remote mode with OBO flow, permissions come from the authenticated user's identity.",
InvalidOperationException invEx when invEx.Message.Contains("tenant") =>
"Tenant mismatch. In remote OBO mode, the requested tenant must match your " +
"authenticated user's tenant ID.",
_ => base.GetErrorMessage(ex)
};Every new command must be added to consolidated mode:
- File:
servers/Azure.Mcp.Server/src/Resources/consolidated-tools.json - Add new commands to the best matching category and exact matching
toolMetadata - Update existing consolidated tool descriptions where newly mapped tools are added
- If no matching category exists, suggest a new consolidated tool
- Find correct tool name:
cd servers/Azure.Mcp.Server/src/bin/Debug/net10.0 ./azmcp[.exe] tools list --name --namespace <tool_area>
When writing tests, consider both transport modes:
Unit Tests (Always Required):
- Mock all external dependencies
- Test command logic in isolation
- No Azure resources required
- Fast execution
Live Tests (Required for Azure Service Commands):
- Test against real Azure resources
- Verify Azure SDK integration
- Validate RBAC permissions
- Test works identically in both stdio and HTTP modes (commands are transport-agnostic)
Live tests inherently cover both modes because commands use IAzureTokenCredentialProvider which handles credential acquisition differently per mode. No separate "remote mode" test is needed — the same test validates both.
When documenting commands in azmcp-commands.md, include permissions section:
### Permissions
**Stdio Mode:** Requires authenticated Azure identity (Azure CLI, VS Code, Managed Identity)
**Remote HTTP Mode (OBO):** Requires `Mcp.Tools.ReadWrite` scope + user's RBAC
**Remote HTTP Mode (Hosting Environment):** Requires `Mcp.Tools.ReadWrite` scope + server's RBAC