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]