Skip to content

Introduce the new options pattern#2637

Draft
hallipr wants to merge 1 commit into
mainfrom
users/pahallis/options
Draft

Introduce the new options pattern#2637
hallipr wants to merge 1 commit into
mainfrom
users/pahallis/options

Conversation

@hallipr
Copy link
Copy Markdown
Member

@hallipr hallipr commented May 12, 2026

What does this PR do?

This pull request introduces significant refactoring to the Azure MCP command and options infrastructure, primarily to simplify option binding and validation, and to improve consistency across commands. The changes also include updates to retry policy configuration, test adjustments, and new project references.

GitHub issue number?

[Link to the GitHub issue this PR addresses]

Pre-merge Checklist

  • Required for All PRs
    • Read contribution guidelines
    • PR title clearly describes the change
    • Commit history is clean with descriptive messages (cleanup guide)
    • Added comprehensive tests for new/modified functionality
    • Created a changelog entry if the change falls among the following: new feature, bug fix, UI/UX update, breaking change, or updated dependencies. Follow the changelog entry guide
  • For MCP tool changes:
    • One tool per PR: This PR adds or modifies only one MCP tool for faster review cycles
    • Updated servers/Azure.Mcp.Server/README.md and/or servers/Fabric.Mcp.Server/README.md documentation
    • Validate README.md changes running the script ./eng/scripts/Process-PackageReadMe.ps1. See Package README
    • For new or modified tool descriptions, ran ToolDescriptionEvaluator and obtained a score of 0.4 or more and a top 3 ranking for all related test prompts
    • For tools with new names, including new tools or renamed tools, update consolidated-tools.json
    • For renamed tools, follow the Tool Rename Checklist and tag the PR with the breaking-change label
    • For new tools associated with Azure services or publicly available tools/APIs/products, add URL to documentation in the PR description
  • Extra steps for Azure MCP Server tool changes:
    • Updated command list in servers/Azure.Mcp.Server/docs/azmcp-commands.md
    • Ran ./eng/scripts/Update-AzCommandsMetadata.ps1 to update tool metadata in azmcp-commands.md (required for CI)
    • Updated test prompts in servers/Azure.Mcp.Server/docs/e2eTestPrompts.md
    • 👉 For Community (non-Microsoft team member) PRs:
      • Security review: Reviewed code for security vulnerabilities, malicious code, or suspicious activities before running tests (crypto mining, spam, data exfiltration, etc.)
      • Manual tests run: added comment /azp run mcp - pullrequest - live to run Live Test Pipeline

public class SubscriptionListOptions : GlobalOptions;
public class SubscriptionListOptions : SubscriptionOptions
{
public string? ResourceGroup { get; set; }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Doesn't make sense for this to continue having this option

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true, but I didn't want to affect the user-facing api in this PR. All options are preserved with their existing "requiredness", name and description


public AuthMethod? AuthMethod { get; set; }

[Option(Name = "retry")]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What happens to the Name here given that this is annotated on a model which has another set of values?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name becomes a prefix to the properties of the RetrtyPolicyOptions class.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

And the parent object in the inputSchema would be retry: {}? I don't think that would be clear to the LLM. Maybe we can have Name override the property name and a different annotation for the prefix on child properties. Also, would the current prefix apply only to direct children or nested ones too?

@hallipr hallipr force-pushed the users/pahallis/options branch 2 times, most recently from 2b56c66 to 534b462 Compare May 13, 2026 01:05
using NSubstitute;
using Xunit;

using Microsoft.Mcp.Core.Areas.Server.Models;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems to be in an odd spot

Comment on lines +109 to +112
if (options.Tool != null && options.Tool.Length > 0)
{
options.Mode = ModeTypes.All;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Well, this is really interesting behavior... doesn't seem right for another option to implicitly override another like this. Not an issue in this PR but something that probably should be discussed as a follow up.

if (hasNamespace && hasTool)
{
commandResult.AddError("The --namespace and --tool options cannot be used together. Please specify either --namespace to filter by service namespace or --tool to filter by specific tool names, but not both.");
validationResult.AddError("The --namespace and --tool options cannot be used together. Please specify either --namespace to filter by service namespace or --tool to filter by specific tool names, but not both.");
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Comment for tracking, not an issue in this PR.

Using --namespace, etc. ties the error message to a CLI-like mode. MCP parameters don't use -- prefixes and would just be namespace here.

Can we write an issue to clean this up so that our error messages return based on the mode used (CLI or MCP right now). In CLI we use --namespace ... options and in MCP we use namespace ... parameters.

/// </summary>
[JsonPropertyName("mode")]
public string? Mode { get; set; } = ModeTypes.NamespaceProxy;
public string? Mode { get; set; }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another thing noticed due to changes here but isn't due to the PR.

We should make an enum for Mode instead of is being a string

@hallipr hallipr force-pushed the users/pahallis/options branch from 534b462 to d74dad9 Compare May 13, 2026 04:44

return (int)response.Status;
}
catch (CommandValidationException ex)
Copy link
Copy Markdown
Contributor

@alzimmermsft alzimmermsft May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When does this get thrown here? It seems that BaseCommand should handle catching CommandValidationException and using HandleException to process it by having BindOptions and ValidateOptions in the same block.


public List<string> Errors { get; } = [];

public void AddError(string error) => Errors.Add(error);
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If we're adding this, should we make Errors a private field instead of a property?

private static readonly DiagnosticDescriptor Rule = new(
DiagnosticId,
title: "Optional group contains required member",
messageFormat: "Nullable complex property '{0}' contains non-nullable member '{1}'. All properties within a nullable complex type must be nullable.",
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this need to handle chains more deeply nested than one level? For example, Foo.Bar.Baz where Foo is nullable, Bar is nullable, but Baz is non-null.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this checks recursively?

while (current is not null)
{
if (current.IsGenericType &&
current.Name == "BaseCommand" &&
Copy link
Copy Markdown
Contributor

@alzimmermsft alzimmermsft May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can be a follow-up but it also seems that we should have a rule where a command never implements IBaseCommand directly, as the current state of this PR that wouldn't function properly.

Or the design in this PR needs to be changed so that BaseCommand doesn't contain common implementation needed by all command.

public string Type { get; set; } = "string";

[JsonPropertyName("required")]
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we be ignoring this in JSON serialization when the value is false?

Comment on lines +15 to +16
/// Resolves the subscription from the parse result, falling back to Azure CLI profile
/// or AZURE_SUBSCRIPTION_ID environment variable.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This really should be the docstring for the default implementation, not the interface. The interface should allow the implementation to do whatever it wants.

/// <summary>
/// A description of what the option controls. Used in help text and by AI agents.
/// </summary>
public string? Description { get; init; }
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I thought the plan was for Description to be non-null

PropertyInfo[] allProperties = type.GetProperties(BindingFlags.Public | BindingFlags.Instance);

// When a derived class hides a base property with 'new', GetProperties returns both.
// Keep only the most-derived version (the one whose DeclaringType is closest to 'type').
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this a behavior handled by how type.GetProperties works where it checks super types first for their properties? If not, the check below seems wrong as it just checks IsAssignableFrom without checking depth.

@hallipr hallipr force-pushed the users/pahallis/options branch from 7b6a3f0 to b64f194 Compare May 14, 2026 07:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

Status: Untriaged

Development

Successfully merging this pull request may close these issues.

3 participants