Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -41,23 +41,23 @@ public ToolsListCommandTests()
/// <summary>
/// Helper method to deserialize response results to CommandInfo list
/// </summary>
private static ToolsListCommand.ToolsListResult DeserializeCommandsResults(CommandResponse response) =>
DeserializeJson(response, () => new([], null));
private static ToolsListCommand.ToolsListCommandResult DeserializeCommandsResults(CommandResponse response) =>
DeserializeJson(response);

/// <summary>
/// Helper method to deserialize response results to ToolNamesResult
/// </summary>
private static ToolsListCommand.ToolsListResult DeserializeResult(CommandResponse response) =>
DeserializeJson(response, () => new(null, []));
private static ToolsListCommand.ToolsListCommandResult DeserializeResult(CommandResponse response) =>
DeserializeJson(response);

private static ToolsListCommand.ToolsListResult DeserializeJson(CommandResponse response, Func<ToolsListCommand.ToolsListResult> defaultValueFactory)
private static ToolsListCommand.ToolsListCommandResult DeserializeJson(CommandResponse response)
{
Assert.NotNull(response);
Assert.NotNull(response.Results);
Assert.Equal(HttpStatusCode.OK, response.Status);

var json = JsonSerializer.Serialize(response.Results);
var result = JsonSerializer.Deserialize(json, ModelsJsonContext.Default.ToolsListResult) ?? defaultValueFactory();
var result = JsonSerializer.Deserialize(json, ModelsJsonContext.Default.ToolsListCommandResult);

Assert.NotNull(result);
return result;
Expand All @@ -79,6 +79,12 @@ public async Task ExecuteAsync_WithValidContext_ReturnsCommandInfoList()

Assert.NotNull(result.Commands);
Assert.NotEmpty(result.Commands);
Assert.Null(result.Names);

var json = JsonSerializer.SerializeToElement(response.Results);
Assert.Equal(JsonValueKind.Object, json.ValueKind);
Assert.True(json.TryGetProperty("commands", out _));
Assert.False(json.TryGetProperty("names", out _));

foreach (var command in result.Commands)
{
Expand Down Expand Up @@ -114,7 +120,7 @@ public async Task ExecuteAsync_JsonSerializationStressTest_HandlesLargeResults()
var json = JsonSerializer.Serialize(response.Results);

// Verify JSON round-trip preserves all data
var serializedJson = JsonSerializer.Serialize(result, ModelsJsonContext.Default.ToolsListResult);
var serializedJson = JsonSerializer.Serialize(result, ModelsJsonContext.Default.ToolsListCommandResult);
Assert.Equal(json, serializedJson);
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -30,7 +29,7 @@ List all available commands and their tools in a hierarchical structure. This co
LocalRequired = false,
Secret = false)]
public sealed class ToolsListCommand(IServiceProvider serviceProvider, ILogger<ToolsListCommand> logger)
: BaseCommand<ToolsListOptions, ToolsListCommand.ToolsListResult>
: BaseCommand<ToolsListOptions, ToolsListCommand.ToolsListCommandResult>
{
private static readonly HashSet<string> s_ignored = new(StringComparer.OrdinalIgnoreCase) { "server", "tools" };
private static readonly HashSet<string> s_surfaced = new(StringComparer.OrdinalIgnoreCase) { "extension" };
Expand Down Expand Up @@ -82,11 +81,15 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,
if (options.NameOnly)
{
var namespaceNames = namespaceCommands.Select(nc => nc.Command).ToList();
context.Response.Results = ResponseResult.Create(new(null, namespaceNames), ModelsJsonContext.Default.ToolsListResult);
context.Response.Results = ResponseResult.Create(
new(null, namespaceNames),
ModelsJsonContext.Default.ToolsListCommandResult);
return context.Response;
}

context.Response.Results = ResponseResult.Create(new(namespaceCommands, null), ModelsJsonContext.Default.ToolsListResult);
context.Response.Results = ResponseResult.Create(
new(namespaceCommands, null),
ModelsJsonContext.Default.ToolsListCommandResult);
return context.Response;
}

Expand All @@ -103,7 +106,9 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,

var toolNames = allToolNames.OrderBy(name => name, StringComparer.OrdinalIgnoreCase).ToList();

context.Response.Results = ResponseResult.Create(new(null, toolNames), ModelsJsonContext.Default.ToolsListResult);
context.Response.Results = ResponseResult.Create(
new(null, toolNames),
ModelsJsonContext.Default.ToolsListCommandResult);
return context.Response;
}

Expand All @@ -118,7 +123,9 @@ public override async Task<CommandResponse> ExecuteAsync(CommandContext context,

var tools = allTools.ToList();

context.Response.Results = ResponseResult.Create(new(tools, null), ModelsJsonContext.Default.ToolsListResult);
context.Response.Results = ResponseResult.Create(
new(tools, null),
ModelsJsonContext.Default.ToolsListCommandResult);
return context.Response;
}
catch (Exception ex)
Expand Down Expand Up @@ -167,56 +174,10 @@ private static CommandInfo CreateCommand(string tokenizedName, IBaseCommand comm
};
}

[JsonConverter(typeof(ToolsListResultConverter))]
public sealed record ToolsListResult(
public sealed record ToolsListCommandResult(

@alzimmermsft Alan Zimmer (alzimmermsft) Aug 5, 2026

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 is a breaking change on the JSON response shape. I'm fine with it overall, but we are changing the response for azmcp tools list, which will break a lot of CLI usage. This is changing tool listing from:

[
  {tool},
  {tool},
  ...
]

to

{
  "Commands": [
    {tool},
    {tool},
    ...
  ]
}

[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List<CommandInfo>? Commands,
[property: JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] List<string>? Names);

public sealed class ToolsListResultConverter : JsonConverter<ToolsListResult>
{
public override ToolsListResult? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
if (reader.TokenType == JsonTokenType.StartObject)
{
List<string>? names = null;
while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
{
if (reader.TokenType == JsonTokenType.PropertyName && reader.GetString() == "names")
{
reader.Read(); // Move to the value of "names"
names = JsonSerializer.Deserialize(ref reader, ModelsJsonContext.Default.ListString);
}
}
return new(null, names);
}
else if (reader.TokenType == JsonTokenType.StartArray)
{
var commands = JsonSerializer.Deserialize(ref reader, ModelsJsonContext.Default.ListCommandInfo);
return new(commands, null);
}

throw new JsonException("Invalid JSON format for ToolsListResult.");
}

public override void Write(Utf8JsonWriter writer, ToolsListResult? value, JsonSerializerOptions options)
{
if (value is not null)
{
if (value.Commands is not null)
{
JsonSerializer.Serialize(writer, value.Commands, ModelsJsonContext.Default.ListCommandInfo);
}
else if (value.Names is not null)
{
writer.WriteStartObject();
writer.WritePropertyName("names");
JsonSerializer.Serialize(writer, value.Names, ModelsJsonContext.Default.ListString);
writer.WriteEndObject();
}
}
}
}

private static void SearchCommandInCommandGroup(string commandPrefix, CommandGroup searchedGroup, List<CommandInfo> foundCommands)
{
var commands = CommandFactory.GetVisibleCommands(searchedGroup.Commands).Select(kvp =>
Expand Down
3 changes: 1 addition & 2 deletions core/Microsoft.Mcp.Core/src/Models/ModelsJsonContext.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,9 @@
namespace Microsoft.Mcp.Core.Models;

[JsonSerializable(typeof(List<CommandInfo>))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(CommandResponse))]
[JsonSerializable(typeof(ETag), TypeInfoPropertyName = "McpETag")]
[JsonSerializable(typeof(ToolMetadata))]
[JsonSerializable(typeof(ToolsListCommand.ToolsListResult))]
[JsonSerializable(typeof(ToolsListCommand.ToolsListCommandResult))]
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
public sealed partial class ModelsJsonContext : JsonSerializerContext;
5 changes: 4 additions & 1 deletion eng/scripts/New-ToolsListFile.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,10 @@ try

# Parse, sort options within each tool by name, and re-serialize
$json = $outLines -join "`n" | ConvertFrom-Json
foreach ($tool in $json.results) {
$commandsProperty = $json.results.PSObject.Properties['commands']
$tools = if ($null -ne $commandsProperty) { $commandsProperty.Value } else { $json.results }

foreach ($tool in $tools) {
if ($tool.option) {
$tool.option = @($tool.option | Sort-Object -Property name)
}
Expand Down
3 changes: 2 additions & 1 deletion eng/scripts/Test-ToolNameLength.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,8 @@ foreach ($serverInfo in $serversToTest) {
}

$toolsResult = $toolsJson | ConvertFrom-Json
$tools = $toolsResult.results
$commandsProperty = $toolsResult.results.PSObject.Properties['commands']
$tools = if ($null -ne $commandsProperty) { $commandsProperty.Value } else { $toolsResult.results }

if ($null -eq $tools -or $tools.Count -eq 0) {
Write-Warning "No tools found in $currentServerName - skipping"
Expand Down
5 changes: 4 additions & 1 deletion eng/scripts/Update-AzCommandsMetadata.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,10 @@ try {
# Build a dictionary of command -> metadata
Write-Host "Building command metadata dictionary..." -ForegroundColor Yellow
$commandMetadata = @{}
foreach ($tool in $toolsData.results) {
$commandsProperty = $toolsData.results.PSObject.Properties['commands']
$tools = if ($null -ne $commandsProperty) { $commandsProperty.Value } else { $toolsData.results }

foreach ($tool in $tools) {
if ($tool.command -and $tool.metadata) {
$commandMetadata[$tool.command] = $tool.metadata
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -224,11 +224,13 @@ function Invoke-NamespaceGeneration {

if (-not (Test-Path $NamespaceToolsPath)) { throw "Namespace tools file not found: $NamespaceToolsPath" }
$namespaceJson = Get-Content -Raw -Path $NamespaceToolsPath | ConvertFrom-Json
if (-not $namespaceJson.results) { throw "Input namespace tools JSON missing 'results' array" }
$commandsProperty = $namespaceJson.results.PSObject.Properties['commands']
$namespaceCommands = if ($null -ne $commandsProperty) { $commandsProperty.Value } else { $namespaceJson.results }
if (-not $namespaceCommands) { throw "Input namespace tools JSON is missing commands" }

$warnings = @()
$outputMap = [ordered]@{}
foreach ($ns in $namespaceJson.results) {
foreach ($ns in $namespaceCommands) {
if (-not $ns.name) { continue }

$commandStrings = @(Get-NamespaceCommandStrings -Node $ns -AllPromptKeys $AllPromptKeys)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,19 @@ try {
# Try to parse the JSON to verify it's valid
try {
$json = Get-Content $jsonFile -Raw | ConvertFrom-Json
$toolCount = if ($null -ne $json.results) { $json.results.Count } elseif ($null -ne $json.tools) { $json.tools.Count } else { $null }
$commandsProperty = $json.results.PSObject.Properties['commands']
$toolCount = if ($null -ne $commandsProperty) {
$commandsProperty.Value.Count
}
elseif ($null -ne $json.results) {
$json.results.Count
}
elseif ($null -ne $json.tools) {
$json.tools.Count
}
else {
$null
}

if ($null -ne $toolCount) {
Write-Host "Contains $toolCount tools" -ForegroundColor Cyan
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json.Serialization;

namespace ToolSelection.Models;

public sealed record ListToolsPayload(
[property: JsonPropertyName("commands")] List<Tool>? Commands);
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,14 @@ public class ListToolsResult
public string? Message { get; set; }

[JsonPropertyName("results")]
public List<Tool>? Tools { get; set; }
public ListToolsPayload? Results { get; set; }

[JsonIgnore]
public List<Tool>? Tools
{
get => Results?.Commands;
set => Results = value is null ? null : new(value);
}

[JsonPropertyName("consolidated_tools")]
public List<Tool>? ConsolidatedTools { get; set; }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ namespace ToolSelection.Models;

[JsonSourceGenerationOptions(WriteIndented = true, PropertyNameCaseInsensitive = true)]
[JsonSerializable(typeof(ListToolsResult))]
[JsonSerializable(typeof(ListToolsPayload))]
[JsonSerializable(typeof(List<Tool>))]
[JsonSerializable(typeof(Tool))]
[JsonSerializable(typeof(Dictionary<string, List<string>>), TypeInfoPropertyName = "DictionaryStringListString")]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

using System.Text.Json;
using ToolSelection.Models;
using Xunit;

namespace ToolMetadataExporter.UnitTests.Models;

public class ListToolsResultTests
{
[Fact]
public void Deserialize_ReadsCommandsFromObjectRoot()
{
const string json = """
{
"status": 200,
"results": {
"commands": [
{
"name": "list",
"command": "tools list"
}
]
}
}
""";

var result = Assert.IsType<ListToolsResult>(
JsonSerializer.Deserialize(json, SourceGenerationContext.Default.ListToolsResult));
var tool = Assert.Single(Assert.IsType<List<Tool>>(result.Tools));

Assert.Equal("list", tool.Name);
Assert.Equal("tools list", tool.Command);
}
}
14 changes: 14 additions & 0 deletions servers/Azure.Mcp.Server/changelog-entries/1784946361604.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
changes:
- section: "Breaking Changes"
description: |
Normalized result payloads for the following tools:
- `tools list` now returns either `{ commands }` or `{ names }`, omitting the inactive field.
- `advisor recommendation apply` now returns the resulting rules in a `{ rules }` object.
- `azurebestpractices get`, `azurebestpractices ai_app`, and `azureterraformbestpractices get` now return the resulting best practices in a `{ bestPractices }` object.
- `functions language list` and `functions project get` now return the resulting singleton values as direct objects.
- `monitor healthmodels list` now returns the resulting health models in a `{ healthModels }` object.
- `monitor resource log query` and `monitor workspace log query` now return the resulting rows in a `{ results }` object.
- `monitor instrumentation orchestrator-start`, `orchestrator-next`, `send-brownfield-analysis`, and `send-enhancement-select` now return the resulting scalar value in a `{ result }` object.
- `search index query` now returns the resulting rows in a `{ results }` object.
- `sql server get` now returns the resulting servers in a `{ servers }` object.
- `wellarchitectedframework serviceguide get` now returns the resulting guidance in a `{ guidance }` object.
12 changes: 12 additions & 0 deletions servers/Fabric.Mcp.Server/changelog-entries/1784946362379.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
changes:
- section: "Breaking Changes"
description: |
Normalized result payloads for the following tools:
- `docs best-practices` now returns the resulting best practices in a `{ bestPractices }` object.
- `docs item-definitions` now returns the resulting definition string in a `{ definition }` object.
- `docs platform-api-spec` and `docs workload-api-spec` now return the produced public API in a `{ publicApi }` object.
- `onelake create or update data access role` and `onelake get data access role` now return the produced roles in a `{ roles }` object.
- `onelake list data access roles` now returns `{ roles }` instead of `{ value }`.
- `onelake get settings` now returns the produced settings in a `{ settings }` object.
- `onelake create shortcut` and `onelake get shortcut` now return the produced shortcut in a `{ shortcut }` object.
- `onelake list shortcuts` now returns `{ shortcuts }` instead of `{ value }`.
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ namespace Azure.Mcp.Tools.Advisor.Commands;
[JsonSerializable(typeof(RecommendationListCommand.RecommendationListResult))]
[JsonSerializable(typeof(RecommendationTypeListCommand.RecommendationTypeListResult))]
[JsonSerializable(typeof(RecommendationSummaryCommand.RecommendationSummaryResult))]
[JsonSerializable(typeof(List<string>))]
[JsonSerializable(typeof(RecommendationApplyCommand.RecommendationApplyCommandResult))]
[JsonSerializable(typeof(RecommendationData))]
[JsonSerializable(typeof(Models.Recommendation))]
[JsonSerializable(typeof(Models.RecommendationType))]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ namespace Azure.Mcp.Tools.Advisor.Commands.Recommendation;
Secret = false
)]
public sealed class RecommendationApplyCommand(ILogger<RecommendationApplyCommand> logger)
: BaseCommand<RecommendationApplyOptions, List<string>>
: BaseCommand<RecommendationApplyOptions, RecommendationApplyCommand.RecommendationApplyCommandResult>
{
private readonly ILogger<RecommendationApplyCommand> _logger = logger;
private static readonly ConcurrentDictionary<string, string> s_advisorRecommendationRulesCache = new();
Expand All @@ -51,7 +51,9 @@ public override Task<CommandResponse> ExecuteAsync(CommandContext context, Recom
var resourceFileName = $"{options.Resource}.json";
var recommendationApplyRules = GetAdvisorRecommendationRules(resourceFileName);

context.Response.Results = ResponseResult.Create([recommendationApplyRules], AdvisorJsonContext.Default.ListString);
context.Response.Results = ResponseResult.Create(
new([recommendationApplyRules]),
AdvisorJsonContext.Default.RecommendationApplyCommandResult);

context.Activity?.AddTag("RecommendationRules_Resource", options.Resource);
}
Expand Down Expand Up @@ -102,4 +104,6 @@ private static HashSet<string> LoadAvailableResources()

return resources;
}

public sealed record RecommendationApplyCommandResult(List<string> Rules);
}
Loading
Loading