From bddb5e4ec8ecc45c3bd8acf6c5a891470fd4e4d8 Mon Sep 17 00:00:00 2001 From: Deepali Garg Date: Thu, 1 Oct 2026 21:24:16 -0700 Subject: [PATCH 1/2] Add develop-mcp list-published-servers command Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- CHANGELOG.md | 1 + docs/commands/README.md | 1 + src/DEVELOPER.md | 4 + .../Commands/DevelopMcpCommand.cs | 72 +++++++++++ .../Models/PublishedMcpServer.cs | 86 +++++++++++++ .../Models/README.md | 1 + .../Services/Agent365ToolingService.cs | 55 ++++++++ .../Services/IAgent365ToolingService.cs | 7 + .../DevelopMcpCommandRegressionTests.cs | 121 ++++++++++++++++++ .../Commands/DevelopMcpCommandTests.cs | 32 ++++- .../PublishedMcpServersResponseTests.cs | 94 ++++++++++++++ 11 files changed, 473 insertions(+), 1 deletion(-) create mode 100644 src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishedMcpServer.cs create mode 100644 src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/PublishedMcpServersResponseTests.cs 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..471ac8bc 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,77 @@ 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("{DisplayName}", server.DisplayName ?? server.McpServerName ?? "Unknown"); + logger.LogInformation(" Name: {Name}", server.McpServerName ?? "Unknown"); + logger.LogInformation(" URL: {Url}", server.Url ?? "Unknown"); + logger.LogInformation(" Status: {Status}", server.Status ?? "Unknown"); + logger.LogInformation(" Source Environment: {SourceEnvironmentName}", server.SourceEnvironmentName ?? "Unknown"); + logger.LogInformation(" Source Environment ID: {SourceEnvironmentId}", server.SourceEnvironmentId ?? "Unknown"); + logger.LogInformation(" Source Server Name: {SourceServerName}", server.SourceServerName ?? "Unknown"); + + if (!string.IsNullOrWhiteSpace(server.Description)) + { + logger.LogInformation(" Description: {Description}", server.Description); + } + } + 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..a2d8e80e --- /dev/null +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishedMcpServer.cs @@ -0,0 +1,86 @@ +// 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 display name of the MCP server + /// + [JsonPropertyName("displayName")] + public string? DisplayName { get; set; } + + /// + /// The description of the MCP server + /// + [JsonPropertyName("description")] + public string? Description { get; set; } + + /// + /// The tenant URL for the MCP server + /// + [JsonPropertyName("url")] + public string? Url { get; set; } + + /// + /// The approval status (PendingApproval, Approved, or Blocked) + /// + [JsonPropertyName("status")] + public string? Status { get; set; } + + /// + /// The ID of the Dataverse environment the server was published from + /// + [JsonPropertyName("sourceEnvironmentId")] + public string? SourceEnvironmentId { get; set; } + + /// + /// The display name of the Dataverse environment the server was published from + /// + [JsonPropertyName("sourceEnvironmentName")] + public string? SourceEnvironmentName { get; set; } + + /// + /// The server name in the source Dataverse environment + /// + [JsonPropertyName("sourceServerName")] + public string? SourceServerName { get; set; } +} + +/// +/// Response model for the list published MCP servers endpoint +/// +public class PublishedMcpServersResponse +{ + /// + /// Total count of published MCP servers + /// + [JsonPropertyName("count")] + public int Count { get; set; } + + /// + /// 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..008558c8 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,125 @@ 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 + { + Count = 2, + Servers = new[] + { + new PublishedMcpServer + { + McpServerName = "zeta-alias", + DisplayName = "Zeta Server", + Description = "Zeta description", + Url = "https://tenant.example/agents/zeta-alias", + Status = "Approved", + SourceEnvironmentId = "env-zeta-id", + SourceEnvironmentName = "Zeta Environment", + SourceServerName = "msdyn_Zeta" + }, + new PublishedMcpServer + { + McpServerName = "alpha-alias", + DisplayName = "alpha-alias", + Description = null, + Url = "https://tenant.example/agents/alpha-alias", + Status = "PendingApproval", + SourceEnvironmentId = "env-alpha-id", + SourceEnvironmentName = "Alpha Environment", + SourceServerName = "msdyn_Alpha" + } + } + }); + 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 Server", + " Name: zeta-alias", + " URL: https://tenant.example/agents/zeta-alias", + " Status: Approved", + " Source Environment: Zeta Environment", + " Source Environment ID: env-zeta-id", + " Source Server Name: msdyn_Zeta", + " Description: Zeta description", + "alpha-alias", + " Name: alpha-alias", + " URL: https://tenant.example/agents/alpha-alias", + " Status: PendingApproval", + " Source Environment: Alpha Environment", + " Source Environment ID: env-alpha-id", + " Source Server Name: msdyn_Alpha", + "Listed 2 published MCP server(s)" + }, + because: "every field, including the name when it equals the display name, must be shown for each server in the order the platform returned them"); + logger.Messages.Should().ContainSingle(m => m.Contains("Description:"), + because: "description is nullable and is printed only when the server has one"); + } + + [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..1c3da197 --- /dev/null +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/PublishedMcpServersResponseTests.cs @@ -0,0 +1,94 @@ +// 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 = """ + { + "count": 2, + "servers": [ + { + "mcpServerName": "zeta-alias", + "displayName": "Zeta Server", + "description": null, + "url": "https://tenant.example/agents/zeta-alias", + "status": "Blocked", + "sourceEnvironmentId": "env-zeta-id", + "sourceEnvironmentName": "Zeta Environment", + "sourceServerName": "msdyn_Zeta" + }, + { + "mcpServerName": "alpha-alias", + "displayName": "Alpha Server", + "description": "Alpha description", + "url": "https://tenant.example/agents/alpha-alias", + "status": "PendingApproval", + "sourceEnvironmentId": "env-alpha-id", + "sourceEnvironmentName": "Alpha Environment", + "sourceServerName": "msdyn_Alpha" + } + ] + } + """; + + // Act + var response = JsonDeserializationHelper.DeserializeWithDoubleSerialization( + json, NullLogger.Instance); + + // Assert + response.Should().NotBeNull(); + response!.Count.Should().Be(2); + response.GetServers().Should().BeEquivalentTo( + new[] + { + new PublishedMcpServer + { + McpServerName = "zeta-alias", + DisplayName = "Zeta Server", + Description = null, + Url = "https://tenant.example/agents/zeta-alias", + Status = "Blocked", + SourceEnvironmentId = "env-zeta-id", + SourceEnvironmentName = "Zeta Environment", + SourceServerName = "msdyn_Zeta" + }, + new PublishedMcpServer + { + McpServerName = "alpha-alias", + DisplayName = "Alpha Server", + Description = "Alpha description", + Url = "https://tenant.example/agents/alpha-alias", + Status = "PendingApproval", + SourceEnvironmentId = "env-alpha-id", + SourceEnvironmentName = "Alpha Environment", + SourceServerName = "msdyn_Alpha" + } + }, + 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( + """{"count":0,"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"); + } +} From e4dcaeada0fbc858ec7d8c278855b3968066a8bc Mon Sep 17 00:00:00 2001 From: Deepali Garg Date: Sun, 4 Oct 2026 21:25:01 -0700 Subject: [PATCH 2/2] Match trimmed list-published-servers contract Response is now { servers } with mcpServerName, status, sourceMcpServerName and sourceEnvironmentId (MCP-Platform PR 3968 review). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../Commands/DevelopMcpCommand.cs | 12 +--- .../Models/PublishedMcpServer.cs | 42 ++----------- .../DevelopMcpCommandRegressionTests.cs | 63 +++++++++++-------- .../PublishedMcpServersResponseTests.cs | 38 +++-------- 4 files changed, 55 insertions(+), 100 deletions(-) diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs index 471ac8bc..c3b719d0 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Commands/DevelopMcpCommand.cs @@ -408,18 +408,10 @@ private static Command CreateListPublishedServersSubcommand( foreach (var server in servers) { - logger.LogInformation("{DisplayName}", server.DisplayName ?? server.McpServerName ?? "Unknown"); - logger.LogInformation(" Name: {Name}", server.McpServerName ?? "Unknown"); - logger.LogInformation(" URL: {Url}", server.Url ?? "Unknown"); + logger.LogInformation("{McpServerName}", server.McpServerName ?? "Unknown"); logger.LogInformation(" Status: {Status}", server.Status ?? "Unknown"); - logger.LogInformation(" Source Environment: {SourceEnvironmentName}", server.SourceEnvironmentName ?? "Unknown"); + logger.LogInformation(" Source MCP Server Name: {SourceMcpServerName}", server.SourceMcpServerName ?? "Unknown"); logger.LogInformation(" Source Environment ID: {SourceEnvironmentId}", server.SourceEnvironmentId ?? "Unknown"); - logger.LogInformation(" Source Server Name: {SourceServerName}", server.SourceServerName ?? "Unknown"); - - if (!string.IsNullOrWhiteSpace(server.Description)) - { - logger.LogInformation(" Description: {Description}", server.Description); - } } logger.LogInformation("Listed {Count} published MCP server(s)", servers.Length); }); diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishedMcpServer.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishedMcpServer.cs index a2d8e80e..a146b483 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishedMcpServer.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Models/PublishedMcpServer.cs @@ -16,24 +16,6 @@ public class PublishedMcpServer [JsonPropertyName("mcpServerName")] public string? McpServerName { get; set; } - /// - /// The display name of the MCP server - /// - [JsonPropertyName("displayName")] - public string? DisplayName { get; set; } - - /// - /// The description of the MCP server - /// - [JsonPropertyName("description")] - public string? Description { get; set; } - - /// - /// The tenant URL for the MCP server - /// - [JsonPropertyName("url")] - public string? Url { get; set; } - /// /// The approval status (PendingApproval, Approved, or Blocked) /// @@ -41,22 +23,16 @@ public class PublishedMcpServer public string? Status { get; set; } /// - /// The ID of the Dataverse environment the server was published from - /// - [JsonPropertyName("sourceEnvironmentId")] - public string? SourceEnvironmentId { get; set; } - - /// - /// The display name of the Dataverse environment the server was published from + /// The server name in the source Dataverse environment /// - [JsonPropertyName("sourceEnvironmentName")] - public string? SourceEnvironmentName { get; set; } + [JsonPropertyName("sourceMcpServerName")] + public string? SourceMcpServerName { get; set; } /// - /// The server name in the source Dataverse environment + /// The ID of the Dataverse environment the server was published from /// - [JsonPropertyName("sourceServerName")] - public string? SourceServerName { get; set; } + [JsonPropertyName("sourceEnvironmentId")] + public string? SourceEnvironmentId { get; set; } } /// @@ -64,12 +40,6 @@ public class PublishedMcpServer /// public class PublishedMcpServersResponse { - /// - /// Total count of published MCP servers - /// - [JsonPropertyName("count")] - public int Count { get; set; } - /// /// MCP servers published to tenant scope across all accessible Dataverse environments /// 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 008558c8..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 @@ -361,30 +361,21 @@ public async Task ListPublishedServersCommand_PrintsEveryServerInResponseOrder() var toolingService = Substitute.For(); toolingService.ListPublishedServersAsync(Arg.Any()).Returns(new PublishedMcpServersResponse { - Count = 2, Servers = new[] { new PublishedMcpServer { McpServerName = "zeta-alias", - DisplayName = "Zeta Server", - Description = "Zeta description", - Url = "https://tenant.example/agents/zeta-alias", Status = "Approved", - SourceEnvironmentId = "env-zeta-id", - SourceEnvironmentName = "Zeta Environment", - SourceServerName = "msdyn_Zeta" + SourceMcpServerName = "msdyn_Zeta", + SourceEnvironmentId = "env-zeta-id" }, new PublishedMcpServer { McpServerName = "alpha-alias", - DisplayName = "alpha-alias", - Description = null, - Url = "https://tenant.example/agents/alpha-alias", Status = "PendingApproval", - SourceEnvironmentId = "env-alpha-id", - SourceEnvironmentName = "Alpha Environment", - SourceServerName = "msdyn_Alpha" + SourceMcpServerName = "msdyn_Alpha", + SourceEnvironmentId = "env-alpha-id" } } }); @@ -398,26 +389,46 @@ public async Task ListPublishedServersCommand_PrintsEveryServerInResponseOrder() logger.Messages.Should().ContainInOrder( new[] { - "Zeta Server", - " Name: zeta-alias", - " URL: https://tenant.example/agents/zeta-alias", + "zeta-alias", " Status: Approved", - " Source Environment: Zeta Environment", + " Source MCP Server Name: msdyn_Zeta", " Source Environment ID: env-zeta-id", - " Source Server Name: msdyn_Zeta", - " Description: Zeta description", "alpha-alias", - " Name: alpha-alias", - " URL: https://tenant.example/agents/alpha-alias", " Status: PendingApproval", - " Source Environment: Alpha Environment", + " Source MCP Server Name: msdyn_Alpha", " Source Environment ID: env-alpha-id", - " Source Server Name: msdyn_Alpha", "Listed 2 published MCP server(s)" }, - because: "every field, including the name when it equals the display name, must be shown for each server in the order the platform returned them"); - logger.Messages.Should().ContainSingle(m => m.Contains("Description:"), - because: "description is nullable and is printed only when the server has one"); + 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] 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 index 1c3da197..3b5da7ee 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/PublishedMcpServersResponseTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Models/PublishedMcpServersResponseTests.cs @@ -16,27 +16,18 @@ public void Deserialize_PublishedMcpServersBody_MapsEveryFieldInResponseOrder() // Arrange const string json = """ { - "count": 2, "servers": [ { "mcpServerName": "zeta-alias", - "displayName": "Zeta Server", - "description": null, - "url": "https://tenant.example/agents/zeta-alias", "status": "Blocked", - "sourceEnvironmentId": "env-zeta-id", - "sourceEnvironmentName": "Zeta Environment", - "sourceServerName": "msdyn_Zeta" + "sourceMcpServerName": "msdyn_Zeta", + "sourceEnvironmentId": "env-zeta-id" }, { "mcpServerName": "alpha-alias", - "displayName": "Alpha Server", - "description": "Alpha description", - "url": "https://tenant.example/agents/alpha-alias", "status": "PendingApproval", - "sourceEnvironmentId": "env-alpha-id", - "sourceEnvironmentName": "Alpha Environment", - "sourceServerName": "msdyn_Alpha" + "sourceMcpServerName": "msdyn_Alpha", + "sourceEnvironmentId": "env-alpha-id" } ] } @@ -48,31 +39,22 @@ public void Deserialize_PublishedMcpServersBody_MapsEveryFieldInResponseOrder() // Assert response.Should().NotBeNull(); - response!.Count.Should().Be(2); - response.GetServers().Should().BeEquivalentTo( + response!.GetServers().Should().BeEquivalentTo( new[] { new PublishedMcpServer { McpServerName = "zeta-alias", - DisplayName = "Zeta Server", - Description = null, - Url = "https://tenant.example/agents/zeta-alias", Status = "Blocked", - SourceEnvironmentId = "env-zeta-id", - SourceEnvironmentName = "Zeta Environment", - SourceServerName = "msdyn_Zeta" + SourceMcpServerName = "msdyn_Zeta", + SourceEnvironmentId = "env-zeta-id" }, new PublishedMcpServer { McpServerName = "alpha-alias", - DisplayName = "Alpha Server", - Description = "Alpha description", - Url = "https://tenant.example/agents/alpha-alias", Status = "PendingApproval", - SourceEnvironmentId = "env-alpha-id", - SourceEnvironmentName = "Alpha Environment", - SourceServerName = "msdyn_Alpha" + SourceMcpServerName = "msdyn_Alpha", + SourceEnvironmentId = "env-alpha-id" } }, options => options.WithStrictOrdering(), @@ -84,7 +66,7 @@ public void GetServers_NullServersArray_ReturnsEmpty() { // Act var response = JsonDeserializationHelper.DeserializeWithDoubleSerialization( - """{"count":0,"servers":null}""", NullLogger.Instance); + """{"servers":null}""", NullLogger.Instance); // Assert response.Should().NotBeNull();