From 7dc5489b38a2609911d2bc41bc40d07d5db6885b Mon Sep 17 00:00:00 2001 From: Jair Myree Date: Tue, 16 Jun 2026 12:20:57 -0400 Subject: [PATCH 1/3] Fix monitor_healthmodels_entity_get HTTP 400 error The control-plane GET that resolves the health model dataplane endpoint used the unsupported Microsoft.CloudHealth api-version 2023-10-01-preview, which ARM rejects with HTTP 400 (Bad Request). The data-plane request URL was also malformed: it concatenated the dataplane endpoint with the path without a separator and did not URL-encode the entity name. - Use supported api-version 2025-05-01-preview for the control-plane call. - Join the dataplane endpoint and path with a single '/' (trimming any trailing slash) and URL-encode the entity name. - Add MonitorHealthModelServiceTests covering URL construction and api-version. Fixes #2247 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...jairmyree-fix-healthmodels-entity-get.yaml | 4 + .../src/Services/MonitorHealthModelService.cs | 4 +- .../MonitorHealthModelServiceTests.cs | 142 ++++++++++++++++++ 3 files changed, 148 insertions(+), 2 deletions(-) create mode 100644 servers/Azure.Mcp.Server/changelog-entries/jairmyree-fix-healthmodels-entity-get.yaml create mode 100644 tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/MonitorHealthModelServiceTests.cs diff --git a/servers/Azure.Mcp.Server/changelog-entries/jairmyree-fix-healthmodels-entity-get.yaml b/servers/Azure.Mcp.Server/changelog-entries/jairmyree-fix-healthmodels-entity-get.yaml new file mode 100644 index 0000000000..a8da71caad --- /dev/null +++ b/servers/Azure.Mcp.Server/changelog-entries/jairmyree-fix-healthmodels-entity-get.yaml @@ -0,0 +1,4 @@ +changes: + - section: "Bugs Fixed" + description: | + Fixed `monitor_healthmodels_entity_get` returning HTTP 400 (Bad Request) when querying entity health. The tool now uses a supported `Microsoft.CloudHealth` control-plane API version (`2025-05-01-preview`), and the data-plane request URL is built correctly by joining the dataplane endpoint with a path separator and URL-encoding the entity name. diff --git a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorHealthModelService.cs b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorHealthModelService.cs index 28ca9db20f..1cbdeaf24d 100644 --- a/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorHealthModelService.cs +++ b/tools/Azure.Mcp.Tools.Monitor/src/Services/MonitorHealthModelService.cs @@ -14,7 +14,7 @@ namespace Azure.Mcp.Tools.Monitor.Services; public class MonitorHealthModelService(ITenantService tenantService, IHttpClientFactory httpClientFactory) : BaseAzureService(tenantService), IMonitorHealthModelService { - private const string ApiVersion = "2023-10-01-preview"; + private const string ApiVersion = "2025-05-01-preview"; private readonly IHttpClientFactory _httpClientFactory = httpClientFactory ?? throw new ArgumentNullException(nameof(httpClientFactory)); private readonly ITenantService _tenantService = tenantService ?? throw new ArgumentNullException(nameof(tenantService)); @@ -45,7 +45,7 @@ public async Task GetEntityHealth( ValidateRequiredParameters((nameof(entity), entity), (nameof(healthModelName), healthModelName), (nameof(resourceGroupName), resourceGroupName), (nameof(subscription), subscription)); string dataplaneEndpoint = await GetDataplaneEndpointAsync(subscription, resourceGroupName, healthModelName, cancellationToken); - string entityHealthUrl = $"{dataplaneEndpoint}api/entities/{entity}/history"; + string entityHealthUrl = $"{dataplaneEndpoint.TrimEnd('/')}/api/entities/{Uri.EscapeDataString(entity)}/history"; string healthResponseString = await GetDataplaneResponseAsync(entityHealthUrl, cancellationToken); return JsonNode.Parse(healthResponseString) ?? throw new Exception("Failed to parse health response to JSON."); diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/MonitorHealthModelServiceTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/MonitorHealthModelServiceTests.cs new file mode 100644 index 0000000000..cf9e1e09f6 --- /dev/null +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/MonitorHealthModelServiceTests.cs @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +using System.Net; +using System.Text; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure.Tenant; +using Azure.Mcp.Tools.Monitor.Services; +using Azure.ResourceManager; +using Microsoft.Mcp.Core.Services.Azure.Authentication; +using NSubstitute; +using Xunit; + +namespace Azure.Mcp.Tools.Monitor.Tests.HealthModels; + +/// +/// Tests for that verify the control-plane and +/// data-plane requests are constructed correctly. These guard against regressions of the +/// HTTP 400 failure reported when querying entity health (see issue #2247). +/// +public class MonitorHealthModelServiceTests +{ + private const string SupportedApiVersion = "2025-05-01-preview"; + + private static MonitorHealthModelService CreateService(CapturingHttpMessageHandler handler) + { + var tenantService = Substitute.For(); + + var cloudConfig = Substitute.For(); + cloudConfig.ArmEnvironment.Returns(ArmEnvironment.AzurePublicCloud); + cloudConfig.CloudType.Returns(AzureCloudConfiguration.AzureCloud.AzurePublicCloud); + tenantService.CloudConfiguration.Returns(cloudConfig); + + var credential = Substitute.For(); + credential.GetTokenAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new AccessToken("test-token", DateTimeOffset.UtcNow.AddHours(1)))); + tenantService.GetTokenCredentialAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(credential)); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(handler)); + + return new MonitorHealthModelService(tenantService, httpClientFactory); + } + + [Theory] + [InlineData("https://contoso.healthmodels.azure.com")] // no trailing slash + [InlineData("https://contoso.healthmodels.azure.com/")] // trailing slash + public async Task GetEntityHealth_BuildsWellFormedDataplaneUrl(string dataplaneEndpoint) + { + // Arrange + const string entity = "2c139c0b-87d8-4935-ae18-4217431002d2"; + var handler = new CapturingHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("management.azure.com", StringComparison.OrdinalIgnoreCase)) + { + var body = "{\"properties\":{\"dataplaneEndpoint\":\"" + dataplaneEndpoint + "\"}}"; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"history":[]}""", Encoding.UTF8, "application/json") + }; + }); + + var service = CreateService(handler); + + // Act + var result = await service.GetEntityHealth( + entity, + "contoso", + "rg1", + "12345678-1234-1234-1234-123456789012", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + + var controlPlaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "management.azure.com"); + Assert.Contains($"api-version={SupportedApiVersion}", controlPlaneRequest.RequestUri!.Query, StringComparison.Ordinal); + + var dataplaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "contoso.healthmodels.azure.com"); + Assert.Equal($"https://contoso.healthmodels.azure.com/api/entities/{entity}/history", dataplaneRequest.RequestUri!.AbsoluteUri); + } + + [Fact] + public async Task GetEntityHealth_UrlEncodesEntityName() + { + // Arrange + const string entity = "my entity/with special?chars"; + var handler = new CapturingHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("management.azure.com", StringComparison.OrdinalIgnoreCase)) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"properties":{"dataplaneEndpoint":"https://m.healthmodels.azure.com"}}""", Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"history":[]}""", Encoding.UTF8, "application/json") + }; + }); + + var service = CreateService(handler); + + // Act + await service.GetEntityHealth( + entity, + "m", + "rg1", + "12345678-1234-1234-1234-123456789012", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + var dataplaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "m.healthmodels.azure.com"); + Assert.Equal( + $"https://m.healthmodels.azure.com/api/entities/{Uri.EscapeDataString(entity)}/history", + dataplaneRequest.RequestUri!.AbsoluteUri); + } + + private sealed class CapturingHttpMessageHandler(Func responder) : HttpMessageHandler + { + private readonly Func _responder = responder; + + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_responder(request)); + } + } +} From f1c7bca8fd3f9e35777c8c2dc88b0260dc4f4fac Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 19:21:53 +0000 Subject: [PATCH 2/3] Move HealthModel service URL/api-version tests into EntityGetHealthCommandTests --- .../Entity/EntityGetHealthCommandTests.cs | 124 +++++++++++++++ .../MonitorHealthModelServiceTests.cs | 142 ------------------ 2 files changed, 124 insertions(+), 142 deletions(-) delete mode 100644 tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/MonitorHealthModelServiceTests.cs diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs index 8b3413c067..b3e649f1fa 100644 --- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs @@ -2,11 +2,16 @@ // Licensed under the MIT License. using System.Net; +using System.Text; using System.Text.Json.Nodes; +using Azure.Core; +using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.Mcp.Tools.Monitor.Commands.HealthModels.Entity; using Azure.Mcp.Tools.Monitor.Services; +using Azure.ResourceManager; using Microsoft.Mcp.Core.Models; using Microsoft.Mcp.Core.Options; +using Microsoft.Mcp.Core.Services.Azure.Authentication; using Microsoft.Mcp.Tests.Client; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -22,6 +27,7 @@ public class EntityGetHealthCommandTests : CommandUnitTestsBase(r => r.DelaySeconds == RetryDelay && r.MaxRetries == MaxRetries), Arg.Any()); } + + [Theory] + [InlineData("https://contoso.healthmodels.azure.com")] // no trailing slash + [InlineData("https://contoso.healthmodels.azure.com/")] // trailing slash + public async Task GetEntityHealth_BuildsWellFormedDataplaneUrl(string dataplaneEndpoint) + { + // Arrange + const string entity = "2c139c0b-87d8-4935-ae18-4217431002d2"; + var handler = new CapturingHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("management.azure.com", StringComparison.OrdinalIgnoreCase)) + { + var body = "{\"properties\":{\"dataplaneEndpoint\":\"" + dataplaneEndpoint + "\"}}"; + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(body, Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"history":[]}""", Encoding.UTF8, "application/json") + }; + }); + + var service = CreateService(handler); + + // Act + var result = await service.GetEntityHealth( + entity, + "contoso", + "rg1", + "12345678-1234-1234-1234-123456789012", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + Assert.NotNull(result); + + var controlPlaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "management.azure.com"); + Assert.Contains($"api-version={SupportedApiVersion}", controlPlaneRequest.RequestUri!.Query, StringComparison.Ordinal); + + var dataplaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "contoso.healthmodels.azure.com"); + Assert.Equal($"https://contoso.healthmodels.azure.com/api/entities/{entity}/history", dataplaneRequest.RequestUri!.AbsoluteUri); + } + + [Fact] + public async Task GetEntityHealth_UrlEncodesEntityName() + { + // Arrange + const string entity = "my entity/with special?chars"; + var handler = new CapturingHttpMessageHandler(request => + { + var url = request.RequestUri!.ToString(); + if (url.Contains("management.azure.com", StringComparison.OrdinalIgnoreCase)) + { + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"properties":{"dataplaneEndpoint":"https://m.healthmodels.azure.com"}}""", Encoding.UTF8, "application/json") + }; + } + + return new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent("""{"history":[]}""", Encoding.UTF8, "application/json") + }; + }); + + var service = CreateService(handler); + + // Act + await service.GetEntityHealth( + entity, + "m", + "rg1", + "12345678-1234-1234-1234-123456789012", + cancellationToken: TestContext.Current.CancellationToken); + + // Assert + var dataplaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "m.healthmodels.azure.com"); + Assert.Equal( + $"https://m.healthmodels.azure.com/api/entities/{Uri.EscapeDataString(entity)}/history", + dataplaneRequest.RequestUri!.AbsoluteUri); + } + + private static MonitorHealthModelService CreateService(CapturingHttpMessageHandler handler) + { + var tenantService = Substitute.For(); + + var cloudConfig = Substitute.For(); + cloudConfig.ArmEnvironment.Returns(ArmEnvironment.AzurePublicCloud); + cloudConfig.CloudType.Returns(AzureCloudConfiguration.AzureCloud.AzurePublicCloud); + tenantService.CloudConfiguration.Returns(cloudConfig); + + var credential = Substitute.For(); + credential.GetTokenAsync(Arg.Any(), Arg.Any()) + .Returns(new ValueTask(new AccessToken("test-token", DateTimeOffset.UtcNow.AddHours(1)))); + tenantService.GetTokenCredentialAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(credential)); + + var httpClientFactory = Substitute.For(); + httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(handler)); + + return new MonitorHealthModelService(tenantService, httpClientFactory); + } + + private sealed class CapturingHttpMessageHandler(Func responder) : HttpMessageHandler + { + private readonly Func _responder = responder; + + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(_responder(request)); + } + } } diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/MonitorHealthModelServiceTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/MonitorHealthModelServiceTests.cs deleted file mode 100644 index cf9e1e09f6..0000000000 --- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/MonitorHealthModelServiceTests.cs +++ /dev/null @@ -1,142 +0,0 @@ -// Copyright (c) Microsoft Corporation. -// Licensed under the MIT License. - -using System.Net; -using System.Text; -using Azure.Core; -using Azure.Mcp.Core.Services.Azure.Tenant; -using Azure.Mcp.Tools.Monitor.Services; -using Azure.ResourceManager; -using Microsoft.Mcp.Core.Services.Azure.Authentication; -using NSubstitute; -using Xunit; - -namespace Azure.Mcp.Tools.Monitor.Tests.HealthModels; - -/// -/// Tests for that verify the control-plane and -/// data-plane requests are constructed correctly. These guard against regressions of the -/// HTTP 400 failure reported when querying entity health (see issue #2247). -/// -public class MonitorHealthModelServiceTests -{ - private const string SupportedApiVersion = "2025-05-01-preview"; - - private static MonitorHealthModelService CreateService(CapturingHttpMessageHandler handler) - { - var tenantService = Substitute.For(); - - var cloudConfig = Substitute.For(); - cloudConfig.ArmEnvironment.Returns(ArmEnvironment.AzurePublicCloud); - cloudConfig.CloudType.Returns(AzureCloudConfiguration.AzureCloud.AzurePublicCloud); - tenantService.CloudConfiguration.Returns(cloudConfig); - - var credential = Substitute.For(); - credential.GetTokenAsync(Arg.Any(), Arg.Any()) - .Returns(new ValueTask(new AccessToken("test-token", DateTimeOffset.UtcNow.AddHours(1)))); - tenantService.GetTokenCredentialAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(credential)); - - var httpClientFactory = Substitute.For(); - httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(handler)); - - return new MonitorHealthModelService(tenantService, httpClientFactory); - } - - [Theory] - [InlineData("https://contoso.healthmodels.azure.com")] // no trailing slash - [InlineData("https://contoso.healthmodels.azure.com/")] // trailing slash - public async Task GetEntityHealth_BuildsWellFormedDataplaneUrl(string dataplaneEndpoint) - { - // Arrange - const string entity = "2c139c0b-87d8-4935-ae18-4217431002d2"; - var handler = new CapturingHttpMessageHandler(request => - { - var url = request.RequestUri!.ToString(); - if (url.Contains("management.azure.com", StringComparison.OrdinalIgnoreCase)) - { - var body = "{\"properties\":{\"dataplaneEndpoint\":\"" + dataplaneEndpoint + "\"}}"; - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"history":[]}""", Encoding.UTF8, "application/json") - }; - }); - - var service = CreateService(handler); - - // Act - var result = await service.GetEntityHealth( - entity, - "contoso", - "rg1", - "12345678-1234-1234-1234-123456789012", - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(result); - - var controlPlaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "management.azure.com"); - Assert.Contains($"api-version={SupportedApiVersion}", controlPlaneRequest.RequestUri!.Query, StringComparison.Ordinal); - - var dataplaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "contoso.healthmodels.azure.com"); - Assert.Equal($"https://contoso.healthmodels.azure.com/api/entities/{entity}/history", dataplaneRequest.RequestUri!.AbsoluteUri); - } - - [Fact] - public async Task GetEntityHealth_UrlEncodesEntityName() - { - // Arrange - const string entity = "my entity/with special?chars"; - var handler = new CapturingHttpMessageHandler(request => - { - var url = request.RequestUri!.ToString(); - if (url.Contains("management.azure.com", StringComparison.OrdinalIgnoreCase)) - { - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"properties":{"dataplaneEndpoint":"https://m.healthmodels.azure.com"}}""", Encoding.UTF8, "application/json") - }; - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"history":[]}""", Encoding.UTF8, "application/json") - }; - }); - - var service = CreateService(handler); - - // Act - await service.GetEntityHealth( - entity, - "m", - "rg1", - "12345678-1234-1234-1234-123456789012", - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - var dataplaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "m.healthmodels.azure.com"); - Assert.Equal( - $"https://m.healthmodels.azure.com/api/entities/{Uri.EscapeDataString(entity)}/history", - dataplaneRequest.RequestUri!.AbsoluteUri); - } - - private sealed class CapturingHttpMessageHandler(Func responder) : HttpMessageHandler - { - private readonly Func _responder = responder; - - public List Requests { get; } = []; - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - Requests.Add(request); - return Task.FromResult(_responder(request)); - } - } -} From 04b8d0e233f81a3728d70b89453264ed75202601 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 16 Jun 2026 20:33:11 +0000 Subject: [PATCH 3/3] Restructure HealthModel tests: unit in EntityGetHealthCommandTests, live recorded in MonitorCommandTests --- .../Entity/EntityGetHealthCommandTests.cs | 124 ------------------ .../MonitorCommandTests.cs | 38 ++++++ 2 files changed, 38 insertions(+), 124 deletions(-) diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs index b3e649f1fa..8b3413c067 100644 --- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/HealthModels/Entity/EntityGetHealthCommandTests.cs @@ -2,16 +2,11 @@ // Licensed under the MIT License. using System.Net; -using System.Text; using System.Text.Json.Nodes; -using Azure.Core; -using Azure.Mcp.Core.Services.Azure.Tenant; using Azure.Mcp.Tools.Monitor.Commands.HealthModels.Entity; using Azure.Mcp.Tools.Monitor.Services; -using Azure.ResourceManager; using Microsoft.Mcp.Core.Models; using Microsoft.Mcp.Core.Options; -using Microsoft.Mcp.Core.Services.Azure.Authentication; using Microsoft.Mcp.Tests.Client; using NSubstitute; using NSubstitute.ExceptionExtensions; @@ -27,7 +22,6 @@ public class EntityGetHealthCommandTests : CommandUnitTestsBase(r => r.DelaySeconds == RetryDelay && r.MaxRetries == MaxRetries), Arg.Any()); } - - [Theory] - [InlineData("https://contoso.healthmodels.azure.com")] // no trailing slash - [InlineData("https://contoso.healthmodels.azure.com/")] // trailing slash - public async Task GetEntityHealth_BuildsWellFormedDataplaneUrl(string dataplaneEndpoint) - { - // Arrange - const string entity = "2c139c0b-87d8-4935-ae18-4217431002d2"; - var handler = new CapturingHttpMessageHandler(request => - { - var url = request.RequestUri!.ToString(); - if (url.Contains("management.azure.com", StringComparison.OrdinalIgnoreCase)) - { - var body = "{\"properties\":{\"dataplaneEndpoint\":\"" + dataplaneEndpoint + "\"}}"; - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent(body, Encoding.UTF8, "application/json") - }; - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"history":[]}""", Encoding.UTF8, "application/json") - }; - }); - - var service = CreateService(handler); - - // Act - var result = await service.GetEntityHealth( - entity, - "contoso", - "rg1", - "12345678-1234-1234-1234-123456789012", - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - Assert.NotNull(result); - - var controlPlaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "management.azure.com"); - Assert.Contains($"api-version={SupportedApiVersion}", controlPlaneRequest.RequestUri!.Query, StringComparison.Ordinal); - - var dataplaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "contoso.healthmodels.azure.com"); - Assert.Equal($"https://contoso.healthmodels.azure.com/api/entities/{entity}/history", dataplaneRequest.RequestUri!.AbsoluteUri); - } - - [Fact] - public async Task GetEntityHealth_UrlEncodesEntityName() - { - // Arrange - const string entity = "my entity/with special?chars"; - var handler = new CapturingHttpMessageHandler(request => - { - var url = request.RequestUri!.ToString(); - if (url.Contains("management.azure.com", StringComparison.OrdinalIgnoreCase)) - { - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"properties":{"dataplaneEndpoint":"https://m.healthmodels.azure.com"}}""", Encoding.UTF8, "application/json") - }; - } - - return new HttpResponseMessage(HttpStatusCode.OK) - { - Content = new StringContent("""{"history":[]}""", Encoding.UTF8, "application/json") - }; - }); - - var service = CreateService(handler); - - // Act - await service.GetEntityHealth( - entity, - "m", - "rg1", - "12345678-1234-1234-1234-123456789012", - cancellationToken: TestContext.Current.CancellationToken); - - // Assert - var dataplaneRequest = Assert.Single(handler.Requests, r => r.RequestUri!.Host == "m.healthmodels.azure.com"); - Assert.Equal( - $"https://m.healthmodels.azure.com/api/entities/{Uri.EscapeDataString(entity)}/history", - dataplaneRequest.RequestUri!.AbsoluteUri); - } - - private static MonitorHealthModelService CreateService(CapturingHttpMessageHandler handler) - { - var tenantService = Substitute.For(); - - var cloudConfig = Substitute.For(); - cloudConfig.ArmEnvironment.Returns(ArmEnvironment.AzurePublicCloud); - cloudConfig.CloudType.Returns(AzureCloudConfiguration.AzureCloud.AzurePublicCloud); - tenantService.CloudConfiguration.Returns(cloudConfig); - - var credential = Substitute.For(); - credential.GetTokenAsync(Arg.Any(), Arg.Any()) - .Returns(new ValueTask(new AccessToken("test-token", DateTimeOffset.UtcNow.AddHours(1)))); - tenantService.GetTokenCredentialAsync(Arg.Any(), Arg.Any()) - .Returns(Task.FromResult(credential)); - - var httpClientFactory = Substitute.For(); - httpClientFactory.CreateClient(Arg.Any()).Returns(_ => new HttpClient(handler)); - - return new MonitorHealthModelService(tenantService, httpClientFactory); - } - - private sealed class CapturingHttpMessageHandler(Func responder) : HttpMessageHandler - { - private readonly Func _responder = responder; - - public List Requests { get; } = []; - - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) - { - Requests.Add(request); - return Task.FromResult(_responder(request)); - } - } } diff --git a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs index f3d363ce6e..34ade78463 100644 --- a/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs +++ b/tools/Azure.Mcp.Tools.Monitor/tests/Azure.Mcp.Tools.Monitor.Tests/MonitorCommandTests.cs @@ -35,6 +35,7 @@ public sealed class MonitorCommandTests : RecordedCommandTestsBase private string? _storageAccountName; private string? _appInsightsName; private string? _bingWebTestName; + private string? _healthModelName; public MonitorCommandTests(ITestOutputHelper output, TestProxyFixture fixture, LiveServerFixture liveServerFixture) : base(output, fixture, liveServerFixture) @@ -91,6 +92,7 @@ public override async ValueTask InitializeAsync() _storageAccountName = $"{Settings.ResourceBaseName}mon"; _appInsightsName = $"{Settings.ResourceBaseName}-ai"; _bingWebTestName = $"{Settings.ResourceBaseName}-bing-test"; + _healthModelName = $"{Settings.ResourceBaseName}-health"; if (TestMode == TestMode.Playback) { @@ -519,6 +521,42 @@ public override async ValueTask DisposeAsync() // } // } + #region HealthModels Integration Tests + + [Fact] + public async Task Should_Get_Entity_Health() + { + var result = await CallToolAsync( + "monitor_healthmodels_entity_get", + new() + { + { "subscription", Settings.SubscriptionId }, + { "resource-group", Settings.ResourceGroupName }, + { "health-model", _healthModelName }, + { "entity", "root" } + }); + + Assert.NotNull(result); + } + + [Theory] + [InlineData("--invalid-param")] + [InlineData("--subscription invalidSub")] + [InlineData("--subscription sub --resource-group rg")] // Missing required entity/health-model + public async Task Should_Return400_WithInvalidHealthModelInput(string args) + { + var result = await CallToolAsync( + "monitor_healthmodels_entity_get", + new() + { + { "args", args } + }); + + Assert.NotEqual(200, result?.GetProperty("status").GetInt32() ?? 500); + } + + #endregion + #region WebTests Integration Tests [Fact]