diff --git a/CHANGELOG.md b/CHANGELOG.md index 9367e48d..026890b7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Agents that export telemetry through the delegated (OBO) route need `Agent365.Ob Blueprint agents that export telemetry through the app-only S2S endpoint don't need these permissions, and `a365 setup all` no longer requests them for blueprint agents (#501). ### Added +- `a365 develop-mcp list-published-servers` lists the MCP servers published to your tenant from every Dataverse environment you can access, with each server's approval status and source environment. - `a365 develop-mcp grant-agents-access --agent-blueprint-id --mcp-server-name ` reports which agent instances of a blueprint are missing the permission to call a BYO MCP server, and prompts you to select which ones to grant it to (#500). - When more than one Entra application shares the MCP server's name, `a365 develop-mcp grant-agents-access` now lists them all and asks which one to use instead of failing (#500). - `a365 develop-mcp grant-agents-access --help` now lists Microsoft's first-party agent blueprint names and IDs, and the same list is printed when `--agent-blueprint-id` is missing or not a GUID, so you can find the ID without looking it up elsewhere (#500). diff --git a/docs/commands/README.md b/docs/commands/README.md index 29153973..9f3dfe89 100644 --- a/docs/commands/README.md +++ b/docs/commands/README.md @@ -25,6 +25,7 @@ There is reference documentation for each command. | [develop-mcp](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp) | Manage MCP servers in Dataverse environments. | | [develop-mcp list-environments](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-list-environments) | List all Dataverse environments available for MCP server management. | | [develop-mcp list-servers](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-list-servers) | List MCP servers in a specific Dataverse environment. | +| [develop-mcp list-published-servers](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-list-published-servers) | List MCP servers published to tenant scope from all accessible Dataverse environments. | | [develop-mcp publish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-publish) | Publish an MCP server to a Dataverse environment. | | [develop-mcp unpublish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/develop-mcp#develop-mcp-unpublish) | Unpublish an MCP server from a Dataverse environment. | | [publish](https://learn.microsoft.com/microsoft-agent-365/developer/reference/cli/publish) | Update manifest.json ID values and publish the package. Configure federated identity and app role assignments. | diff --git a/src/DEVELOPER.md b/src/DEVELOPER.md index f0a0ec3f..331b0bc9 100644 --- a/src/DEVELOPER.md +++ b/src/DEVELOPER.md @@ -86,6 +86,7 @@ The CLI provides a `develop-mcp` command for managing Model Context Protocol (MC **Server Management:** - `a365 develop-mcp list-servers -e ` — List MCP servers in a specific Dataverse environment +- `a365 develop-mcp list-published-servers` — List MCP servers published to tenant scope from all accessible Dataverse environments - `a365 develop-mcp publish -e -s ` — Publish an MCP server to a Dataverse environment - `a365 develop-mcp unpublish -e -s ` — Unpublish an MCP server from a Dataverse environment @@ -112,6 +113,9 @@ a365 develop-mcp list-environments # List servers in a specific environment a365 develop-mcp list-servers -e "Default-12345678-1234-1234-1234-123456789abc" +# List servers published to tenant scope from every environment you can access +a365 develop-mcp list-published-servers + # Publish a server with alias and display name a365 develop-mcp publish \ --environment-id "Default-12345678-1234-1234-1234-123456789abc" \ diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs index 19712bb0..c3b719d0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -37,6 +37,7 @@ public static Command CreateCommand( // Add subcommands developMcpCommand.AddCommand(CreateListEnvironmentsSubcommand(logger, toolingService)); developMcpCommand.AddCommand(CreateListServersSubcommand(logger, toolingService)); + developMcpCommand.AddCommand(CreateListPublishedServersSubcommand(logger, toolingService)); developMcpCommand.AddCommand(CreatePublishSubcommand(logger, toolingService, graphApiService)); developMcpCommand.AddCommand(CreateUnpublishSubcommand(logger, toolingService)); developMcpCommand.AddCommand(CreateRegisterExternalMcpServerSubcommand(logger, toolingService, graphApiService)); @@ -355,6 +356,69 @@ private static Command CreateListServersSubcommand( return command; } + /// + /// Creates the list-published-servers subcommand + /// + private static Command CreateListPublishedServersSubcommand( + ILogger logger, + IAgent365ToolingService toolingService) + { + var command = new Command("list-published-servers", "List MCP servers published to tenant scope from all accessible Dataverse environments"); + + var dryRunOption = new Option( + name: "--dry-run", + description: "Show what would be done without executing" + ); + command.AddOption(dryRunOption); + + // Verbose is handled globally in Program.cs (sets LogLevel.Debug); declared here so the parser accepts -v. + command.AddOption(new Option(["--verbose", "-v"], description: "Enable verbose logging")); + + command.SetHandler(async (context) => + { + logger.LogInformation("Starting list-published-servers operation..."); + + if (context.ParseResult.GetValueForOption(dryRunOption)) + { + logger.LogInformation("[DRY RUN] Would read config from a365.config.json"); + logger.LogInformation("[DRY RUN] Would query MCP servers published to tenant scope across all accessible Dataverse environments"); + logger.LogInformation("[DRY RUN] Would display list of published MCP servers"); + return; + } + + var serversResponse = await toolingService.ListPublishedServersAsync(context.GetCancellationToken()); + + if (serversResponse == null) + { + logger.LogError("Failed to list published MCP servers"); + context.ExitCode = 1; + return; + } + + var servers = serversResponse.GetServers(); + + if (servers.Length == 0) + { + logger.LogInformation("No published MCP servers found"); + return; + } + + logger.LogInformation("Published MCP Servers:"); + logger.LogInformation("======================"); + + foreach (var server in servers) + { + logger.LogInformation("{McpServerName}", server.McpServerName ?? "Unknown"); + logger.LogInformation(" Status: {Status}", server.Status ?? "Unknown"); + logger.LogInformation(" Source MCP Server Name: {SourceMcpServerName}", server.SourceMcpServerName ?? "Unknown"); + logger.LogInformation(" Source Environment ID: {SourceEnvironmentId}", server.SourceEnvironmentId ?? "Unknown"); + } + logger.LogInformation("Listed {Count} published MCP server(s)", servers.Length); + }); + + return command; + } + /// /// Creates the publish subcommand /// diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishedMcpServer.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishedMcpServer.cs new file mode 100644 index 00000000..a146b483 --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishedMcpServer.cs @@ -0,0 +1,56 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Text.Json.Serialization; + +namespace Microsoft.Agents.A365.DevTools.Cli.Models; + +/// +/// Model representing an MCP server published to tenant scope from a Dataverse environment +/// +public class PublishedMcpServer +{ + /// + /// The tenant-level server name (alias) + /// + [JsonPropertyName("mcpServerName")] + public string? McpServerName { get; set; } + + /// + /// The approval status (PendingApproval, Approved, or Blocked) + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// The server name in the source Dataverse environment + /// + [JsonPropertyName("sourceMcpServerName")] + public string? SourceMcpServerName { get; set; } + + /// + /// The ID of the Dataverse environment the server was published from + /// + [JsonPropertyName("sourceEnvironmentId")] + public string? SourceEnvironmentId { get; set; } +} + +/// +/// Response model for the list published MCP servers endpoint +/// +public class PublishedMcpServersResponse +{ + /// + /// MCP servers published to tenant scope across all accessible Dataverse environments + /// + [JsonPropertyName("servers")] + public PublishedMcpServer[] Servers { get; set; } = Array.Empty(); + + /// + /// Gets the servers array, or an empty array when the response carries none + /// + public PublishedMcpServer[] GetServers() + { + return Servers ?? Array.Empty(); + } +} diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/README.md b/src/Microsoft.Agents.A365.DevTools.Cli/Models/README.md index 080a62ae..31b5be38 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/README.md +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/README.md @@ -32,6 +32,7 @@ This folder contains data models used throughout the CLI. Models are organized b |-------|------|-------------| | **DataverseEnvironment** | `DataverseEnvironment.cs` | Dataverse environment information | | **DataverseMcpServer** | `DataverseMcpServer.cs` | MCP server in Dataverse | +| **PublishedMcpServer** | `PublishedMcpServer.cs` | MCP server published to tenant scope from a Dataverse environment | | **PublishMcpServerRequest** | `PublishMcpServerRequest.cs` | MCP server publish request | | **PublishMcpServerResponse** | `PublishMcpServerResponse.cs` | MCP server publish response | | **ToolingManifest** | `ToolingManifest.cs` | MCP tooling manifest structure | diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs index 9a47768f..acc8d7bd 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -291,6 +291,15 @@ private string BuildListMcpServersUrl(string environment, string environmentId) return $"{baseUrl}/agents/dataverse/environments/{environmentId}/mcpServers"; } + /// + /// Builds URL for listing MCP servers published to tenant scope across all accessible Dataverse environments + /// + private string BuildListPublishedMcpServersUrl(string environment) + { + var baseUrl = BuildAgent365ToolsBaseUrl(environment); + return $"{baseUrl}/agents/dataverse/publishedMcpServers"; + } + /// /// Builds URL for publishing an MCP server to a Dataverse environment. Hits the platform's v2 /// publish endpoint, which performs the full elevation orchestration (PPMI provisioning and MOS @@ -507,6 +516,52 @@ private string BuildProvisionIdentityUrl(string environment, string serverName) } } + /// + public async Task ListPublishedServersAsync(CancellationToken cancellationToken = default) + { + try + { + var endpointUrl = BuildListPublishedMcpServersUrl(_environment); + var correlationId = Internal.HttpClientFactory.GenerateCorrelationId(); + + _logger.LogDebug("Listing published MCP servers (CorrelationId: {CorrelationId})", correlationId); + _logger.LogDebug("Environment: {Env}", _environment); + _logger.LogDebug("Endpoint URL: {Url}", endpointUrl); + + var audience = ConfigConstants.GetAgent365ToolsResourceAppId(_environment); + _logger.LogDebug("Acquiring access token for audience: {Audience}", audience); + + var loginHint = await AzCliHelper.ResolveLoginHintAsync(); + var authToken = await _authService.GetAccessTokenAsync( + audience, userId: loginHint, ct: cancellationToken, authorityHost: _authorityHost); + if (string.IsNullOrWhiteSpace(authToken)) + { + _logger.LogError("Failed to acquire authentication token"); + return null; + } + + using var httpClient = Internal.HttpClientFactory.CreateAuthenticatedClient(authToken, correlationId: correlationId); + + LogRequest("GET", endpointUrl); + + using var response = await httpClient.GetAsync(endpointUrl, cancellationToken); + + var (isSuccess, responseContent) = await ValidateResponseAsync(response, "list published MCP servers", cancellationToken); + if (!isSuccess) + { + return null; + } + + return JsonDeserializationHelper.DeserializeWithDoubleSerialization( + responseContent, _logger); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to list published MCP servers"); + return null; + } + } + /// public async Task PublishServerAsync( string environmentId, diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs index 91ba94e6..62a2a6b3 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/IAgent365ToolingService.cs @@ -31,6 +31,13 @@ public interface IAgent365ToolingService string environmentId, CancellationToken cancellationToken = default); + /// + /// Lists MCP servers published to tenant scope from every Dataverse environment the caller can access + /// + /// Cancellation token + /// Response containing list of published MCP servers + Task ListPublishedServersAsync(CancellationToken cancellationToken = default); + /// /// Publishes an MCP server to a Dataverse environment via the platform's v2 publish endpoint, /// which performs the full elevation orchestration (PPMI provisioning and MOS upload). diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs index 5d9a079d..c1806c66 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandRegressionTests.cs @@ -40,6 +40,7 @@ public async Task DryRunMode_NeverCallsActualServices() { new[] { "list-environments", "--dry-run" }, new[] { "list-servers", "-e", "test-env", "--dry-run" }, + new[] { "list-published-servers", "--dry-run" }, new[] { "publish", "-e", "test-env", "-s", "test-server", "--dry-run" }, new[] { "unpublish", "-e", "test-env", "-s", "test-server", "--dry-run" } }; @@ -53,6 +54,7 @@ public async Task DryRunMode_NeverCallsActualServices() // Verify no service methods were called await _mockToolingService.DidNotReceive().ListEnvironmentsAsync(); await _mockToolingService.DidNotReceive().ListServersAsync(Arg.Any()); + await _mockToolingService.DidNotReceive().ListPublishedServersAsync(Arg.Any()); await _mockToolingService.DidNotReceive().PublishServerAsync(Arg.Any(), Arg.Any(), Arg.Any()); await _mockToolingService.DidNotReceive().UnpublishServerAsync(Arg.Any(), Arg.Any()); } @@ -351,6 +353,136 @@ public async Task ServiceIntegration_UnpublishCommand_PassesCorrectParameters() await _mockToolingService.Received(1).UnpublishServerAsync(testEnvId, testServerName); } + [Fact] + public async Task ListPublishedServersCommand_PrintsEveryServerInResponseOrder() + { + // Arrange + var logger = new CapturingLogger(); + var toolingService = Substitute.For(); + toolingService.ListPublishedServersAsync(Arg.Any()).Returns(new PublishedMcpServersResponse + { + Servers = new[] + { + new PublishedMcpServer + { + McpServerName = "zeta-alias", + Status = "Approved", + SourceMcpServerName = "msdyn_Zeta", + SourceEnvironmentId = "env-zeta-id" + }, + new PublishedMcpServer + { + McpServerName = "alpha-alias", + Status = "PendingApproval", + SourceMcpServerName = "msdyn_Alpha", + SourceEnvironmentId = "env-alpha-id" + } + } + }); + var command = DevelopMcpCommand.CreateCommand(logger, toolingService); + + // Act + var result = await command.InvokeAsync(new[] { "list-published-servers" }); + + // Assert + result.Should().Be(0); + logger.Messages.Should().ContainInOrder( + new[] + { + "zeta-alias", + " Status: Approved", + " Source MCP Server Name: msdyn_Zeta", + " Source Environment ID: env-zeta-id", + "alpha-alias", + " Status: PendingApproval", + " Source MCP Server Name: msdyn_Alpha", + " Source Environment ID: env-alpha-id", + "Listed 2 published MCP server(s)" + }, + because: "every field must be shown for each server in the order the platform returned them"); + } + + [Fact] + public async Task ListPublishedServersCommand_MissingFields_PrintsUnknown() + { + // Arrange + var logger = new CapturingLogger(); + var toolingService = Substitute.For(); + toolingService.ListPublishedServersAsync(Arg.Any()).Returns(new PublishedMcpServersResponse + { + Servers = new[] { new PublishedMcpServer() } + }); + var command = DevelopMcpCommand.CreateCommand(logger, toolingService); + + // Act + var result = await command.InvokeAsync(new[] { "list-published-servers" }); + + // Assert + result.Should().Be(0); + logger.Messages.Should().ContainInOrder( + new[] + { + "Unknown", + " Status: Unknown", + " Source MCP Server Name: Unknown", + " Source Environment ID: Unknown", + "Listed 1 published MCP server(s)" + }, + because: "a field the platform omits must print as Unknown rather than a blank line"); + } + + [Fact] + public async Task ListPublishedServersCommand_NoServers_ReportsNoneAndExitsZero() + { + // Arrange + var logger = new CapturingLogger(); + var toolingService = Substitute.For(); + toolingService.ListPublishedServersAsync(Arg.Any()).Returns(new PublishedMcpServersResponse()); + var command = DevelopMcpCommand.CreateCommand(logger, toolingService); + + // Act + var result = await command.InvokeAsync(new[] { "list-published-servers" }); + + // Assert + result.Should().Be(0, because: "a tenant with no published servers is a valid state, not a failure"); + logger.Messages.Should().Contain("No published MCP servers found"); + } + + [Fact] + public async Task ListPublishedServersCommand_ServiceFailure_LogsErrorAndExitsOne() + { + // Arrange + var logger = new CapturingLogger(); + var toolingService = Substitute.For(); + toolingService.ListPublishedServersAsync(Arg.Any()) + .Returns(Task.FromResult(null)); + var command = DevelopMcpCommand.CreateCommand(logger, toolingService); + + // Act + var result = await command.InvokeAsync(new[] { "list-published-servers" }); + + // Assert + result.Should().Be(1, + because: "the service returns null on auth, scope, or server errors, and scripts must be able to detect the failure"); + logger.Errors.Should().Contain("Failed to list published MCP servers"); + } + + private sealed class CapturingLogger : ILogger + { + private readonly List<(LogLevel Level, string Message)> _entries = []; + + public IEnumerable Messages => _entries.Select(e => e.Message); + + public IEnumerable Errors => _entries.Where(e => e.Level == LogLevel.Error).Select(e => e.Message); + + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + public bool IsEnabled(LogLevel logLevel) => true; + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, + Func formatter) => _entries.Add((logLevel, formatter(state, exception))); + } + [Fact] public void CommandStructure_HasNoPositionalArguments() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs index 0f4efc91..49fac302 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Commands/DevelopMcpCommandTests.cs @@ -42,13 +42,16 @@ public void CreateCommand_HasAllExpectedSubcommands() var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); // Assert - command.Subcommands.Should().HaveCount(5); + command.Subcommands.Should().HaveCount(6, + because: "list-published-servers is registered alongside the five base subcommands; " + + "evaluate and grant-agents-access register only when their services are supplied"); var subcommandNames = command.Subcommands.Select(sc => sc.Name).ToList(); subcommandNames.Should().Contain(new[] { "list-environments", "list-servers", + "list-published-servers", "publish", "unpublish", "register-external-mcp-server" @@ -106,6 +109,33 @@ public void ListServersSubcommand_HasCorrectOptionsWithAliases() verboseOption!.Aliases.Should().Contain("--verbose"); } + [Fact] + public void ListPublishedServersSubcommand_HasCorrectOptionsAndAliases() + { + // Act + var command = DevelopMcpCommand.CreateCommand(_mockLogger, _mockToolingService); + var subcommand = command.Subcommands.First(sc => sc.Name == "list-published-servers"); + + // Assert + subcommand.Description.Should().Be("List MCP servers published to tenant scope from all accessible Dataverse environments"); + + var options = subcommand.Options.ToList(); + options.Should().HaveCount(2); // dry-run, verbose (plus help automatically) + options.Should().NotContain(o => o.Name == "environment-id", + because: "the endpoint returns servers from every environment the caller can access, so there is no environment to select"); + + // Verify dry-run option + var dryRunOption = options.FirstOrDefault(o => o.Name == "dry-run"); + dryRunOption.Should().NotBeNull(); + dryRunOption!.Aliases.Should().Contain("--dry-run"); + + // Verify verbose option + var verboseOption = options.FirstOrDefault(o => o.Name == "verbose"); + verboseOption.Should().NotBeNull(); + verboseOption!.Aliases.Should().Contain("-v"); + verboseOption!.Aliases.Should().Contain("--verbose"); + } + [Fact] public void PublishSubcommand_HasCorrectOptionsWithAliases() { diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/PublishedMcpServersResponseTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/PublishedMcpServersResponseTests.cs new file mode 100644 index 00000000..3b5da7ee --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/PublishedMcpServersResponseTests.cs @@ -0,0 +1,76 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using FluentAssertions; +using Microsoft.Agents.A365.DevTools.Cli.Models; +using Microsoft.Agents.A365.DevTools.Cli.Services.Helpers; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Models; + +public class PublishedMcpServersResponseTests +{ + [Fact] + public void Deserialize_PublishedMcpServersBody_MapsEveryFieldInResponseOrder() + { + // Arrange + const string json = """ + { + "servers": [ + { + "mcpServerName": "zeta-alias", + "status": "Blocked", + "sourceMcpServerName": "msdyn_Zeta", + "sourceEnvironmentId": "env-zeta-id" + }, + { + "mcpServerName": "alpha-alias", + "status": "PendingApproval", + "sourceMcpServerName": "msdyn_Alpha", + "sourceEnvironmentId": "env-alpha-id" + } + ] + } + """; + + // Act + var response = JsonDeserializationHelper.DeserializeWithDoubleSerialization( + json, NullLogger.Instance); + + // Assert + response.Should().NotBeNull(); + response!.GetServers().Should().BeEquivalentTo( + new[] + { + new PublishedMcpServer + { + McpServerName = "zeta-alias", + Status = "Blocked", + SourceMcpServerName = "msdyn_Zeta", + SourceEnvironmentId = "env-zeta-id" + }, + new PublishedMcpServer + { + McpServerName = "alpha-alias", + Status = "PendingApproval", + SourceMcpServerName = "msdyn_Alpha", + SourceEnvironmentId = "env-alpha-id" + } + }, + options => options.WithStrictOrdering(), + because: "the CLI must read every camelCase field the publishedMcpServers route returns, keeping the platform's order"); + } + + [Fact] + public void GetServers_NullServersArray_ReturnsEmpty() + { + // Act + var response = JsonDeserializationHelper.DeserializeWithDoubleSerialization( + """{"servers":null}""", NullLogger.Instance); + + // Assert + response.Should().NotBeNull(); + response!.GetServers().Should().BeEmpty( + because: "the command iterates the servers array, so a null array must read as no servers rather than crash"); + } +}