From 5ee6e73d2c5b58fa2e73f8326caf4c5b7366fb75 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:08:24 +0000 Subject: [PATCH 1/2] Initial plan From e21f4f02962629f803f2366dff637fd07cbb1488 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 26 Jun 2026 08:25:13 +0000 Subject: [PATCH 2/2] Add MCP check to comprehensive health endpoint --- .../HealthCheck/HealthCheckConstants.cs | 1 + .../Configuration/HealthEndpointTests.cs | 87 +++++++++++++++++++ src/Service/HealthCheck/HealthCheckHelper.cs | 44 ++++++++++ src/Service/HealthCheck/HttpUtilities.cs | 59 +++++++++++++ src/Service/HealthCheck/Utilities.cs | 21 +++++ 5 files changed, 212 insertions(+) diff --git a/src/Config/HealthCheck/HealthCheckConstants.cs b/src/Config/HealthCheck/HealthCheckConstants.cs index b57526fb75..1d566b6714 100644 --- a/src/Config/HealthCheck/HealthCheckConstants.cs +++ b/src/Config/HealthCheck/HealthCheckConstants.cs @@ -12,6 +12,7 @@ public static class HealthCheckConstants public const string DATASOURCE = "data-source"; public const string REST = "rest"; public const string GRAPHQL = "graphql"; + public const string MCP = "mcp"; public const string EMBEDDING = "embedding"; public const int ERROR_RESPONSE_TIME_MS = -1; public const int DEFAULT_THRESHOLD_RESPONSE_TIME_MS = 1000; diff --git a/src/Service.Tests/Configuration/HealthEndpointTests.cs b/src/Service.Tests/Configuration/HealthEndpointTests.cs index a1a8bf4223..4d3942e50d 100644 --- a/src/Service.Tests/Configuration/HealthEndpointTests.cs +++ b/src/Service.Tests/Configuration/HealthEndpointTests.cs @@ -110,6 +110,7 @@ public async Task ComprehensiveHealthEndpoint_ValidateContents( ValidateConfigurationDetailsHealthCheckResponse(responseProperties, enableGlobalRest, enableGlobalGraphql, enableGlobalMcp); ValidateIfAttributePresentInResponse(responseProperties, enableDatasourceHealth, HealthCheckConstants.DATASOURCE); ValidateIfAttributePresentInResponse(responseProperties, enableEntityHealth, HealthCheckConstants.ENDPOINT); + ValidateIfAttributePresentInResponse(responseProperties, enableGlobalMcp, HealthCheckConstants.MCP); if (enableEntityHealth) { ValidateEntityRestAndGraphQLResponse(responseProperties, enableEntityRest, enableEntityGraphQL, enableGlobalRest, enableGlobalGraphql); @@ -223,6 +224,52 @@ public async Task TestFailureHealthCheckGraphQLResponseAsync() Assert.IsNotNull(errorMessageFromGraphQL); } + /// + /// Simulates the function call to HttpUtilities.ExecuteMcpQueryAsync. + /// while setting up mock HTTP client to simulate the response from the server to send OK code. + /// Validates the response to ensure no error message is received. + /// + [TestMethod] + public async Task TestHealthCheckMcpResponseAsync() + { + // Arrange + RuntimeConfig runtimeConfig = SetupCustomConfigFile(true, true, true, true, true, true, true, true); + HttpUtilities httpUtilities = SetupMcpTest(runtimeConfig); + + // Act + // Simulate an MCP initialize POST request to the endpoint. + // Response should be null as error message is not expected to be returned. + string errorMessageFromMcp = await httpUtilities.ExecuteMcpQueryAsync( + mcpUriSuffix: runtimeConfig.McpPath, + incomingRoleHeader: string.Empty, + incomingRoleToken: string.Empty); + + // Assert + Assert.IsNull(errorMessageFromMcp); + } + + /// + /// Simulates the function call to HttpUtilities.ExecuteMcpQueryAsync. + /// while setting up mock HTTP client to simulate the response from the server to send InternalServerError code. + /// Validates the response to ensure error message is received. + /// + [TestMethod] + public async Task TestFailureHealthCheckMcpResponseAsync() + { + // Arrange + RuntimeConfig runtimeConfig = SetupCustomConfigFile(true, true, true, true, true, true, true, true); + HttpUtilities httpUtilities = SetupMcpTest(runtimeConfig, HttpStatusCode.InternalServerError); + + // Act + string errorMessageFromMcp = await httpUtilities.ExecuteMcpQueryAsync( + mcpUriSuffix: runtimeConfig.McpPath, + incomingRoleHeader: string.Empty, + incomingRoleToken: string.Empty); + + // Assert + Assert.IsNotNull(errorMessageFromMcp); + } + /// /// Tests the serialization behavior of for the property." /// @@ -366,6 +413,46 @@ private static HttpUtilities SetupGraphQLTest(RuntimeConfig runtimeConfig, HttpS mockHttpClientFactory.Object); } + private static HttpUtilities SetupMcpTest(RuntimeConfig runtimeConfig, HttpStatusCode httpStatusCode = HttpStatusCode.OK) + { + // Arrange + // Create a mock entity map with a single entity for testing and load in RuntimeConfigProvider + MockFileSystem fileSystem = new(); + fileSystem.AddFile(FileSystemRuntimeConfigLoader.DEFAULT_CONFIG_FILE_NAME, new MockFileData(runtimeConfig.ToJson())); + FileSystemRuntimeConfigLoader loader = new(fileSystem); + RuntimeConfigProvider provider = new(loader); + Mock metadataProviderFactory = new(); + + // Mock the handler to return the supplied status code for the MCP initialize POST request. + Mock mockHandler = new(); + mockHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => + req.Method == HttpMethod.Post && + req.RequestUri == new Uri($"{BASE_DAB_URL}{runtimeConfig.McpPath}")), + ItExpr.IsAny()) + .ReturnsAsync(new HttpResponseMessage(httpStatusCode) + { + Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}") + }); + + Mock mockHttpClientFactory = new(); + mockHttpClientFactory.Setup(x => x.CreateClient("ContextConfiguredHealthCheckClient")) + .Returns(new HttpClient(mockHandler.Object) + { + BaseAddress = new Uri($"{BASE_DAB_URL}") + }); + + Mock> logger = new(); + + return new( + logger.Object, + metadataProviderFactory.Object, + provider, + mockHttpClientFactory.Object); + } + private static void ValidateEntityRestAndGraphQLResponse( Dictionary responseProperties, bool enableEntityRest, diff --git a/src/Service/HealthCheck/HealthCheckHelper.cs b/src/Service/HealthCheck/HealthCheckHelper.cs index 0fa5a0d539..4f506154eb 100644 --- a/src/Service/HealthCheck/HealthCheckHelper.cs +++ b/src/Service/HealthCheck/HealthCheckHelper.cs @@ -169,6 +169,7 @@ private async Task UpdateHealthCheckDetailsAsync(ComprehensiveHealthCheckReport comprehensiveHealthCheckReport.Checks = new List(); await UpdateDataSourceHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig); await UpdateEntityHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig, roleHeader, roleToken); + await UpdateMcpHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig, roleHeader, roleToken); await UpdateEmbeddingsHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig); } @@ -213,6 +214,49 @@ private async Task UpdateDataSourceHealthCheckResultsAsync(ComprehensiveHealthCh return (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage); } + // Updates the MCP Health Check Result in the response. + // The check verifies that the MCP endpoint is reachable and responds within the threshold. + // It runs only when MCP is enabled in the runtime configuration. + private async Task UpdateMcpHealthCheckResultsAsync(ComprehensiveHealthCheckReport comprehensiveHealthCheckReport, RuntimeConfig runtimeConfig, string roleHeader, string roleToken) + { + if (comprehensiveHealthCheckReport.Checks is null || !runtimeConfig.IsMcpEnabled) + { + return; + } + + (int, string?) response = await ExecuteMcpQueryAsync(runtimeConfig.McpPath, roleHeader, roleToken); + bool isResponseTimeWithinThreshold = response.Item1 >= 0 && response.Item1 < HealthCheckConstants.DEFAULT_THRESHOLD_RESPONSE_TIME_MS; + + comprehensiveHealthCheckReport.Checks.Add(new HealthCheckResultEntry + { + Name = HealthCheckConstants.MCP, + ResponseTimeData = new ResponseTimeData + { + ResponseTimeMs = response.Item1, + ThresholdMs = HealthCheckConstants.DEFAULT_THRESHOLD_RESPONSE_TIME_MS + }, + Tags = [HealthCheckConstants.MCP], + Exception = response.Item2 ?? (!isResponseTimeWithinThreshold ? TIME_EXCEEDED_ERROR_MESSAGE : null), + Status = isResponseTimeWithinThreshold ? HealthStatus.Healthy : HealthStatus.Unhealthy + }); + } + + // Executes the MCP Query and keeps track of the response time and error message. + private async Task<(int, string?)> ExecuteMcpQueryAsync(string mcpUriSuffix, string roleHeader, string roleToken) + { + string? errorMessage = null; + if (!string.IsNullOrEmpty(mcpUriSuffix)) + { + Stopwatch stopwatch = new(); + stopwatch.Start(); + errorMessage = await _httpUtility.ExecuteMcpQueryAsync(mcpUriSuffix, roleHeader, roleToken); + stopwatch.Stop(); + return string.IsNullOrEmpty(errorMessage) ? ((int)stopwatch.ElapsedMilliseconds, errorMessage) : (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage); + } + + return (HealthCheckConstants.ERROR_RESPONSE_TIME_MS, errorMessage); + } + // Updates the Entity Health Check Results in the response. // Goes through the entities one by one and executes the rest and graphql checks (if enabled). // Stored procedures are excluded from health checks because they require parameters and are not guaranteed to be deterministic. diff --git a/src/Service/HealthCheck/HttpUtilities.cs b/src/Service/HealthCheck/HttpUtilities.cs index 2a8d7b9f3e..78f16f9410 100644 --- a/src/Service/HealthCheck/HttpUtilities.cs +++ b/src/Service/HealthCheck/HttpUtilities.cs @@ -136,6 +136,65 @@ public HttpUtilities( } } + // Executes the MCP query by sending an initialize JSON-RPC POST request to the MCP endpoint. + public async Task ExecuteMcpQueryAsync(string mcpUriSuffix, string incomingRoleHeader, string incomingRoleToken) + { + string? errorMessage = null; + try + { + if (string.IsNullOrEmpty(mcpUriSuffix)) + { + _logger.LogError("The MCP route is not available, hence HealthEndpoint is not available."); + return errorMessage; + } + + if (!Program.CheckSanityOfUrl($"{_httpClient.BaseAddress}{mcpUriSuffix.TrimStart('/')}")) + { + _logger.LogError("Blocked outbound request due to invalid or unsafe URI."); + return "Blocked outbound request due to invalid or unsafe URI."; + } + + string jsonPayload = Utilities.CreateHttpMcpQuery(); + HttpContent content = new StringContent(jsonPayload, Encoding.UTF8, Utilities.JSON_CONTENT_TYPE); + + HttpRequestMessage message = new(method: HttpMethod.Post, requestUri: mcpUriSuffix) + { + Content = content + }; + + // The MCP Streamable HTTP transport requires the client to accept both + // JSON and SSE responses. + message.Headers.Add("Accept", "application/json, text/event-stream"); + + if (!string.IsNullOrEmpty(incomingRoleToken)) + { + message.Headers.Add(AuthenticationOptions.CLIENT_PRINCIPAL_HEADER, incomingRoleToken); + } + + if (!string.IsNullOrEmpty(incomingRoleHeader)) + { + message.Headers.Add(AuthorizationResolver.CLIENT_ROLE_HEADER, incomingRoleHeader); + } + + HttpResponseMessage response = await _httpClient.SendAsync(message); + if (response.IsSuccessStatusCode) + { + _logger.LogTrace($"The MCP HealthEndpoint query executed successfully with code {response.StatusCode}."); + } + else + { + errorMessage = $"The MCP HealthEndpoint query failed with code: {response.StatusCode}."; + } + + return errorMessage; + } + catch (Exception ex) + { + _logger.LogError($"An exception occurred while executing the health check MCP query: {ex.Message}"); + return ex.Message; + } + } + // Executes the GraphQL query by sending a POST request to the API. // Internally calls the metadata provider to fetch the column names to create the graphql payload. public async Task ExecuteGraphQLQueryAsync(string graphqlUriSuffix, string entityName, Entity entity, string incomingRoleHeader, string incomingRoleToken) diff --git a/src/Service/HealthCheck/Utilities.cs b/src/Service/HealthCheck/Utilities.cs index 888ffbca91..dc9125577b 100644 --- a/src/Service/HealthCheck/Utilities.cs +++ b/src/Service/HealthCheck/Utilities.cs @@ -72,6 +72,27 @@ public static string CreateHttpRestQuery(string entityName, int first) return $"/{entityName}?$first={first}"; } + public static string CreateHttpMcpQuery() + { + // Create a minimal MCP request (initialize) as a valid JSON-RPC request. + // 'initialize' is used because other methods (e.g. 'tools/list') require an active + // session in the MCP Streamable HTTP transport. + var payload = new + { + jsonrpc = "2.0", + id = 1, + method = "initialize", + @params = new + { + protocolVersion = "2025-03-26", + capabilities = new { }, + clientInfo = new { name = "dab-health-check", version = "1.0.0" } + } + }; + + return JsonSerializer.Serialize(payload); + } + public static string NormalizeConnectionString(string connectionString, DatabaseType dbType, ILogger? logger = null) { try