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
1 change: 1 addition & 0 deletions src/Config/HealthCheck/HealthCheckConstants.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
87 changes: 87 additions & 0 deletions src/Service.Tests/Configuration/HealthEndpointTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -223,6 +224,52 @@ public async Task TestFailureHealthCheckGraphQLResponseAsync()
Assert.IsNotNull(errorMessageFromGraphQL);
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// 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.
/// </summary>
[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);
}

/// <summary>
/// Tests the serialization behavior of <see cref="RuntimeHealthCheckConfig"/> for the <see cref="RuntimeHealthCheckConfig.MaxQueryParallelism"/> property."
/// </summary>
Expand Down Expand Up @@ -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<IMetadataProviderFactory> metadataProviderFactory = new();

// Mock the handler to return the supplied status code for the MCP initialize POST request.
Mock<HttpMessageHandler> mockHandler = new();
mockHandler.Protected()
.Setup<Task<HttpResponseMessage>>(
"SendAsync",
ItExpr.Is<HttpRequestMessage>(req =>
req.Method == HttpMethod.Post &&
req.RequestUri == new Uri($"{BASE_DAB_URL}{runtimeConfig.McpPath}")),
ItExpr.IsAny<CancellationToken>())
Comment on lines +428 to +434
.ReturnsAsync(new HttpResponseMessage(httpStatusCode)
{
Content = new StringContent("{\"jsonrpc\":\"2.0\",\"id\":1,\"result\":{}}")
});

Mock<IHttpClientFactory> mockHttpClientFactory = new();
mockHttpClientFactory.Setup(x => x.CreateClient("ContextConfiguredHealthCheckClient"))
.Returns(new HttpClient(mockHandler.Object)
{
BaseAddress = new Uri($"{BASE_DAB_URL}")
});

Mock<ILogger<HttpUtilities>> logger = new();

return new(
logger.Object,
metadataProviderFactory.Object,
provider,
mockHttpClientFactory.Object);
}

private static void ValidateEntityRestAndGraphQLResponse(
Dictionary<string, JsonElement> responseProperties,
bool enableEntityRest,
Expand Down
44 changes: 44 additions & 0 deletions src/Service/HealthCheck/HealthCheckHelper.cs
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ private async Task UpdateHealthCheckDetailsAsync(ComprehensiveHealthCheckReport
comprehensiveHealthCheckReport.Checks = new List<HealthCheckResultEntry>();
await UpdateDataSourceHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig);
await UpdateEntityHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig, roleHeader, roleToken);
await UpdateMcpHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig, roleHeader, roleToken);
await UpdateEmbeddingsHealthCheckResultsAsync(comprehensiveHealthCheckReport, runtimeConfig);
}

Expand Down Expand Up @@ -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);
}
Comment on lines +245 to +258

// 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.
Expand Down
59 changes: 59 additions & 0 deletions src/Service/HealthCheck/HttpUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string?> 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
};
Comment on lines +145 to +163

// 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<string?> ExecuteGraphQLQueryAsync(string graphqlUriSuffix, string entityName, Entity entity, string incomingRoleHeader, string incomingRoleToken)
Expand Down
21 changes: 21 additions & 0 deletions src/Service/HealthCheck/Utilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading