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.
Install with Codex or Claude Copy this prompt, paste it into Codex, Claude, or another assistant, and let it review the skill page and install it for you.
A direct command skips the review prompt. Inspect the source before running it.
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")
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.
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.
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.
// ✅ 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 metadatareturn$"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) — 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 can be met.
Phase 0: New Toolset Setup (skip if adding to existing toolset)
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 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.
Register both the service and the command as singletons in {Toolset}Setup.csConfigureServices:
using Azure.Mcp.Core.Options;
using Microsoft.Mcp.Core.Models;
using Microsoft.Mcp.Core.Options;
namespaceAzure.Mcp.Tools.{Toolset}.Options.{Resource};
publicclass {Resource}{Operation}Options : ISubscriptionOption
{
[Option("Description of what this option does (e.g., 'The name of the resource').")]
publicstring? MyOption { get; set; }
[Option(OptionDescriptions.ResourceGroup)]
publicstring? ResourceGroup { get; set; }
[Option(OptionDescriptions.Subscription)]
publicstring? Subscription { get; set; }
[Option(OptionDescriptions.Tenant)]
publicstring? 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(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(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
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.
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);
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;
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;
namespaceAzure.Mcp.Tools.{Toolset}.Tests.{Resource};
publicclass {Resource}{Operation}CommandTests
: SubscriptionCommandUnitTestsBase<{Resource}{Operation}Command, I{Toolset}Service>
{
[Fact]
publicvoidConstructor_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 argspublicasync Task ExecuteAsync_ValidatesInputCorrectly(stringargs, bool shouldSucceed)
{
if (shouldSucceed)
{
Service.GetResourcesAsync(
Arg.Any<string?>(),
Arg.Any<>(),
Arg.Any<?>(),
Arg.Any<?>(),
Arg.Any<CancellationToken>())
.Returns( ResourceQueryResults<MyModel>([], ));
}
response = ExecuteCommandAsync();
Assert.Equal(shouldSucceed ? HttpStatusCode.OK : HttpStatusCode.BadRequest, response.Status);
(!shouldSucceed)
Assert.Contains(, response.Message.ToLower());
}
[]
{
Service.GetResourcesAsync(
Arg.Any<?>(),
Arg.Any<>(),
Arg.Any<?>(),
Arg.Any<?>(),
Arg.Any<CancellationToken>())
.Returns( ResourceQueryResults<MyModel>([], ));
response = ExecuteCommandAsync(, );
result = ValidateAndDeserializeResponse(
response, {Toolset}JsonContext.Default.{Resource}{Operation}CommandResult);
Assert.Empty(result.Items);
}
[]
{
Service.GetResourcesAsync(
Arg.Any<?>(),
Arg.Any<>(),
Arg.Any<?>(),
Arg.Any<?>(),
Arg.Any<CancellationToken>())
.ThrowsAsync( Exception());
response = ExecuteCommandAsync(, , , );
Assert.Equal(HttpStatusCode.InternalServerError, response.Status);
Assert.Contains(, response.Message);
Assert.Contains(, response.Message);
}
[]
{
Service.GetResourcesAsync(
Arg.Any<?>(),
Arg.Any<>(),
Arg.Any<?>(),
Arg.Any<?>(),
Arg.Any<CancellationToken>())
.ThrowsAsync( RequestFailedException(()HttpStatusCode.NotFound, ));
response = ExecuteCommandAsync(, , , );
Assert.Equal(HttpStatusCode.NotFound, response.Status);
Assert.Contains(, 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
dotnet test tools\Azure.Mcp.Tools.{Toolset}\tests\Azure.Mcp.Tools.{Toolset}.Tests `
--filter "FullyQualifiedName~{Resource}{Operation}"
Push recordings
.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:
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:
// ✅ Value is recorded and replayed deterministicallyvar topicName = RegisterOrRetrieveVariable("create_topic_name", $"topic-{Guid.NewGuid():N}"[..24]);
// ❌ Different GUID each run — breaks playback request matchingvar 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.
// 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:
Add 2-3 natural language prompts in alphabetical order:
| {toolset}_{resource}_{operation} | Natural language prompt |
5c. Changelog Entry
Follow docs/changelog-entries.md. Create entry using ./eng/scripts/New-ChangelogEntry.ps1 or manually. Use -ChangelogPath servers/Azure.Mcp.Server/CHANGELOG.md.
5d. README Updates
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 by eng/scripts/Process-PackageReadMe.ps1 into package-specific outputs (NuGet, VSIX, npm, PyPI) so a single update covers all distribution channels.
5e. CODEOWNERS
File: .github/CODEOWNERS
Add your new toolset path with appropriate team ownership:
For internal contributors, refer to the Before creating a pull request section of this document to use our team's deployment and credentials.
Option A: Test a single tool description (fastest for development)
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"
Option B: Run the full evaluator against your service area
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
popd
Interpreting Results
Target: 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
Improving Low Scores
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
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.
Phase 7: PR Checklist
Before creating the PR, verify all of these:
Core Implementation
Options class is flat POCO with [Option] attributes implementing ISubscriptionOption
Command inherits SubscriptionCommand<TOptions, TResult> with ISubscriptionResolver
ExecuteAsync takes (CommandContext, TOptions, CancellationToken) — no ParseResult
Service interface and implementation complete
All async methods include CancellationToken parameter 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
Package and Project Setup
Azure SDK package added to both Directory.Packages.props AND .csproj
Package version consistency (same version in both files)
Projects added to Microsoft.Mcp.slnx and Azure.Mcp.Server.slnx
Toolset registered in Program.csRegisterAreas() (alphabetical)
JSON serialization context includes all new model types
Resource access patterns use collections (e.g., .GetSqlServers().GetAsync())
CancellationToken passed to all async SDK calls
Subscription resolution uses ISubscriptionResolver (injected in constructor)
Service constructor includes IAzureService injection
Documentation
azmcp-commands.md updated with command documentation
Update-AzCommandsMetadata.ps1 executed (CI will fail if skipped)
e2eTestPrompts.md updated (alphabetical order maintained)
Changelog entry created (use -ChangelogPath)
servers/Azure.Mcp.Server/README.md updated with example prompts and service listing
.github/CODEOWNERS entry added for new toolset
Transport-Agnostic Requirements (Remote MCP Server Compatibility)
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 IAzureTokenCredentialProvider for all authentication (not direct DefaultAzureCredential)
Security
ValidateOptions enforces 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), or ValidatePublicTargetUrl (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
Required Files Checklist
Verify all files exist for your command:
src/Options/{Resource}/{Resource}{Operation}Options.cs (flat POCO with [Option] attributes)
OpenWorld: Most Azure resource commands use false because they operate within the well-defined domain of Azure Resource Manager APIs. Only use true for commands interacting with truly unpredictable external systems outside Azure's control.
false: Storage accounts, databases, VMs, schema definitions, best practices guides
true: External web scraping, unstructured third-party data sources (rare)
Destructive: Set true for commands that delete, modify, or could cause data loss.
false: 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 certificates
false: List public resources, show non-sensitive config
LocalRequired: Does it need local tools/files?
true: Azure CLI wrappers, local file operations, tools requiring local installation
false: Pure cloud API commands (most Azure resource commands)
⚠️ Metadata Validation Checklist
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, or update → must be true
If the command name contains list, get, show, query, or describe → must be false
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 true if 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 Destructive for most commands (both can be false for create operations)
Secret:
If the command name or resource contains credential, secret, key, password, certificate, token, or connectionstring → default to true unless 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 true if the command requires local file system access, local CLI tools, or locally installed software
Guidelines:
Fully declare all ToolMetadata properties even if using defaults
Only override GetErrorMessage and GetStatusCode if logic differs from base class
Commands returning arrays return empty array [] for null/empty service results
Quick Reference: Common Pitfalls
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/BindOptions in new commands → ✅ Use [Option] attributes (automatic)
Pattern: Always access through collections, not direct async methods
Missing package references
Add <PackageVersion Include="Azure.ResourceManager.{Service}" Version="{version}" /> to Directory.Packages.props
Then add <PackageReference Include="Azure.ResourceManager.{Service}" /> to project .csproj
Always add to Directory.Packages.props first
Specialized Resource Collection Patterns
// ✅ Rolling upgrade status for VMSSvar upgradeStatus = await vmssResource.Value
.GetVirtualMachineScaleSetRollingUpgrade()
.GetAsync(cancellationToken);
// ✅ VMSS instancesvar vms = await vmssResource.Value
.GetVirtualMachineScaleSetVms()
.GetAllAsync(cancellationToken: cancellationToken);
// Pattern: Get{ResourceType}() returns collection,// then .GetAsync(name, CancellationToken) or .GetAllAsync(CancellationToken)
Subscription Resolution
// ✅ Correct: use IAzureServicevar subscriptionResource = await _azureService.GetSubscription(subscription, tenant, cancellationToken);
// ❌ Wrong: manual ARM client creationvar armClient = await CreateArmClientAsync(tenant, cancellationToken);
var subscriptionResource = armClient.GetSubscriptionResource(new ResourceIdentifier($"/subscriptions/{subscription}"));
Reference: Implicit Usings and Code Quality
Implicit Usings (already included via Directory.Build.props)
The project has <ImplicitUsings>enable</ImplicitUsings>, so these are automatically available — do NOT add them manually:
System
System.Collections.Generic
System.IO
System.Linq
System.Net.Http
System.Threading
System.Threading.Tasks
Preventing Unused Usings
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
Detection and Cleanup
# 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"
Environment Variable Tests
If any test mutates environment variables, the test project must:
Commands use primary constructors with ILogger, service interface, and ISubscriptionResolver injection
Classes are always sealed unless explicitly intended for inheritance
SubscriptionCommand handles subscription validation and resolution via ISubscriptionResolver
Options binding is automatic via [Option] attributes — no manual RegisterOptions/BindOptions
Intermediate Base Command (only when needed)
Use interface constraints for type-safe shared behavior (see docs/option-conversion.md Step 5):
// Define interface for shared option accesspublicinterfaceI{Toolset}Option
{
string Account { get; }
}
// Base command constrains TOptions to the interfacepublicabstractclassBase{Toolset}Command<
[DynamicallyAccessedMembers(TrimAnnotations.CommandAnnotations)] TOptions, TResult>(
ISubscriptionResolver subscriptionResolver)
: SubscriptionCommand<TOptions, TResult>(subscriptionResolver)
where TOptions : class, ISubscriptionOption, I{Toolset}Option
{
publicoverridevoidValidateOptions(TOptions options, ValidationResult validationResult)
{
base.ValidateOptions(options, validationResult);
// Shared validation using options.Account
}
}
// Options class implements the interface (stays flat, no inheritance)publicclassMyOptions : ISubscriptionOption, I{Toolset}Option
{
[Option("The account name.")]
publicrequiredstring Account { get; set; }
[Option(OptionDescriptions.Subscription)]
publicstring? Subscription { get; set; }
// ...
}
Tool ID
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. See docs/option-conversion.md for the full migration guide.
Available Extension Methods
// 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
Key Principles (Legacy Pattern)
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 default Required setting
Commands with exclusive/validation options (legacy approach):
protectedoverridevoidRegisterOptions(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:
publicoverridevoidValidateOptions(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):
protectedoverridevoidRegisterOptions(Command command)
{
base.RegisterOptions(command);
command.Options.Remove(ComputeOptionDefinitions.ResourceGroup);
// ✅ Correct: Use string parameters for Option constructorvar 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");
}
Important Binding Patterns (Legacy)
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
Reference: Error Handling
Status Code Mapping
Base implementation returns InternalServerError for all exceptions. Override for service-specific codes:
Base returns ex.Message. Override for user-actionable messages:
protectedoverridestringGetErrorMessage(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)
};
HandleException Response Format
The base HandleException in BaseCommand:
protectedvirtualvoidHandleException(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.
❌ .GetSqlServerAsync(serverName, cancellationToken) — methods like this don't exist
Reference: Live Test Details
JSON Validation in Live Tests
// Use AssertProperty when the property MUST existvar items = result.AssertProperty("items");
Assert.Equal(JsonValueKind.Array, items.ValueKind);
// Use TryGetProperty for optional/conditional propertiesif (item.TryGetProperty("optional", outvar optionalProp))
{
Assert.Equal(JsonValueKind.String, optionalProp.ValueKind);
}
Key Bicep Template Requirements
Use baseName parameter with appropriate length restrictions
Include testApplicationOid for 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
# 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"
IAsyncLifetime and base.Dispose()
If your live test class implements IAsyncLifetime or overrides Dispose, you must call base.Dispose():