Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 20 additions & 5 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -103,8 +103,9 @@ If you are contributing significant changes, or if the issue is already assigned
2. Create a feature branch
3. Make your changes
4. Write or update tests
5. Test locally
6. Submit a pull request
5. Run `./eng/scripts/Analyze-Code.ps1 -Fix` to auto-fix formatting, solution files, and tools.json drift
6. Test locally
7. Submit a pull request

### Adding a New Command

Expand Down Expand Up @@ -608,17 +609,31 @@ To debug the Azure MCP Server (`azmcp`) when running live tests in VS Code:

### Code Style

To ensure consistent code quality, code format checks will run during all PR and CI builds. Run `dotnet format` before submitting to catch format errors early.
To ensure consistent code quality, code analysis checks will run during all PR and CI builds. Run `Analyze-Code.ps1 -Fix` before submitting to auto-fix formatting, solution files, and tools.json drift, and to catch remaining issues early:

```pwsh
./eng/scripts/Analyze-Code.ps1 -Fix
```

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 line claims Analyze-Code.ps1 -Fix runs "tools.json regeneration" but no call to New-ToolList.ps1 was added to the script in this PR. If the integration is planned for a follow-up, this doc update should wait for that commit so contributors don't hit a confusing gap between what the docs promise and what the script does.

This runs: solution file verification, `dotnet format`, tools.json regeneration, spelling check, and tool metadata validation. Fixable issues (format, solution files, tools.json) are corrected automatically with `-Fix`.

Package README validation currently runs separately in CI, so a clean local `Analyze-Code.ps1 -Fix` run does not cover that check.

To run the check in verify-only mode (as CI does):

```pwsh
./eng/scripts/Analyze-Code.ps1
```

#### Spelling Check

To ensure consistent spelling across the codebase, run the spelling check before submitting:
To run spelling check standalone:

```pwsh
.\eng\common\spelling\Invoke-Cspell.ps1
```

This will check all files for spelling errors using the project's dictionary. Add any new technical terms or proper nouns to `.vscode/cspell.json` if needed.
Add any new technical terms or proper nouns to `.vscode/cspell.json` if needed.

#### Requirements

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,9 @@ namespace Azure.Mcp.Core.UnitTests.Areas.Server;

internal class CommandFactoryHelpers
{
public static ICommandFactory CreateCommandFactory(IServiceProvider? serviceProvider = default)
public static ICommandFactory CreateCommandFactory(
IServiceProvider? serviceProvider = default,
IAreaSetup[]? additionalAreaSetups = default)
{
IAreaSetup[] areaSetups = [
// Core areas
Expand Down Expand Up @@ -104,6 +106,7 @@ public static ICommandFactory CreateCommandFactory(IServiceProvider? serviceProv
new StorageSetup(),
new VirtualDesktopSetup(),
new WorkbooksSetup(),
.. (additionalAreaSetups ?? []),
];

var services = serviceProvider ?? CreateDefaultServiceProvider();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Mcp.Core.Areas;
using Microsoft.Mcp.Core.Areas.Tools;
using Microsoft.Mcp.Core.Areas.Tools.Commands;
using Microsoft.Mcp.Core.Areas.Tools.Options;
using Microsoft.Mcp.Core.Commands;
Expand Down Expand Up @@ -865,4 +866,186 @@ public async Task ExecuteAsync_WithNamespaceModeAndMultipleNamespaces_ReturnsFil
Assert.Contains(result, cmd => cmd.Name.Equals("storage", StringComparison.OrdinalIgnoreCase));
Assert.Contains(result, cmd => cmd.Name.Equals("keyvault", StringComparison.OrdinalIgnoreCase));
}

/// <summary>
/// Verifies that --include-hidden returns hidden commands that are normally filtered out.
/// The ToolsListCommand itself is [HiddenCommand], so it should appear only with --include-hidden.
/// </summary>
[Fact]
public async Task ExecuteAsync_WithIncludeHidden_ReturnsHiddenCommands()
{
// Arrange - use a context with ToolsSetup registered so there's a hidden command
var context = CreateContextWithToolsSetup();
var args = _commandDefinition.Parse(["--include-hidden"]);

// Act
var response = await _command.ExecuteAsync(context, args, TestContext.Current.CancellationToken);

// Assert
Assert.NotNull(response);
Assert.Equal(HttpStatusCode.OK, response.Status);
Assert.NotNull(response.Results);

var result = DeserializeResults(response.Results);
Assert.NotNull(result);
Assert.NotEmpty(result);

// ToolsListCommand is [HiddenCommand] - it should appear with --include-hidden
Assert.Contains(result, cmd => cmd.Command == "tools list");
}

/// <summary>
/// Verifies that without --include-hidden, hidden commands like "tools list" are excluded.
/// </summary>
[Fact]
public async Task ExecuteAsync_WithoutIncludeHidden_ExcludesHiddenCommands()
{
// Arrange - use a context with ToolsSetup registered so there's a hidden command to filter
var context = CreateContextWithToolsSetup();
var args = _commandDefinition.Parse([]);

// Act
var response = await _command.ExecuteAsync(context, args, TestContext.Current.CancellationToken);

// Assert
Assert.NotNull(response);
Assert.NotNull(response.Results);

var result = DeserializeResults(response.Results);
Assert.NotNull(result);

// ToolsListCommand is [HiddenCommand] - it should NOT appear without --include-hidden
Assert.DoesNotContain(result, cmd => cmd.Command == "tools list");
}

/// <summary>
/// Verifies that --include-hidden returns more commands than the default (without --include-hidden).
/// </summary>
[Fact]
public async Task ExecuteAsync_WithIncludeHidden_ReturnsMoreCommandsThanDefault()
{
// Arrange - both runs must use contexts with ToolsSetup, but separate instances
// since CommandContext.Response is shared per instance
var defaultContext = CreateContextWithToolsSetup();
var hiddenContext = CreateContextWithToolsSetup();
var defaultArgs = _commandDefinition.Parse([]);
var includeHiddenArgs = _commandDefinition.Parse(["--include-hidden"]);

// Act
var defaultResponse = await _command.ExecuteAsync(defaultContext, defaultArgs, TestContext.Current.CancellationToken);
var hiddenResponse = await _command.ExecuteAsync(hiddenContext, includeHiddenArgs, TestContext.Current.CancellationToken);

// Assert
var defaultResult = DeserializeResults(defaultResponse.Results!);
var hiddenResult = DeserializeResults(hiddenResponse.Results!);

Assert.True(hiddenResult.Count > defaultResult.Count,
$"Expected --include-hidden to return more commands ({hiddenResult.Count}) than default ({defaultResult.Count})");
}

/// <summary>
/// Verifies that --include-hidden with --name-only includes hidden command names.
/// </summary>
[Fact]
public async Task ExecuteAsync_WithIncludeHiddenAndNameOnly_IncludesHiddenNames()
{
// Arrange - separate contexts since CommandContext.Response is shared per instance
var defaultContext = CreateContextWithToolsSetup();
var hiddenContext = CreateContextWithToolsSetup();
var defaultArgs = _commandDefinition.Parse(["--name-only"]);
var includeHiddenArgs = _commandDefinition.Parse(["--name-only", "--include-hidden"]);

// Act
var defaultResponse = await _command.ExecuteAsync(defaultContext, defaultArgs, TestContext.Current.CancellationToken);
var hiddenResponse = await _command.ExecuteAsync(hiddenContext, includeHiddenArgs, TestContext.Current.CancellationToken);

// Assert
var defaultResult = DeserializeToolNamesResult(defaultResponse.Results!);
var hiddenResult = DeserializeToolNamesResult(hiddenResponse.Results!);

Assert.True(hiddenResult.Names.Count > defaultResult.Names.Count,
$"Expected --include-hidden --name-only to return more names ({hiddenResult.Names.Count}) than default ({defaultResult.Names.Count})");

// "tools_list" should only appear in the hidden results
Assert.Contains(hiddenResult.Names, name => name.Contains("tools") && name.Contains("list"));
Assert.DoesNotContain(defaultResult.Names, name => name.Contains("tools") && name.Contains("list"));
}

/// <summary>
/// Verifies that --include-hidden can be parsed without errors.
/// </summary>
[Fact]
public void CanParseIncludeHiddenOption()
{
// Arrange & Act
var parseResult = _commandDefinition.Parse(["--include-hidden"]);

// Assert
Assert.False(parseResult.Errors.Any(), $"Parse errors for --include-hidden: {string.Join(", ", parseResult.Errors)}");
Assert.True(parseResult.GetValueOrDefault<bool>(ToolsListOptionDefinitions.IncludeHidden.Name));
}

/// <summary>
/// Verifies that BindOptions correctly binds the --include-hidden option.
/// </summary>
[Fact]
public void BindOptions_WithIncludeHidden_BindsCorrectly()
{
// Arrange
var parseResult = _commandDefinition.Parse(["--include-hidden"]);

var bindOptionsMethod = typeof(ToolsListCommand).GetMethod("BindOptions",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(bindOptionsMethod);

// Act
var options = bindOptionsMethod.Invoke(_command, [parseResult]) as ToolsListOptions;

// Assert
Assert.NotNull(options);
Assert.True(options.IncludeHidden);
Assert.False(options.NameOnly);
Assert.False(options.NamespaceMode);
}

/// <summary>
/// Verifies that BindOptions defaults IncludeHidden to false when not specified.
/// </summary>
[Fact]
public void BindOptions_WithoutIncludeHidden_DefaultsToFalse()
{
// Arrange
var parseResult = _commandDefinition.Parse([]);

var bindOptionsMethod = typeof(ToolsListCommand).GetMethod("BindOptions",
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
Assert.NotNull(bindOptionsMethod);

// Act
var options = bindOptionsMethod.Invoke(_command, [parseResult]) as ToolsListOptions;

// Assert
Assert.NotNull(options);
Assert.False(options.IncludeHidden);
}

/// <summary>
/// Creates a CommandContext whose ICommandFactory includes ToolsSetup,
/// so the hidden ToolsListCommand is registered and available for filtering tests.
/// </summary>
private static CommandContext CreateContextWithToolsSetup()
{
var services = CommandFactoryHelpers.SetupCommonServices();
var toolsSetup = new ToolsSetup();
toolsSetup.ConfigureServices(services);
var sp = services.BuildServiceProvider();

var commandFactory = CommandFactoryHelpers.CreateCommandFactory(
serviceProvider: sp,
additionalAreaSetups: [toolsSetup]);
services.AddSingleton(commandFactory);

sp = services.BuildServiceProvider();
return new CommandContext(sp);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ protected override void RegisterOptions(Command command)
command.Options.Add(ToolsListOptionDefinitions.NamespaceMode);
command.Options.Add(ToolsListOptionDefinitions.Namespace);
command.Options.Add(ToolsListOptionDefinitions.NameOnly);
command.Options.Add(ToolsListOptionDefinitions.IncludeHidden);
}

protected override ToolsListOptions BindOptions(ParseResult parseResult)
Expand All @@ -45,6 +46,7 @@ protected override ToolsListOptions BindOptions(ParseResult parseResult)
{
NamespaceMode = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.NamespaceMode),
NameOnly = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.NameOnly),
IncludeHidden = parseResult.GetValueOrDefault(ToolsListOptionDefinitions.IncludeHidden),
Comment thread
hallipr marked this conversation as resolved.
Comment thread
hallipr marked this conversation as resolved.
Comment thread
hallipr marked this conversation as resolved.
Namespaces = namespaces.ToList()
};
}
Expand Down Expand Up @@ -89,7 +91,7 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
if (subgroup is not null)
{
List<CommandInfo> foundCommands = [];
searchCommandInCommandGroup("", subgroup, foundCommands);
searchCommandInCommandGroup("", subgroup, foundCommands, options.IncludeHidden);
namespaceCommands.AddRange(foundCommands);
}
}
Expand All @@ -111,7 +113,7 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
if (options.NameOnly)
{
// Get all visible commands and extract their tokenized names (full command paths)
var allToolNames = CommandFactory.GetVisibleCommands(factory.AllCommands)
var allToolNames = GetFilteredCommands(factory.AllCommands, options.IncludeHidden)
.Select(kvp => kvp.Key) // Use the tokenized key instead of just the command name
.Where(name => !string.IsNullOrEmpty(name));

Expand All @@ -128,7 +130,7 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
}

// Get all tools with full details
var allTools = CommandFactory.GetVisibleCommands(factory.AllCommands)
var allTools = GetFilteredCommands(factory.AllCommands, options.IncludeHidden)
.Select(kvp => CreateCommand(kvp.Key, kvp.Value));

// Apply namespace filtering if specified
Expand All @@ -150,6 +152,14 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
}
}

private static IEnumerable<KeyValuePair<string, IBaseCommand>> GetFilteredCommands(
IEnumerable<KeyValuePair<string, IBaseCommand>> commands, bool includeHidden)
{
return includeHidden
? commands.OrderBy(kvp => kvp.Key, StringComparer.Ordinal)
: CommandFactory.GetVisibleCommands(commands);
}

private static IEnumerable<string> ApplyNamespaceFilterToNames(IEnumerable<string> names, List<string> namespaces, char separator)
{
if (namespaces.Count == 0)
Expand Down Expand Up @@ -188,9 +198,9 @@ private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand comm
}

public record ToolNamesResult(List<string> Names);
private void searchCommandInCommandGroup(string commandPrefix, CommandGroup searchedGroup, List<CommandInfo> foundCommands)
private static void searchCommandInCommandGroup(string commandPrefix, CommandGroup searchedGroup, List<CommandInfo> foundCommands, bool includeHidden)
{
var commands = CommandFactory.GetVisibleCommands(searchedGroup.Commands).Select(kvp =>
var commands = GetFilteredCommands(searchedGroup.Commands, includeHidden).Select(kvp =>
{
var command = kvp.Value.GetCommand();
return new CommandInfo
Expand All @@ -204,7 +214,7 @@ private void searchCommandInCommandGroup(string commandPrefix, CommandGroup sear
foundCommands.AddRange(commands);
foreach (CommandGroup nextLevelSubGroup in searchedGroup.SubGroup)
{
searchCommandInCommandGroup($"{commandPrefix}{searchedGroup.Name} ", nextLevelSubGroup, foundCommands);
searchCommandInCommandGroup($"{commandPrefix}{searchedGroup.Name} ", nextLevelSubGroup, foundCommands, includeHidden);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ public static class ToolsListOptionDefinitions
public const string NamespaceModeOptionName = "namespace-mode";
public const string NamespaceOptionName = "namespace";
public const string NameOnlyOptionName = "name-only";
public const string IncludeHiddenOptionName = "include-hidden";

public static readonly Option<bool> NamespaceMode = new($"--{NamespaceModeOptionName}")
{
Expand All @@ -27,4 +28,10 @@ public static class ToolsListOptionDefinitions
Description = "If specified, returns only tool names without descriptions or metadata.",
Required = false
};

public static readonly Option<bool> IncludeHidden = new($"--{IncludeHiddenOptionName}")
{
Description = "If specified, includes hidden commands in the output.",
Required = false
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ public sealed class ToolsListOptions
/// </summary>
public bool NameOnly { get; set; } = false;

/// <summary>
/// If true, includes hidden commands in the output.
/// </summary>
public bool IncludeHidden { get; set; } = false;

/// <summary>
/// Optional namespaces to filter tools. If provided, only tools from these namespaces will be returned.
/// </summary>
Expand Down
Loading