diff --git a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs index 578b0839..f4f83640 100644 --- a/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs +++ b/src/Microsoft.Agents.A365.DevTools.Cli/Services/Agent365ToolingService.cs @@ -159,6 +159,46 @@ private class ApiStatusResponse } } + /// + /// Builds a populated failure from a failed publish response + /// body so the caller surfaces the server's actual error. The publish path previously discarded the + /// body and returned null, which the executor rendered as the misleading "No response received" + /// no matter what the server reported (a duplicate-instance rejection, a validation error, or a + /// downstream 5xx). Handles the platform's { Status, Message } envelope — including double-serialized + /// bodies, since the platform returns its already-JSON string via Ok(string) which re-serializes it — + /// and ASP.NET { error[, details] } / { message } problem bodies, falling back to the status code when + /// the body carries no readable message. + /// + /// The raw failure response body. + /// The HTTP status code, used only when the body has no readable message. + /// Logger for the double-serialization-aware deserialization helper. + /// A non-null failure response whose is set. + internal static PublishMcpServerResponse BuildPublishFailureResponse( + string? responseContent, + System.Net.HttpStatusCode statusCode, + ILogger logger) + { + // Prefer the platform's { Status, Message } envelope (the deserialization helper transparently + // unwraps double-serialized bodies). + var envelope = JsonDeserializationHelper.DeserializeWithDoubleSerialization( + responseContent ?? string.Empty, logger); + var message = envelope?.Message; + + // Otherwise fall back to ASP.NET-style { error[, details] } / { message } problem bodies. + if (string.IsNullOrWhiteSpace(message)) + { + message = ExtractErrorMessage(responseContent); + } + + return new PublishMcpServerResponse + { + Status = "Failed", + Message = string.IsNullOrWhiteSpace(message) + ? $"Server returned {statusCode}" + : message, + }; + } + /// /// Common helper method to log HTTP request details /// @@ -523,7 +563,10 @@ private string BuildProvisionIdentityUrl(string environment, string serverName) var (isSuccess, responseContent) = await ValidateResponseAsync(response, "publish MCP server", cancellationToken); if (!isSuccess) { - return null; + // Surface the server's actual error instead of returning null (which the executor renders + // as the misleading "No response received"). ValidateResponseAsync already flags both + // non-2xx responses and 200 bodies carrying a { Status: "Failed" } envelope. + return BuildPublishFailureResponse(responseContent, response.StatusCode, _logger); } // Try to deserialize response, but allow for empty/null response diff --git a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs index 86539da0..d60ebd8f 100644 --- a/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs +++ b/src/Tests/Microsoft.Agents.A365.DevTools.Cli.Tests/Services/Agent365ToolingServicePureFunctionTests.cs @@ -3,6 +3,9 @@ using FluentAssertions; using Microsoft.Agents.A365.DevTools.Cli.Services; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net; +using System.Text.Json; namespace Microsoft.Agents.A365.DevTools.Cli.Tests.Services; @@ -87,6 +90,92 @@ public void ExtractErrorMessage_ReturnsNull_WhenJsonHasNoKnownFields() Agent365ToolingService.ExtractErrorMessage(json).Should().BeNull(); } + // --- BuildPublishFailureResponse tests --- + + [Fact] + public void BuildPublishFailureResponse_SurfacesEnvelopeMessage_WhenDoubleSerialized() + { + // The platform returns its already-JSON string via Ok(string), which serializes it a second + // time. This is the exact shape a duplicate-instance rejection reaches the CLI as. + const string expected = "MCP server 'msdyn_DataverseMCPServer' is already published in environment 'env' under alias 'DG_DV_S22'. Only one published instance is allowed per server. Unpublish the existing instance before republishing."; + var inner = JsonSerializer.Serialize(new { Status = "Failed", Message = expected }); + var doubleSerialized = JsonSerializer.Serialize(inner); + + var result = Agent365ToolingService.BuildPublishFailureResponse(doubleSerialized, HttpStatusCode.OK, NullLogger.Instance); + + result.Should().NotBeNull(); + result.Status.Should().Be("Failed"); + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be(expected); + } + + [Fact] + public void BuildPublishFailureResponse_SurfacesEnvelopeMessage_WhenSingleSerialized() + { + const string expected = "Custom MCP server 'x' in the environment 'env' is not setup correctly. Please recreate the server and try publishing again."; + var body = JsonSerializer.Serialize(new { Status = "Failed", Message = expected }); + + var result = Agent365ToolingService.BuildPublishFailureResponse(body, HttpStatusCode.OK, NullLogger.Instance); + + result.Status.Should().Be("Failed"); + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be(expected); + } + + [Fact] + public void BuildPublishFailureResponse_SurfacesError_FromAspNetProblemBody() + { + var body = """{"error":"DisplayName is required in the request body"}"""; + + var result = Agent365ToolingService.BuildPublishFailureResponse(body, HttpStatusCode.BadRequest, NullLogger.Instance); + + result.Status.Should().Be("Failed"); + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("DisplayName is required in the request body"); + } + + [Fact] + public void BuildPublishFailureResponse_PrefersDetails_FromAspNetErrorAndDetailsBody() + { + var body = """{"error":"Failed to publish (v2) MCP server to Dataverse environment","details":"TEDS API call failed with status 403"}"""; + + var result = Agent365ToolingService.BuildPublishFailureResponse(body, HttpStatusCode.InternalServerError, NullLogger.Instance); + + result.Status.Should().Be("Failed"); + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("TEDS API call failed with status 403"); + } + + [Fact] + public void BuildPublishFailureResponse_FallsBackToStatusCode_WhenBodyIsEmpty() + { + var result = Agent365ToolingService.BuildPublishFailureResponse(string.Empty, HttpStatusCode.BadGateway, NullLogger.Instance); + + result.Status.Should().Be("Failed"); + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("Server returned BadGateway"); + } + + [Fact] + public void BuildPublishFailureResponse_FallsBackToStatusCode_WhenBodyIsNull() + { + var result = Agent365ToolingService.BuildPublishFailureResponse(null, HttpStatusCode.InternalServerError, NullLogger.Instance); + + result.Status.Should().Be("Failed"); + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("Server returned InternalServerError"); + } + + [Fact] + public void BuildPublishFailureResponse_SurfacesRawContent_WhenBodyIsNotJson() + { + var result = Agent365ToolingService.BuildPublishFailureResponse("Bad Gateway", HttpStatusCode.BadGateway, NullLogger.Instance); + + result.Status.Should().Be("Failed"); + result.IsSuccess.Should().BeFalse(); + result.Message.Should().Be("Bad Gateway"); + } + // --- RedactSecretsFromPayload tests --- [Fact]