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 @@ -159,6 +159,46 @@ private class ApiStatusResponse
}
}

/// <summary>
/// Builds a populated failure <see cref="PublishMcpServerResponse"/> from a failed publish response
/// body so the caller surfaces the server's actual error. The publish path previously discarded the
/// body and returned <c>null</c>, 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.
/// </summary>
/// <param name="responseContent">The raw failure response body.</param>
/// <param name="statusCode">The HTTP status code, used only when the body has no readable message.</param>
/// <param name="logger">Logger for the double-serialization-aware deserialization helper.</param>
/// <returns>A non-null failure response whose <see cref="PublishMcpServerResponse.Message"/> is set.</returns>
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<PublishMcpServerResponse>(
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,
};
}

/// <summary>
/// Common helper method to log HTTP request details
/// </summary>
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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]
Expand Down
Loading