From 70ba0b1913c605d0a48cf9d105ac0c0e4cfe3204 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 27 Feb 2026 19:06:35 +0530 Subject: [PATCH 1/7] feat: add mcpCheckInput and mcpCheckOutput methods Add standalone policy-check methods and request/response POJOs for external orchestrators to use AxonFlow as a policy gate. Includes sync and async (CompletableFuture) variants. 403 responses are treated as valid policy-blocked results, not errors. Refs: getaxonflow/axonflow-enterprise#1258 --- .../java/com/getaxonflow/sdk/AxonFlow.java | 204 ++++++++++++++++++ .../sdk/types/MCPCheckInputRequest.java | 112 ++++++++++ .../sdk/types/MCPCheckInputResponse.java | 111 ++++++++++ .../sdk/types/MCPCheckOutputRequest.java | 122 +++++++++++ .../sdk/types/MCPCheckOutputResponse.java | 140 ++++++++++++ 5 files changed, 689 insertions(+) create mode 100644 src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java create mode 100644 src/main/java/com/getaxonflow/sdk/types/MCPCheckInputResponse.java create mode 100644 src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputRequest.java create mode 100644 src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputResponse.java diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index c494000..0c98156 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -1405,6 +1405,210 @@ public ConnectorResponse mcpExecute(String connector, String statement) { return mcpQuery(connector, statement); } + // ======================================================================== + // MCP Policy Check (Standalone) + // ======================================================================== + + /** + * Validates an MCP input statement against configured policies without executing it. + * + *

This method calls the agent's {@code /api/v1/mcp/check-input} endpoint to pre-validate + * a statement before sending it to the connector. Useful for checking SQL injection + * patterns, blocked operations, and input policy violations.

+ * + *

Example usage: + *

{@code
+     * MCPCheckInputResponse result = axonflow.mcpCheckInput("postgres", "SELECT * FROM users");
+     * if (!result.isAllowed()) {
+     *     System.out.println("Blocked: " + result.getBlockReason());
+     * }
+     * }
+ * + * @param connectorType name of the MCP connector type (e.g., "postgres") + * @param statement the statement to validate + * @return MCPCheckInputResponse with allowed status, block reason, and policy info + * @throws ConnectorException if the request fails (note: 403 is not an error, it means blocked) + */ + public MCPCheckInputResponse mcpCheckInput(String connectorType, String statement) { + return mcpCheckInput(connectorType, statement, null); + } + + /** + * Validates an MCP input statement against configured policies with options. + * + * @param connectorType name of the MCP connector type (e.g., "postgres") + * @param statement the statement to validate + * @param options optional parameters: "operation" (String), "parameters" (Map) + * @return MCPCheckInputResponse with allowed status, block reason, and policy info + * @throws ConnectorException if the request fails (note: 403 is not an error, it means blocked) + */ + public MCPCheckInputResponse mcpCheckInput(String connectorType, String statement, Map options) { + Objects.requireNonNull(connectorType, "connectorType cannot be null"); + Objects.requireNonNull(statement, "statement cannot be null"); + + return retryExecutor.execute(() -> { + MCPCheckInputRequest request; + if (options != null) { + String operation = (String) options.getOrDefault("operation", "query"); + @SuppressWarnings("unchecked") + Map parameters = (Map) options.get("parameters"); + request = new MCPCheckInputRequest(connectorType, statement, parameters, operation); + } else { + request = new MCPCheckInputRequest(connectorType, statement); + } + + Request httpRequest = buildRequest("POST", "/api/v1/mcp/check-input", request); + try (Response response = httpClient.newCall(httpRequest).execute()) { + ResponseBody responseBody = response.body(); + if (responseBody == null) { + throw new ConnectorException("Empty response from MCP check-input", connectorType, "mcpCheckInput"); + } + String responseJson = responseBody.string(); + + // 403 means policy blocked — the body is still a valid response + if (!response.isSuccessful() && response.code() != 403) { + try { + Map errorData = objectMapper.readValue(responseJson, + new TypeReference>() {}); + String errorMsg = errorData.get("error") != null ? + errorData.get("error").toString() : + "MCP check-input failed: " + response.code(); + throw new ConnectorException(errorMsg, connectorType, "mcpCheckInput"); + } catch (JsonProcessingException e) { + throw new ConnectorException("MCP check-input failed: " + response.code(), connectorType, "mcpCheckInput"); + } + } + + return objectMapper.readValue(responseJson, MCPCheckInputResponse.class); + } + }, "mcpCheckInput"); + } + + /** + * Asynchronously validates an MCP input statement against configured policies. + * + * @param connectorType name of the MCP connector type + * @param statement the statement to validate + * @return a future containing the check result + */ + public CompletableFuture mcpCheckInputAsync(String connectorType, String statement) { + return CompletableFuture.supplyAsync(() -> mcpCheckInput(connectorType, statement), asyncExecutor); + } + + /** + * Asynchronously validates an MCP input statement against configured policies with options. + * + * @param connectorType name of the MCP connector type + * @param statement the statement to validate + * @param options optional parameters + * @return a future containing the check result + */ + public CompletableFuture mcpCheckInputAsync(String connectorType, String statement, Map options) { + return CompletableFuture.supplyAsync(() -> mcpCheckInput(connectorType, statement, options), asyncExecutor); + } + + /** + * Validates MCP response data against configured policies. + * + *

This method calls the agent's {@code /api/v1/mcp/check-output} endpoint to check + * response data for PII content, exfiltration limit violations, and other output + * policy violations. If PII redaction is active, {@code redactedData} contains the + * sanitized version.

+ * + *

Example usage: + *

{@code
+     * List> rows = List.of(
+     *     Map.of("name", "John", "ssn", "123-45-6789")
+     * );
+     * MCPCheckOutputResponse result = axonflow.mcpCheckOutput("postgres", rows);
+     * if (!result.isAllowed()) {
+     *     System.out.println("Blocked: " + result.getBlockReason());
+     * }
+     * if (result.getRedactedData() != null) {
+     *     System.out.println("Redacted: " + result.getRedactedData());
+     * }
+     * }
+ * + * @param connectorType name of the MCP connector type (e.g., "postgres") + * @param responseData the response data rows to validate + * @return MCPCheckOutputResponse with allowed status, redacted data, and policy info + * @throws ConnectorException if the request fails (note: 403 is not an error, it means blocked) + */ + public MCPCheckOutputResponse mcpCheckOutput(String connectorType, List> responseData) { + return mcpCheckOutput(connectorType, responseData, null); + } + + /** + * Validates MCP response data against configured policies with options. + * + * @param connectorType name of the MCP connector type (e.g., "postgres") + * @param responseData the response data rows to validate + * @param options optional parameters: "message" (String), "metadata" (Map), "row_count" (int) + * @return MCPCheckOutputResponse with allowed status, redacted data, and policy info + * @throws ConnectorException if the request fails (note: 403 is not an error, it means blocked) + */ + public MCPCheckOutputResponse mcpCheckOutput(String connectorType, List> responseData, Map options) { + Objects.requireNonNull(connectorType, "connectorType cannot be null"); + Objects.requireNonNull(responseData, "responseData cannot be null"); + + return retryExecutor.execute(() -> { + String message = options != null ? (String) options.get("message") : null; + @SuppressWarnings("unchecked") + Map metadata = options != null ? (Map) options.get("metadata") : null; + int rowCount = options != null ? (int) options.getOrDefault("row_count", 0) : 0; + + MCPCheckOutputRequest request = new MCPCheckOutputRequest(connectorType, responseData, message, metadata, rowCount); + + Request httpRequest = buildRequest("POST", "/api/v1/mcp/check-output", request); + try (Response response = httpClient.newCall(httpRequest).execute()) { + ResponseBody responseBody = response.body(); + if (responseBody == null) { + throw new ConnectorException("Empty response from MCP check-output", connectorType, "mcpCheckOutput"); + } + String responseJson = responseBody.string(); + + // 403 means policy blocked — the body is still a valid response + if (!response.isSuccessful() && response.code() != 403) { + try { + Map errorData = objectMapper.readValue(responseJson, + new TypeReference>() {}); + String errorMsg = errorData.get("error") != null ? + errorData.get("error").toString() : + "MCP check-output failed: " + response.code(); + throw new ConnectorException(errorMsg, connectorType, "mcpCheckOutput"); + } catch (JsonProcessingException e) { + throw new ConnectorException("MCP check-output failed: " + response.code(), connectorType, "mcpCheckOutput"); + } + } + + return objectMapper.readValue(responseJson, MCPCheckOutputResponse.class); + } + }, "mcpCheckOutput"); + } + + /** + * Asynchronously validates MCP response data against configured policies. + * + * @param connectorType name of the MCP connector type + * @param responseData the response data rows to validate + * @return a future containing the check result + */ + public CompletableFuture mcpCheckOutputAsync(String connectorType, List> responseData) { + return CompletableFuture.supplyAsync(() -> mcpCheckOutput(connectorType, responseData), asyncExecutor); + } + + /** + * Asynchronously validates MCP response data against configured policies with options. + * + * @param connectorType name of the MCP connector type + * @param responseData the response data rows to validate + * @param options optional parameters + * @return a future containing the check result + */ + public CompletableFuture mcpCheckOutputAsync(String connectorType, List> responseData, Map options) { + return CompletableFuture.supplyAsync(() -> mcpCheckOutput(connectorType, responseData, options), asyncExecutor); + } + // ======================================================================== // Policy CRUD - Static Policies // ======================================================================== diff --git a/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java b/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java new file mode 100644 index 0000000..4c4cc07 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java @@ -0,0 +1,112 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Map; +import java.util.Objects; + +/** + * Request to validate an MCP input against configured policies without executing it. + * + *

Used with the {@code POST /api/v1/mcp/check-input} endpoint to pre-validate + * a statement before sending it to the connector.

+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class MCPCheckInputRequest { + + @JsonProperty("connector_type") + private final String connectorType; + + @JsonProperty("statement") + private final String statement; + + @JsonProperty("parameters") + private final Map parameters; + + @JsonProperty("operation") + private final String operation; + + /** + * Creates a request with connector type and statement only. + * Operation defaults to "query". + * + * @param connectorType the MCP connector type (e.g., "postgres") + * @param statement the statement to validate + */ + public MCPCheckInputRequest(String connectorType, String statement) { + this(connectorType, statement, null, "query"); + } + + /** + * Creates a request with all fields. + * + * @param connectorType the MCP connector type (e.g., "postgres") + * @param statement the statement to validate + * @param parameters optional query parameters + * @param operation the operation type (e.g., "query", "execute") + */ + public MCPCheckInputRequest(String connectorType, String statement, + Map parameters, String operation) { + this.connectorType = connectorType; + this.statement = statement; + this.parameters = parameters; + this.operation = operation; + } + + public String getConnectorType() { + return connectorType; + } + + public String getStatement() { + return statement; + } + + public Map getParameters() { + return parameters; + } + + public String getOperation() { + return operation; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MCPCheckInputRequest that = (MCPCheckInputRequest) o; + return Objects.equals(connectorType, that.connectorType) && + Objects.equals(statement, that.statement) && + Objects.equals(parameters, that.parameters) && + Objects.equals(operation, that.operation); + } + + @Override + public int hashCode() { + return Objects.hash(connectorType, statement, parameters, operation); + } + + @Override + public String toString() { + return "MCPCheckInputRequest{" + + "connectorType='" + connectorType + '\'' + + ", statement='" + statement + '\'' + + ", operation='" + operation + '\'' + + '}'; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputResponse.java b/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputResponse.java new file mode 100644 index 0000000..c1454fe --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputResponse.java @@ -0,0 +1,111 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +/** + * Response from the MCP input policy check endpoint. + * + *

Indicates whether the input statement is allowed by configured policies. + * A 403 HTTP response still returns a valid response body with {@code allowed=false} + * and details in {@code blockReason} and {@code policyInfo}.

+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class MCPCheckInputResponse { + + @JsonProperty("allowed") + private final boolean allowed; + + @JsonProperty("block_reason") + private final String blockReason; + + @JsonProperty("policies_evaluated") + private final int policiesEvaluated; + + @JsonProperty("policy_info") + private final ConnectorPolicyInfo policyInfo; + + @JsonCreator + public MCPCheckInputResponse( + @JsonProperty("allowed") boolean allowed, + @JsonProperty("block_reason") String blockReason, + @JsonProperty("policies_evaluated") int policiesEvaluated, + @JsonProperty("policy_info") ConnectorPolicyInfo policyInfo) { + this.allowed = allowed; + this.blockReason = blockReason; + this.policiesEvaluated = policiesEvaluated; + this.policyInfo = policyInfo; + } + + /** + * Returns whether the input is allowed by policies. + */ + public boolean isAllowed() { + return allowed; + } + + /** + * Returns the reason the input was blocked, or null if allowed. + */ + public String getBlockReason() { + return blockReason; + } + + /** + * Returns the number of policies evaluated. + */ + public int getPoliciesEvaluated() { + return policiesEvaluated; + } + + /** + * Returns detailed policy evaluation information. + */ + public ConnectorPolicyInfo getPolicyInfo() { + return policyInfo; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MCPCheckInputResponse that = (MCPCheckInputResponse) o; + return allowed == that.allowed && + policiesEvaluated == that.policiesEvaluated && + Objects.equals(blockReason, that.blockReason) && + Objects.equals(policyInfo, that.policyInfo); + } + + @Override + public int hashCode() { + return Objects.hash(allowed, blockReason, policiesEvaluated, policyInfo); + } + + @Override + public String toString() { + return "MCPCheckInputResponse{" + + "allowed=" + allowed + + ", blockReason='" + blockReason + '\'' + + ", policiesEvaluated=" + policiesEvaluated + + ", policyInfo=" + policyInfo + + '}'; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputRequest.java b/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputRequest.java new file mode 100644 index 0000000..3b12ac0 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputRequest.java @@ -0,0 +1,122 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; +import java.util.Map; +import java.util.Objects; + +/** + * Request to validate MCP response data against configured policies. + * + *

Used with the {@code POST /api/v1/mcp/check-output} endpoint to check + * response data for PII, exfiltration limits, and other policy violations.

+ */ +@JsonInclude(JsonInclude.Include.NON_NULL) +public final class MCPCheckOutputRequest { + + @JsonProperty("connector_type") + private final String connectorType; + + @JsonProperty("response_data") + private final List> responseData; + + @JsonProperty("message") + private final String message; + + @JsonProperty("metadata") + private final Map metadata; + + @JsonProperty("row_count") + private final int rowCount; + + /** + * Creates a request with connector type and response data only. + * + * @param connectorType the MCP connector type (e.g., "postgres") + * @param responseData the response data rows to validate + */ + public MCPCheckOutputRequest(String connectorType, List> responseData) { + this(connectorType, responseData, null, null, 0); + } + + /** + * Creates a request with all fields. + * + * @param connectorType the MCP connector type (e.g., "postgres") + * @param responseData the response data rows to validate + * @param message optional message context + * @param metadata optional metadata + * @param rowCount the number of rows in the response + */ + public MCPCheckOutputRequest(String connectorType, List> responseData, + String message, Map metadata, int rowCount) { + this.connectorType = connectorType; + this.responseData = responseData; + this.message = message; + this.metadata = metadata; + this.rowCount = rowCount; + } + + public String getConnectorType() { + return connectorType; + } + + public List> getResponseData() { + return responseData; + } + + public String getMessage() { + return message; + } + + public Map getMetadata() { + return metadata; + } + + public int getRowCount() { + return rowCount; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MCPCheckOutputRequest that = (MCPCheckOutputRequest) o; + return rowCount == that.rowCount && + Objects.equals(connectorType, that.connectorType) && + Objects.equals(responseData, that.responseData) && + Objects.equals(message, that.message) && + Objects.equals(metadata, that.metadata); + } + + @Override + public int hashCode() { + return Objects.hash(connectorType, responseData, message, metadata, rowCount); + } + + @Override + public String toString() { + return "MCPCheckOutputRequest{" + + "connectorType='" + connectorType + '\'' + + ", rowCount=" + rowCount + + ", message='" + message + '\'' + + '}'; + } +} diff --git a/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputResponse.java b/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputResponse.java new file mode 100644 index 0000000..1d4a5a7 --- /dev/null +++ b/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputResponse.java @@ -0,0 +1,140 @@ +/* + * Copyright 2026 AxonFlow + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.getaxonflow.sdk.types; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.Objects; + +/** + * Response from the MCP output policy check endpoint. + * + *

Indicates whether the output data passes configured policies. May include + * redacted data if PII redaction policies are active, and exfiltration check + * information if data volume limits are configured.

+ */ +@JsonIgnoreProperties(ignoreUnknown = true) +public final class MCPCheckOutputResponse { + + @JsonProperty("allowed") + private final boolean allowed; + + @JsonProperty("block_reason") + private final String blockReason; + + @JsonProperty("redacted_data") + private final Object redactedData; + + @JsonProperty("policies_evaluated") + private final int policiesEvaluated; + + @JsonProperty("exfiltration_info") + private final ExfiltrationCheckInfo exfiltrationInfo; + + @JsonProperty("policy_info") + private final ConnectorPolicyInfo policyInfo; + + @JsonCreator + public MCPCheckOutputResponse( + @JsonProperty("allowed") boolean allowed, + @JsonProperty("block_reason") String blockReason, + @JsonProperty("redacted_data") Object redactedData, + @JsonProperty("policies_evaluated") int policiesEvaluated, + @JsonProperty("exfiltration_info") ExfiltrationCheckInfo exfiltrationInfo, + @JsonProperty("policy_info") ConnectorPolicyInfo policyInfo) { + this.allowed = allowed; + this.blockReason = blockReason; + this.redactedData = redactedData; + this.policiesEvaluated = policiesEvaluated; + this.exfiltrationInfo = exfiltrationInfo; + this.policyInfo = policyInfo; + } + + /** + * Returns whether the output data is allowed by policies. + */ + public boolean isAllowed() { + return allowed; + } + + /** + * Returns the reason the output was blocked, or null if allowed. + */ + public String getBlockReason() { + return blockReason; + } + + /** + * Returns the redacted version of the data, or null if no redaction was applied. + */ + public Object getRedactedData() { + return redactedData; + } + + /** + * Returns the number of policies evaluated. + */ + public int getPoliciesEvaluated() { + return policiesEvaluated; + } + + /** + * Returns exfiltration check information. + * May be null if exfiltration checking is disabled. + */ + public ExfiltrationCheckInfo getExfiltrationInfo() { + return exfiltrationInfo; + } + + /** + * Returns detailed policy evaluation information. + */ + public ConnectorPolicyInfo getPolicyInfo() { + return policyInfo; + } + + @Override + public boolean equals(Object o) { + if (this == o) return true; + if (o == null || getClass() != o.getClass()) return false; + MCPCheckOutputResponse that = (MCPCheckOutputResponse) o; + return allowed == that.allowed && + policiesEvaluated == that.policiesEvaluated && + Objects.equals(blockReason, that.blockReason) && + Objects.equals(redactedData, that.redactedData) && + Objects.equals(exfiltrationInfo, that.exfiltrationInfo) && + Objects.equals(policyInfo, that.policyInfo); + } + + @Override + public int hashCode() { + return Objects.hash(allowed, blockReason, redactedData, policiesEvaluated, + exfiltrationInfo, policyInfo); + } + + @Override + public String toString() { + return "MCPCheckOutputResponse{" + + "allowed=" + allowed + + ", blockReason='" + blockReason + '\'' + + ", policiesEvaluated=" + policiesEvaluated + + ", exfiltrationInfo=" + exfiltrationInfo + + ", policyInfo=" + policyInfo + + '}'; + } +} From 84ec5fd5ffc44dca5bd56f13105805e109c7604b Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 27 Feb 2026 19:14:26 +0530 Subject: [PATCH 2/7] test: add tests for MCP check-input/check-output methods Cover mcpCheckInput and mcpCheckOutput SDK methods and their associated request/response types to restore JaCoCo coverage above the 73% threshold. Tests added: - mcpCheckInput: basic call, with options, 403 blocked result, 500 error, null validation, async variant - mcpCheckOutput: basic call, with options, 403 blocked result, exfiltration info, 500 error, null validation, async variant - MCPCheckInputRequest: construction, serialization, equals/hashCode - MCPCheckInputResponse: construction, deserialization, equals/hashCode - MCPCheckOutputRequest: construction, serialization, equals/hashCode - MCPCheckOutputResponse: construction, deserialization, equals/hashCode --- .../com/getaxonflow/sdk/AxonFlowTest.java | 265 +++++++++++++ .../getaxonflow/sdk/types/MoreTypesTest.java | 367 ++++++++++++++++++ 2 files changed, 632 insertions(+) diff --git a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java index 46560f3..dd12e0d 100644 --- a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java +++ b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java @@ -1611,6 +1611,271 @@ void mcpExecuteShouldReturnResponseWithPolicyInfo() { assertThat(response.getPolicyInfo().getPoliciesEvaluated()).isEqualTo(3); } + // ======================================================================== + // MCP Check Input/Output Tests (Policy Pre-validation) + // ======================================================================== + + @Test + @DisplayName("mcpCheckInput should return allowed response") + void mcpCheckInputShouldReturnAllowedResponse() { + stubFor(post(urlEqualTo("/api/v1/mcp/check-input")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\": true, \"policies_evaluated\": 3, " + + "\"policy_info\": {\"policies_evaluated\": 3, \"blocked\": false, " + + "\"redactions_applied\": 0, \"processing_time_ms\": 1}}"))); + + MCPCheckInputResponse response = axonflow.mcpCheckInput("postgres", "SELECT * FROM users"); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getPoliciesEvaluated()).isEqualTo(3); + assertThat(response.getBlockReason()).isNull(); + assertThat(response.getPolicyInfo()).isNotNull(); + assertThat(response.getPolicyInfo().getPoliciesEvaluated()).isEqualTo(3); + } + + @Test + @DisplayName("mcpCheckInput with options should send operation and parameters") + void mcpCheckInputWithOptionsShouldSendOperationAndParameters() { + stubFor(post(urlEqualTo("/api/v1/mcp/check-input")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\": true, \"policies_evaluated\": 5, " + + "\"policy_info\": {\"policies_evaluated\": 5, \"blocked\": false, " + + "\"redactions_applied\": 0, \"processing_time_ms\": 2}}"))); + + Map options = Map.of( + "operation", "execute", + "parameters", Map.of("limit", 100) + ); + MCPCheckInputResponse response = axonflow.mcpCheckInput("postgres", "UPDATE users SET name = $1", options); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getPoliciesEvaluated()).isEqualTo(5); + + verify(postRequestedFor(urlEqualTo("/api/v1/mcp/check-input")) + .withRequestBody(containing("\"connector_type\":\"postgres\"")) + .withRequestBody(containing("\"statement\":\"UPDATE users SET name = $1\"")) + .withRequestBody(containing("\"operation\":\"execute\""))); + } + + @Test + @DisplayName("mcpCheckInput should handle 403 as blocked result") + void mcpCheckInputShouldHandle403AsBlockedResult() { + stubFor(post(urlEqualTo("/api/v1/mcp/check-input")) + .willReturn(aResponse() + .withStatus(403) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\": false, \"block_reason\": \"SQL injection detected\", " + + "\"policies_evaluated\": 3, " + + "\"policy_info\": {\"policies_evaluated\": 3, \"blocked\": true, " + + "\"block_reason\": \"SQL injection detected\", " + + "\"redactions_applied\": 0, \"processing_time_ms\": 1}}"))); + + MCPCheckInputResponse response = axonflow.mcpCheckInput("postgres", "SELECT * FROM users; DROP TABLE users;--"); + + assertThat(response.isAllowed()).isFalse(); + assertThat(response.getBlockReason()).isEqualTo("SQL injection detected"); + assertThat(response.getPolicyInfo()).isNotNull(); + assertThat(response.getPolicyInfo().isBlocked()).isTrue(); + } + + @Test + @DisplayName("mcpCheckInput should throw on 500 error") + void mcpCheckInputShouldThrowOn500Error() { + stubFor(post(urlEqualTo("/api/v1/mcp/check-input")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\": \"Internal server error\"}"))); + + assertThatThrownBy(() -> axonflow.mcpCheckInput("postgres", "SELECT 1")) + .isInstanceOf(ConnectorException.class) + .hasMessageContaining("Internal server error"); + } + + @Test + @DisplayName("mcpCheckInput should require non-null connectorType") + void mcpCheckInputShouldRequireConnectorType() { + assertThatThrownBy(() -> axonflow.mcpCheckInput(null, "SELECT 1")) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("mcpCheckInput should require non-null statement") + void mcpCheckInputShouldRequireStatement() { + assertThatThrownBy(() -> axonflow.mcpCheckInput("postgres", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("mcpCheckInputAsync should return future") + void mcpCheckInputAsyncShouldReturnFuture() throws Exception { + stubFor(post(urlEqualTo("/api/v1/mcp/check-input")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\": true, \"policies_evaluated\": 2, " + + "\"policy_info\": {\"policies_evaluated\": 2, \"blocked\": false, " + + "\"redactions_applied\": 0, \"processing_time_ms\": 1}}"))); + + CompletableFuture future = axonflow.mcpCheckInputAsync("postgres", "SELECT 1"); + MCPCheckInputResponse response = future.get(); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getPoliciesEvaluated()).isEqualTo(2); + } + + @Test + @DisplayName("mcpCheckOutput should return allowed response") + void mcpCheckOutputShouldReturnAllowedResponse() { + stubFor(post(urlEqualTo("/api/v1/mcp/check-output")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\": true, \"policies_evaluated\": 4, " + + "\"policy_info\": {\"policies_evaluated\": 4, \"blocked\": false, " + + "\"redactions_applied\": 0, \"processing_time_ms\": 3}}"))); + + List> responseData = List.of( + Map.of("id", 1, "name", "Alice"), + Map.of("id", 2, "name", "Bob") + ); + MCPCheckOutputResponse response = axonflow.mcpCheckOutput("postgres", responseData); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getPoliciesEvaluated()).isEqualTo(4); + assertThat(response.getBlockReason()).isNull(); + assertThat(response.getPolicyInfo()).isNotNull(); + } + + @Test + @DisplayName("mcpCheckOutput with options should send message, metadata, and row_count") + void mcpCheckOutputWithOptionsShouldSendOptions() { + stubFor(post(urlEqualTo("/api/v1/mcp/check-output")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\": true, \"policies_evaluated\": 6, " + + "\"policy_info\": {\"policies_evaluated\": 6, \"blocked\": false, " + + "\"redactions_applied\": 0, \"processing_time_ms\": 2}}"))); + + List> responseData = List.of( + Map.of("id", 1, "name", "Alice") + ); + Map options = Map.of( + "message", "Query completed", + "metadata", Map.of("source", "analytics"), + "row_count", 1 + ); + MCPCheckOutputResponse response = axonflow.mcpCheckOutput("postgres", responseData, options); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getPoliciesEvaluated()).isEqualTo(6); + + verify(postRequestedFor(urlEqualTo("/api/v1/mcp/check-output")) + .withRequestBody(containing("\"connector_type\":\"postgres\"")) + .withRequestBody(containing("\"message\":\"Query completed\"")) + .withRequestBody(containing("\"row_count\":1"))); + } + + @Test + @DisplayName("mcpCheckOutput should handle 403 as blocked result") + void mcpCheckOutputShouldHandle403AsBlockedResult() { + stubFor(post(urlEqualTo("/api/v1/mcp/check-output")) + .willReturn(aResponse() + .withStatus(403) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\": false, \"block_reason\": \"PII detected in output\", " + + "\"policies_evaluated\": 4, " + + "\"redacted_data\": [{\"id\": 1, \"ssn\": \"***REDACTED***\"}], " + + "\"policy_info\": {\"policies_evaluated\": 4, \"blocked\": true, " + + "\"block_reason\": \"PII detected in output\", " + + "\"redactions_applied\": 1, \"processing_time_ms\": 5}}"))); + + List> responseData = List.of( + Map.of("id", 1, "ssn", "123-45-6789") + ); + MCPCheckOutputResponse response = axonflow.mcpCheckOutput("postgres", responseData); + + assertThat(response.isAllowed()).isFalse(); + assertThat(response.getBlockReason()).isEqualTo("PII detected in output"); + assertThat(response.getRedactedData()).isNotNull(); + assertThat(response.getPolicyInfo()).isNotNull(); + assertThat(response.getPolicyInfo().isBlocked()).isTrue(); + } + + @Test + @DisplayName("mcpCheckOutput should handle response with exfiltration info") + void mcpCheckOutputShouldHandleExfiltrationInfo() { + stubFor(post(urlEqualTo("/api/v1/mcp/check-output")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\": true, \"policies_evaluated\": 3, " + + "\"exfiltration_info\": {\"rows_returned\": 10, \"row_limit\": 1000, " + + "\"bytes_returned\": 2048, \"byte_limit\": 1048576, \"within_limits\": true}, " + + "\"policy_info\": {\"policies_evaluated\": 3, \"blocked\": false, " + + "\"redactions_applied\": 0, \"processing_time_ms\": 2}}"))); + + List> responseData = List.of(Map.of("id", 1)); + MCPCheckOutputResponse response = axonflow.mcpCheckOutput("postgres", responseData); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getExfiltrationInfo()).isNotNull(); + assertThat(response.getExfiltrationInfo().getRowsReturned()).isEqualTo(10); + assertThat(response.getExfiltrationInfo().isWithinLimits()).isTrue(); + } + + @Test + @DisplayName("mcpCheckOutput should throw on 500 error") + void mcpCheckOutputShouldThrowOn500Error() { + stubFor(post(urlEqualTo("/api/v1/mcp/check-output")) + .willReturn(aResponse() + .withStatus(500) + .withHeader("Content-Type", "application/json") + .withBody("{\"error\": \"Internal server error\"}"))); + + assertThatThrownBy(() -> axonflow.mcpCheckOutput("postgres", List.of(Map.of("id", 1)))) + .isInstanceOf(ConnectorException.class) + .hasMessageContaining("Internal server error"); + } + + @Test + @DisplayName("mcpCheckOutput should require non-null connectorType") + void mcpCheckOutputShouldRequireConnectorType() { + assertThatThrownBy(() -> axonflow.mcpCheckOutput(null, List.of())) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("mcpCheckOutput should require non-null responseData") + void mcpCheckOutputShouldRequireResponseData() { + assertThatThrownBy(() -> axonflow.mcpCheckOutput("postgres", null)) + .isInstanceOf(NullPointerException.class); + } + + @Test + @DisplayName("mcpCheckOutputAsync should return future") + void mcpCheckOutputAsyncShouldReturnFuture() throws Exception { + stubFor(post(urlEqualTo("/api/v1/mcp/check-output")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\": true, \"policies_evaluated\": 2, " + + "\"policy_info\": {\"policies_evaluated\": 2, \"blocked\": false, " + + "\"redactions_applied\": 0, \"processing_time_ms\": 1}}"))); + + CompletableFuture future = axonflow.mcpCheckOutputAsync( + "postgres", List.of(Map.of("id", 1))); + MCPCheckOutputResponse response = future.get(); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getPoliciesEvaluated()).isEqualTo(2); + } + // ======================================================================== // Rollback Plan // ======================================================================== diff --git a/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java b/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java index e00f360..97d7537 100644 --- a/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java +++ b/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java @@ -1515,4 +1515,371 @@ void shouldHaveToString() { assertThat(response.toString()).contains("ClientResponse"); } } + + @Nested + @DisplayName("MCPCheckInputRequest") + class MCPCheckInputRequestTests { + + @Test + @DisplayName("should create instance with connector type and statement only") + void shouldCreateWithBasicFields() { + MCPCheckInputRequest request = new MCPCheckInputRequest("postgres", "SELECT * FROM users"); + + assertThat(request.getConnectorType()).isEqualTo("postgres"); + assertThat(request.getStatement()).isEqualTo("SELECT * FROM users"); + assertThat(request.getOperation()).isEqualTo("query"); + assertThat(request.getParameters()).isNull(); + } + + @Test + @DisplayName("should create instance with all fields") + void shouldCreateWithAllFields() { + Map params = Map.of("limit", 100); + MCPCheckInputRequest request = new MCPCheckInputRequest( + "postgres", "UPDATE users SET name = $1", params, "execute" + ); + + assertThat(request.getConnectorType()).isEqualTo("postgres"); + assertThat(request.getStatement()).isEqualTo("UPDATE users SET name = $1"); + assertThat(request.getOperation()).isEqualTo("execute"); + assertThat(request.getParameters()).containsEntry("limit", 100); + } + + @Test + @DisplayName("should serialize to JSON") + void shouldSerializeToJson() throws Exception { + MCPCheckInputRequest request = new MCPCheckInputRequest( + "postgres", "SELECT 1", Map.of("timeout", 30), "query" + ); + + String json = objectMapper.writeValueAsString(request); + + assertThat(json).contains("\"connector_type\":\"postgres\""); + assertThat(json).contains("\"statement\":\"SELECT 1\""); + assertThat(json).contains("\"operation\":\"query\""); + assertThat(json).contains("\"parameters\""); + } + + @Test + @DisplayName("should omit null parameters in JSON") + void shouldOmitNullParametersInJson() throws Exception { + MCPCheckInputRequest request = new MCPCheckInputRequest("postgres", "SELECT 1"); + + String json = objectMapper.writeValueAsString(request); + + assertThat(json).doesNotContain("\"parameters\""); + } + + @Test + @DisplayName("should implement equals and hashCode") + void shouldImplementEqualsAndHashCode() { + MCPCheckInputRequest r1 = new MCPCheckInputRequest("postgres", "SELECT 1"); + MCPCheckInputRequest r2 = new MCPCheckInputRequest("postgres", "SELECT 1"); + MCPCheckInputRequest r3 = new MCPCheckInputRequest("mysql", "SELECT 1"); + + assertThat(r1).isEqualTo(r2); + assertThat(r1.hashCode()).isEqualTo(r2.hashCode()); + assertThat(r1).isNotEqualTo(r3); + } + + @Test + @DisplayName("should have toString") + void shouldHaveToString() { + MCPCheckInputRequest request = new MCPCheckInputRequest("postgres", "SELECT 1"); + assertThat(request.toString()).contains("MCPCheckInputRequest"); + assertThat(request.toString()).contains("postgres"); + } + } + + @Nested + @DisplayName("MCPCheckInputResponse") + class MCPCheckInputResponseTests { + + @Test + @DisplayName("should create allowed response") + void shouldCreateAllowedResponse() { + MCPCheckInputResponse response = new MCPCheckInputResponse(true, null, 3, null); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getBlockReason()).isNull(); + assertThat(response.getPoliciesEvaluated()).isEqualTo(3); + assertThat(response.getPolicyInfo()).isNull(); + } + + @Test + @DisplayName("should create blocked response") + void shouldCreateBlockedResponse() { + ConnectorPolicyInfo policyInfo = new ConnectorPolicyInfo( + 3, true, "SQL injection detected", 0, 1, null + ); + MCPCheckInputResponse response = new MCPCheckInputResponse( + false, "SQL injection detected", 3, policyInfo + ); + + assertThat(response.isAllowed()).isFalse(); + assertThat(response.getBlockReason()).isEqualTo("SQL injection detected"); + assertThat(response.getPolicyInfo()).isNotNull(); + assertThat(response.getPolicyInfo().isBlocked()).isTrue(); + } + + @Test + @DisplayName("should deserialize from JSON") + void shouldDeserializeFromJson() throws Exception { + String json = "{" + + "\"allowed\":true," + + "\"policies_evaluated\":5," + + "\"policy_info\":{\"policies_evaluated\":5,\"blocked\":false," + + "\"redactions_applied\":0,\"processing_time_ms\":2}" + + "}"; + + MCPCheckInputResponse response = objectMapper.readValue(json, MCPCheckInputResponse.class); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getPoliciesEvaluated()).isEqualTo(5); + assertThat(response.getPolicyInfo()).isNotNull(); + assertThat(response.getPolicyInfo().getPoliciesEvaluated()).isEqualTo(5); + } + + @Test + @DisplayName("should deserialize blocked response from JSON") + void shouldDeserializeBlockedResponseFromJson() throws Exception { + String json = "{" + + "\"allowed\":false," + + "\"block_reason\":\"DROP TABLE not allowed\"," + + "\"policies_evaluated\":3," + + "\"policy_info\":{\"policies_evaluated\":3,\"blocked\":true," + + "\"block_reason\":\"DROP TABLE not allowed\"," + + "\"redactions_applied\":0,\"processing_time_ms\":1}" + + "}"; + + MCPCheckInputResponse response = objectMapper.readValue(json, MCPCheckInputResponse.class); + + assertThat(response.isAllowed()).isFalse(); + assertThat(response.getBlockReason()).isEqualTo("DROP TABLE not allowed"); + } + + @Test + @DisplayName("should implement equals and hashCode") + void shouldImplementEqualsAndHashCode() { + MCPCheckInputResponse r1 = new MCPCheckInputResponse(true, null, 3, null); + MCPCheckInputResponse r2 = new MCPCheckInputResponse(true, null, 3, null); + MCPCheckInputResponse r3 = new MCPCheckInputResponse(false, "blocked", 3, null); + + assertThat(r1).isEqualTo(r2); + assertThat(r1.hashCode()).isEqualTo(r2.hashCode()); + assertThat(r1).isNotEqualTo(r3); + } + + @Test + @DisplayName("should have toString") + void shouldHaveToString() { + MCPCheckInputResponse response = new MCPCheckInputResponse(true, null, 3, null); + assertThat(response.toString()).contains("MCPCheckInputResponse"); + } + } + + @Nested + @DisplayName("MCPCheckOutputRequest") + class MCPCheckOutputRequestTests { + + @Test + @DisplayName("should create instance with connector type and response data only") + void shouldCreateWithBasicFields() { + List> data = List.of(Map.of("id", 1, "name", "Alice")); + MCPCheckOutputRequest request = new MCPCheckOutputRequest("postgres", data); + + assertThat(request.getConnectorType()).isEqualTo("postgres"); + assertThat(request.getResponseData()).hasSize(1); + assertThat(request.getMessage()).isNull(); + assertThat(request.getMetadata()).isNull(); + assertThat(request.getRowCount()).isEqualTo(0); + } + + @Test + @DisplayName("should create instance with all fields") + void shouldCreateWithAllFields() { + List> data = List.of( + Map.of("id", 1, "name", "Alice"), + Map.of("id", 2, "name", "Bob") + ); + Map metadata = Map.of("source", "analytics"); + MCPCheckOutputRequest request = new MCPCheckOutputRequest( + "postgres", data, "Query completed", metadata, 2 + ); + + assertThat(request.getConnectorType()).isEqualTo("postgres"); + assertThat(request.getResponseData()).hasSize(2); + assertThat(request.getMessage()).isEqualTo("Query completed"); + assertThat(request.getMetadata()).containsEntry("source", "analytics"); + assertThat(request.getRowCount()).isEqualTo(2); + } + + @Test + @DisplayName("should serialize to JSON") + void shouldSerializeToJson() throws Exception { + List> data = List.of(Map.of("id", 1)); + MCPCheckOutputRequest request = new MCPCheckOutputRequest( + "postgres", data, "done", Map.of("key", "val"), 1 + ); + + String json = objectMapper.writeValueAsString(request); + + assertThat(json).contains("\"connector_type\":\"postgres\""); + assertThat(json).contains("\"response_data\""); + assertThat(json).contains("\"message\":\"done\""); + assertThat(json).contains("\"row_count\":1"); + } + + @Test + @DisplayName("should omit null fields in JSON") + void shouldOmitNullFieldsInJson() throws Exception { + List> data = List.of(Map.of("id", 1)); + MCPCheckOutputRequest request = new MCPCheckOutputRequest("postgres", data); + + String json = objectMapper.writeValueAsString(request); + + assertThat(json).doesNotContain("\"message\""); + assertThat(json).doesNotContain("\"metadata\""); + } + + @Test + @DisplayName("should implement equals and hashCode") + void shouldImplementEqualsAndHashCode() { + List> data = List.of(Map.of("id", 1)); + MCPCheckOutputRequest r1 = new MCPCheckOutputRequest("postgres", data); + MCPCheckOutputRequest r2 = new MCPCheckOutputRequest("postgres", data); + MCPCheckOutputRequest r3 = new MCPCheckOutputRequest("mysql", data); + + assertThat(r1).isEqualTo(r2); + assertThat(r1.hashCode()).isEqualTo(r2.hashCode()); + assertThat(r1).isNotEqualTo(r3); + } + + @Test + @DisplayName("should have toString") + void shouldHaveToString() { + List> data = List.of(Map.of("id", 1)); + MCPCheckOutputRequest request = new MCPCheckOutputRequest("postgres", data); + assertThat(request.toString()).contains("MCPCheckOutputRequest"); + assertThat(request.toString()).contains("postgres"); + } + } + + @Nested + @DisplayName("MCPCheckOutputResponse") + class MCPCheckOutputResponseTests { + + @Test + @DisplayName("should create allowed response") + void shouldCreateAllowedResponse() { + MCPCheckOutputResponse response = new MCPCheckOutputResponse( + true, null, null, 4, null, null + ); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getBlockReason()).isNull(); + assertThat(response.getRedactedData()).isNull(); + assertThat(response.getPoliciesEvaluated()).isEqualTo(4); + assertThat(response.getExfiltrationInfo()).isNull(); + assertThat(response.getPolicyInfo()).isNull(); + } + + @Test + @DisplayName("should create blocked response with redacted data") + void shouldCreateBlockedResponseWithRedactedData() { + ConnectorPolicyInfo policyInfo = new ConnectorPolicyInfo( + 4, true, "PII detected", 1, 5, null + ); + List> redacted = List.of( + Map.of("id", 1, "ssn", "***REDACTED***") + ); + MCPCheckOutputResponse response = new MCPCheckOutputResponse( + false, "PII detected", redacted, 4, null, policyInfo + ); + + assertThat(response.isAllowed()).isFalse(); + assertThat(response.getBlockReason()).isEqualTo("PII detected"); + assertThat(response.getRedactedData()).isNotNull(); + assertThat(response.getPolicyInfo().getRedactionsApplied()).isEqualTo(1); + } + + @Test + @DisplayName("should create response with exfiltration info") + void shouldCreateResponseWithExfiltrationInfo() { + ExfiltrationCheckInfo exfilInfo = new ExfiltrationCheckInfo( + 10, 1000, 2048, 1048576, true + ); + MCPCheckOutputResponse response = new MCPCheckOutputResponse( + true, null, null, 3, exfilInfo, null + ); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getExfiltrationInfo()).isNotNull(); + assertThat(response.getExfiltrationInfo().getRowsReturned()).isEqualTo(10); + assertThat(response.getExfiltrationInfo().getRowLimit()).isEqualTo(1000); + assertThat(response.getExfiltrationInfo().isWithinLimits()).isTrue(); + } + + @Test + @DisplayName("should deserialize from JSON") + void shouldDeserializeFromJson() throws Exception { + String json = "{" + + "\"allowed\":true," + + "\"policies_evaluated\":3," + + "\"exfiltration_info\":{\"rows_returned\":5,\"row_limit\":500," + + "\"bytes_returned\":1024,\"byte_limit\":524288,\"within_limits\":true}," + + "\"policy_info\":{\"policies_evaluated\":3,\"blocked\":false," + + "\"redactions_applied\":0,\"processing_time_ms\":2}" + + "}"; + + MCPCheckOutputResponse response = objectMapper.readValue(json, MCPCheckOutputResponse.class); + + assertThat(response.isAllowed()).isTrue(); + assertThat(response.getPoliciesEvaluated()).isEqualTo(3); + assertThat(response.getExfiltrationInfo()).isNotNull(); + assertThat(response.getExfiltrationInfo().getRowsReturned()).isEqualTo(5); + assertThat(response.getPolicyInfo()).isNotNull(); + } + + @Test + @DisplayName("should deserialize blocked response with redacted data from JSON") + void shouldDeserializeBlockedResponseFromJson() throws Exception { + String json = "{" + + "\"allowed\":false," + + "\"block_reason\":\"PII content detected\"," + + "\"redacted_data\":[{\"id\":1,\"ssn\":\"***REDACTED***\"}]," + + "\"policies_evaluated\":4," + + "\"policy_info\":{\"policies_evaluated\":4,\"blocked\":true," + + "\"block_reason\":\"PII content detected\"," + + "\"redactions_applied\":1,\"processing_time_ms\":3}" + + "}"; + + MCPCheckOutputResponse response = objectMapper.readValue(json, MCPCheckOutputResponse.class); + + assertThat(response.isAllowed()).isFalse(); + assertThat(response.getBlockReason()).isEqualTo("PII content detected"); + assertThat(response.getRedactedData()).isNotNull(); + } + + @Test + @DisplayName("should implement equals and hashCode") + void shouldImplementEqualsAndHashCode() { + MCPCheckOutputResponse r1 = new MCPCheckOutputResponse(true, null, null, 3, null, null); + MCPCheckOutputResponse r2 = new MCPCheckOutputResponse(true, null, null, 3, null, null); + MCPCheckOutputResponse r3 = new MCPCheckOutputResponse(false, "blocked", null, 3, null, null); + + assertThat(r1).isEqualTo(r2); + assertThat(r1.hashCode()).isEqualTo(r2.hashCode()); + assertThat(r1).isNotEqualTo(r3); + } + + @Test + @DisplayName("should have toString") + void shouldHaveToString() { + MCPCheckOutputResponse response = new MCPCheckOutputResponse( + true, null, null, 3, null, null + ); + assertThat(response.toString()).contains("MCPCheckOutputResponse"); + } + } } From 7f544e897d51ef960c6afb37b36939402767bb9d Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 27 Feb 2026 19:22:48 +0530 Subject: [PATCH 3/7] docs: add MCP policy-check endpoints to changelog --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e337c4..26899dd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ All notable changes to the AxonFlow Java SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- **MCP Policy-Check Endpoints**: Standalone policy validation for external orchestrators (LangGraph, CrewAI) + - `mcpCheckInput(connectorType, statement)`: Validate MCP queries/commands against input policies (SQLi, PII, dynamic) before execution + - `mcpCheckOutput(connectorType, responseData)`: Validate MCP response data against output policies (PII redaction, exfiltration limits) after execution + - New types: `MCPCheckInputRequest`, `MCPCheckInputResponse`, `MCPCheckOutputRequest`, `MCPCheckOutputResponse` + - Sync + async variants with overloads for additional options + +--- + ## [3.6.0] - 2026-02-22 ### Added From 0952a30a57e55adf5d7c9e1a2028e069b1ce6fd3 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 27 Feb 2026 19:39:14 +0530 Subject: [PATCH 4/7] fix: allow null responseData in mcpCheckOutput for execute-style requests The API contract allows check-output requests with message only (no response_data) for execute-style validation. Removed the Objects.requireNonNull check on responseData and updated the test to verify execute-style requests with null responseData work correctly. --- .../java/com/getaxonflow/sdk/AxonFlow.java | 2 +- .../com/getaxonflow/sdk/AxonFlowTest.java | 19 +++++++++++++++---- 2 files changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index 0c98156..3c371bb 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -1549,7 +1549,7 @@ public MCPCheckOutputResponse mcpCheckOutput(String connectorType, List> responseData, Map options) { Objects.requireNonNull(connectorType, "connectorType cannot be null"); - Objects.requireNonNull(responseData, "responseData cannot be null"); + // responseData can be null for execute-style requests that use message instead return retryExecutor.execute(() -> { String message = options != null ? (String) options.get("message") : null; diff --git a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java index dd12e0d..fbd71e8 100644 --- a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java +++ b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java @@ -1851,10 +1851,21 @@ void mcpCheckOutputShouldRequireConnectorType() { } @Test - @DisplayName("mcpCheckOutput should require non-null responseData") - void mcpCheckOutputShouldRequireResponseData() { - assertThatThrownBy(() -> axonflow.mcpCheckOutput("postgres", null)) - .isInstanceOf(NullPointerException.class); + @DisplayName("mcpCheckOutput should allow null responseData for execute-style requests") + void mcpCheckOutputShouldAllowNullResponseData() { + stubFor(post(urlEqualTo("/api/v1/mcp/check-output")) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody("{\"allowed\": true, \"policies_evaluated\": 1, " + + "\"policy_info\": {\"policies_evaluated\": 1, \"blocked\": false, " + + "\"redactions_applied\": 0, \"processing_time_ms\": 1}}"))); + + Map options = new HashMap<>(); + options.put("message", "3 rows updated"); + + MCPCheckOutputResponse resp = axonflow.mcpCheckOutput("postgres", null, options); + assertThat(resp.isAllowed()).isTrue(); } @Test From c24f243c2f116726fac8cbc91eab6cf63423004b Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 27 Feb 2026 19:42:12 +0530 Subject: [PATCH 5/7] fix: add missing HashMap import in AxonFlowTest --- src/test/java/com/getaxonflow/sdk/AxonFlowTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java index fbd71e8..338a01f 100644 --- a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java +++ b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java @@ -23,6 +23,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.DisplayName; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; From ff697aad95b18126b30409074289940f5fce66a2 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 27 Feb 2026 21:14:08 +0530 Subject: [PATCH 6/7] docs: set v3.7.0 release date in changelog --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26899dd..d436f53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to the AxonFlow Java SDK will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased] +## [3.7.0] - 2026-02-28 ### Added From 9d0819b5b3633782f4871098458e3db9ce610264 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 28 Feb 2026 13:58:40 +0530 Subject: [PATCH 7/7] docs: improve v3.7.0 changelog entry --- CHANGELOG.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d436f53..13cf6f3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,11 +9,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added -- **MCP Policy-Check Endpoints**: Standalone policy validation for external orchestrators (LangGraph, CrewAI) - - `mcpCheckInput(connectorType, statement)`: Validate MCP queries/commands against input policies (SQLi, PII, dynamic) before execution - - `mcpCheckOutput(connectorType, responseData)`: Validate MCP response data against output policies (PII redaction, exfiltration limits) after execution +- **MCP Policy-Check Endpoints** (Platform v4.6.0+): Standalone policy validation for external orchestrators (LangGraph, CrewAI) to enforce AxonFlow policies without executing connector queries + - `mcpCheckInput(connectorType, statement)`: Validate SQL/commands against input policies (SQLi detection, dangerous query blocking, PII in queries, dynamic policies). Returns `MCPCheckInputResponse` with `isAllowed()` or `getBlockReason()` + - `mcpCheckOutput(connectorType, responseData)`: Validate MCP response data against output policies (PII redaction, exfiltration limits, dynamic policies). Returns original or redacted data with `PolicyInfo` - New types: `MCPCheckInputRequest`, `MCPCheckInputResponse`, `MCPCheckOutputRequest`, `MCPCheckOutputResponse` - - Sync + async variants with overloads for additional options + - Sync + async variants with overloads for additional options (`parameters`, `operation`) + - Supports both query-style (`responseData`) and execute-style (`message` + `metadata`) output validation ---