diff --git a/.gitignore b/.gitignore index c9e6d37..f5bf946 100644 --- a/.gitignore +++ b/.gitignore @@ -103,3 +103,7 @@ local.properties # Heartbeat real-stack smoke build dir tests/heartbeat-real-stack/build/ tests/heartbeat-real-stack/cp.txt + +# Python bytecode (from scripts/wire_shape tooling) +__pycache__/ +*.pyc diff --git a/CHANGELOG.md b/CHANGELOG.md index e2be471..32ee5ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,9 +7,47 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -Hostile-testing sweep ahead of the BukuWarung integration -(getaxonflow/axonflow-enterprise#2861), plus the `caller_name` audit field -below. +## [9.0.0] - 2026-07-18 + +### Changed (BREAKING) + +- **The LangGraph adapter now reports the (server, tool) identity as two + separate wire fields instead of concatenating them into `connectorType`.** + `MCPToolRequest#getServerName()` and `MCPToolRequest#getName()` are threaded + through as `connector_type` and the new `tool` field on + `mcpCheckInput`/`mcpCheckOutput`, matching the platform's two-field + (server, tool) MCP identity contract. `mcpToolInterceptor()` sends + `connectorType = serverName` and `tool = getName()`; the default + `connectorTypeFn` returns the bare `serverName`. **`MCPInterceptorOptions` + has no `toolFn`:** the tool identity is always `MCPToolRequest#getName()`, so + a caller cannot write an arbitrary tool identity into the audit trail. + `connectorTypeFn` remains the escape hatch for the server/connector + dimension only. + + **Migration.** Policies or per-connector settings matching the old + concatenated value — e.g. `connector_type == "filesystem.read_file"` — stop + matching after upgrade. Re-scope them to match `connector_type == + "filesystem"` together with the `tool` field (e.g. `tool == "read_file"`). + The `connectorTypeFn` option is the compatibility lever: a caller can restore + any prior `connector_type` value (including the old concatenated form, + `req -> req.getServerName() + "." + req.getName()`) without losing the + separate `tool` field. + + **Missing-server edge.** With the default resolver, a tool whose + `getServerName()` is empty now sends `connector_type=""`, which the platform + rejects with HTTP 400 → the call throws and the tool is blocked (fail-closed), + never run ungoverned. Previously the concatenated value was `".tool"` (a + non-empty string the platform accepted). Supply a `connectorTypeFn` for + server-less MCP tools. + + **Minimum platform.** The `tool` field is consumed on `POST + /api/v1/mcp/check-input` by **AxonFlow platform v9.10.0+**. On platforms + below v9.10.0 the `tool` field is silently dropped and identity degrades to + the bare server name — coarser than the old concatenated value — so + **upgrade the platform to v9.10.0+ before adopting this SDK major.** + Response-plane (`check-output`) `tool` scoping requires **AxonFlow platform + v9.11.0+**; until then the SDK sends it forward-compatibly and older + platforms ignore it. ### Fixed @@ -27,14 +65,19 @@ below. SQLi → `deny` on `/api/v1/decide`, `allowed=false` on check-input, sync == async on both planes. +- `runtime-e2e/mcp_server_tool_split/` — live-agent assertion for the + `connector_type`/`tool` split: `LangGraphAdapter.mcpToolInterceptor()` + round-trips a clean tool call through check-input/check-output with the + server and tool names as two distinct wire fields, a direct + `mcpCheckInput(..., options)` call with an explicit `tool` option is + accepted, and the two-argument `mcpCheckInput(connectorType, statement)` + overload (no `tool` field) still works unchanged. - **`AuditToolCallRequest.callerName` (wire: `caller_name`)** — identifies WHICH CLIENT made a tool call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`), replacing the misleadingly-named `toolType` field for that - purpose (getaxonflow/axonflow-enterprise#2912, epic #2905). `toolType` is - kept as a **deprecated** input fallback — not removed, not renamed; the - server resolves `caller_name` if supplied, else the legacy `tool_type`, - else a default. `runtime-e2e/caller_name_audit/` proves `callerName` - reaches `policy_details.caller_name` on a live agent + orchestrator. + purpose. `toolType` is kept as a **deprecated** input fallback — not + removed, not renamed; the server resolves `caller_name` if supplied, else + the legacy `tool_type`, else a default. ## [8.5.1] - 2026-06-16: TLS security hardening (production guard) diff --git a/pom.xml b/pom.xml index 55ab3a8..6eaa825 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ com.getaxonflow axonflow-sdk - 8.5.1 + 9.0.0 jar AxonFlow Java SDK diff --git a/runtime-e2e/mcp_server_tool_split/McpServerToolSplitTest.java b/runtime-e2e/mcp_server_tool_split/McpServerToolSplitTest.java new file mode 100644 index 0000000..a1c6f4b --- /dev/null +++ b/runtime-e2e/mcp_server_tool_split/McpServerToolSplitTest.java @@ -0,0 +1,120 @@ +/* + * runtime-e2e/mcp_server_tool_split/McpServerToolSplitTest.java + * + * Real-stack proof that the platform's two-field MCP identity contract + * (connector_type + tool, epic getaxonflow/axonflow-enterprise#2905, #2904) + * is accepted end-to-end through the Java SDK's real public surface, for + * getaxonflow/axonflow-sdk-java#2909: + * + * LangGraphAdapter.mcpToolInterceptor() used to derive a single + * connectorType string as "{serverName}.{toolName}" because the wire + * contract only carried one identity field. It now sends + * MCPToolRequest#getServerName() as connectorType and + * MCPToolRequest#getName() as a separate "tool" field. + * + * This test runs a real JVM with the built SDK jar on the classpath and + * issues real HTTP requests to a real running AxonFlow agent — no mocks, + * no WireMock, no stubs. It asserts: + * + * 1. Interceptor path: MCPToolInterceptor#intercept() (reached only via + * LangGraphAdapter#mcpToolInterceptor(), the actual consumer-facing + * surface) round-trips a clean tool call through check-input -> + * handler -> check-output against the live agent, proving the agent + * accepts and processes the split server+tool identity on both the + * input and output check calls without erroring. + * 2. Direct low-level parity: AxonFlow#mcpCheckInput(connectorType, + * statement, options) with an explicit "tool" option is accepted + * (connector_type and tool arrive as two distinct wire fields). + * 3. Backward compatibility: the original two-argument + * mcpCheckInput(connectorType, statement) overload — which carries + * NO tool field at all — still works unchanged against the same + * live agent, proving the new field is additive, not breaking. + * + * Run (after `mvn -DskipTests package` to produce the SDK jar): + * + * mvn -q -DskipTests dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt + * SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1) + * AXONFLOW_AGENT_URL=http://localhost:8080 \ + * java -cp "$SDK_JAR:$(cat /tmp/cp.txt)" \ + * runtime-e2e/mcp_server_tool_split/McpServerToolSplitTest.java + * + * Defaults to http://localhost:8080 (community mode, no auth) if + * AXONFLOW_AGENT_URL is unset. Uses a dedicated tenant/clientId + * ("java-sdk-2909-runtime-e2e") to avoid colliding with other tests + * sharing the same agent. + */ +import com.getaxonflow.sdk.AxonFlow; +import com.getaxonflow.sdk.AxonFlowConfig; +import com.getaxonflow.sdk.adapters.LangGraphAdapter; +import com.getaxonflow.sdk.adapters.MCPToolInterceptor; +import com.getaxonflow.sdk.adapters.MCPToolRequest; +import com.getaxonflow.sdk.types.MCPCheckInputResponse; +import java.util.Map; + +public class McpServerToolSplitTest { + + static void fail(String msg) { + System.err.println("FAIL: " + msg); + System.exit(1); + } + + static void check(boolean cond, String msg) { + if (!cond) { + fail(msg); + } + } + + public static void main(String[] args) throws Exception { + String endpoint = + System.getenv().getOrDefault("AXONFLOW_AGENT_URL", "http://localhost:8080"); + String tenantId = "java-sdk-2909-runtime-e2e"; + + try (AxonFlow client = + AxonFlow.create( + AxonFlowConfig.builder().endpoint(endpoint).clientId(tenantId).build())) { + + // 1. Interceptor path: the real consumer-facing surface for #2909. + // MCPToolRequest#getServerName() -> connectorType, #getName() -> tool, + // threaded through as two distinct fields instead of being + // concatenated into one opaque connectorType string. + LangGraphAdapter adapter = + LangGraphAdapter.builder(client, "mcp-server-tool-split-e2e").build(); + MCPToolInterceptor interceptor = adapter.mcpToolInterceptor(); + + MCPToolRequest request = + new MCPToolRequest("orders-server", "list_orders", Map.of("status", "open")); + + Object result = interceptor.intercept(request, req -> Map.of("orders", java.util.List.of())); + check(result != null, "interceptor.intercept() returned null for a clean tool call"); + System.out.println( + "PASS [interceptor-server-tool-split] server=orders-server tool=list_orders " + + "-> result=" + result); + + // 2. Direct low-level call: explicit "tool" option is accepted and + // processed by the live agent as a distinct field from connectorType. + MCPCheckInputResponse withTool = + client.mcpCheckInput( + "orders-server", + "orders-server.list_orders({\"status\":\"open\"})", + Map.of("operation", "execute", "tool", "list_orders")); + check( + withTool.isAllowed(), + "expected clean statement with explicit tool option to be allowed, got blocked: " + + withTool.getBlockReason()); + System.out.println("PASS [direct-check-input-with-tool] allowed=" + withTool.isAllowed()); + + // 3. Backward compatibility: the pre-#2909 two-arg overload has no + // tool field on the wire at all. It must still work unchanged. + MCPCheckInputResponse withoutTool = + client.mcpCheckInput("orders-server", "SELECT 1"); + check( + withoutTool.isAllowed(), + "expected clean statement with no tool field (old shape) to be allowed, got blocked: " + + withoutTool.getBlockReason()); + System.out.println( + "PASS [backward-compat-no-tool] allowed=" + withoutTool.isAllowed()); + } + + System.out.println("RESULT: PASS (3/3)"); + } +} diff --git a/runtime-e2e/mcp_server_tool_split/README.md b/runtime-e2e/mcp_server_tool_split/README.md new file mode 100644 index 0000000..43459f1 --- /dev/null +++ b/runtime-e2e/mcp_server_tool_split/README.md @@ -0,0 +1,31 @@ +# mcp_server_tool_split (#2909 — MCP server/tool identity split, epic #2905/#2904) + +Real-stack proof, against a live AxonFlow agent, that: + +1. `LangGraphAdapter.mcpToolInterceptor()` (the real consumer-facing MCP + interceptor) sends `MCPToolRequest#getServerName()` as `connector_type` + and `MCPToolRequest#getName()` as a separate `tool` field — no longer + concatenated into one opaque `"{serverName}.{toolName}"` string — and a + clean tool call round-trips through check-input -> handler -> check-output + successfully. +2. `AxonFlow#mcpCheckInput(connectorType, statement, options)` with an + explicit `"tool"` option is accepted by the live agent. +3. The pre-#2909 two-argument `mcpCheckInput(connectorType, statement)` + overload — which carries no `tool` field at all — still works unchanged + (backward compatibility). + +## Run + +``` +mvn -q -DskipTests package +mvn -q -DskipTests dependency:build-classpath -Dmdep.outputFile=/tmp/cp.txt +SDK_JAR=$(ls target/axonflow-sdk-*.jar | grep -v sources | grep -v javadoc | head -1) +AXONFLOW_AGENT_URL=http://localhost:8080 \ + java -cp "$SDK_JAR:$(cat /tmp/cp.txt)" \ + runtime-e2e/mcp_server_tool_split/McpServerToolSplitTest.java +``` + +Defaults to `http://localhost:8080` (community mode, no auth) if +`AXONFLOW_AGENT_URL` is unset. Uses a dedicated `clientId`/tenant +(`java-sdk-2909-runtime-e2e`) to avoid collisions with other tests sharing +the same agent. Exits non-zero (and prints `FAIL: ...`) if any step fails. diff --git a/src/main/java/com/getaxonflow/sdk/AxonFlow.java b/src/main/java/com/getaxonflow/sdk/AxonFlow.java index f642fc6..bd91fe9 100644 --- a/src/main/java/com/getaxonflow/sdk/AxonFlow.java +++ b/src/main/java/com/getaxonflow/sdk/AxonFlow.java @@ -2468,7 +2468,7 @@ public MCPCheckInputResponse mcpCheckInput(String connectorType, String statemen * * @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) + * @param options optional parameters: "operation" (String), "parameters" (Map), "tool" (String) * @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) */ @@ -2487,9 +2487,13 @@ public MCPCheckInputResponse mcpCheckInput( // content_type selects the request-redaction detector (ADR-056 / #2563); null // defaults to text/plain server-side. String contentType = (String) options.get("content_type"); + // tool identifies the specific tool/action invoked on the MCP server, distinct + // from connectorType which identifies the server/connector itself (epic #2905 / + // #2904). + String tool = (String) options.get("tool"); request = new MCPCheckInputRequest( - connectorType, statement, parameters, operation, contentType); + connectorType, statement, parameters, operation, contentType, tool); } else { request = new MCPCheckInputRequest(connectorType, statement); } @@ -2828,7 +2832,8 @@ public MCPCheckOutputResponse mcpCheckOutput( * * @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) + * @param options optional parameters: "message" (String), "metadata" (Map), "row_count" (int), + * "tool" (String) * @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) */ @@ -2844,9 +2849,14 @@ public MCPCheckOutputResponse mcpCheckOutput( Map metadata = options != null ? (Map) options.get("metadata") : null; int rowCount = options != null ? (int) options.getOrDefault("row_count", 0) : 0; + // tool identifies the specific tool/action invoked on the MCP server, distinct + // from connectorType which identifies the server/connector itself (epic #2905 / + // #2904). + String tool = options != null ? (String) options.get("tool") : null; MCPCheckOutputRequest request = - new MCPCheckOutputRequest(connectorType, responseData, message, metadata, rowCount); + new MCPCheckOutputRequest( + connectorType, responseData, message, metadata, rowCount, tool); Request httpRequest = buildRequest("POST", "/api/v1/mcp/check-output", request); try (Response response = executeHttp(httpClient, httpRequest)) { diff --git a/src/main/java/com/getaxonflow/sdk/adapters/LangGraphAdapter.java b/src/main/java/com/getaxonflow/sdk/adapters/LangGraphAdapter.java index fa7569c..695693d 100644 --- a/src/main/java/com/getaxonflow/sdk/adapters/LangGraphAdapter.java +++ b/src/main/java/com/getaxonflow/sdk/adapters/LangGraphAdapter.java @@ -449,13 +449,15 @@ public MCPToolInterceptor mcpToolInterceptor(MCPInterceptorOptions options) { connectorTypeFn = options.getConnectorTypeFn() != null ? options.getConnectorTypeFn() - : req -> req.getServerName() + "." + req.getName(); + : MCPToolRequest::getServerName; operation = options.getOperation(); } else { - connectorTypeFn = req -> req.getServerName() + "." + req.getName(); + connectorTypeFn = MCPToolRequest::getServerName; operation = "execute"; } + // The tool identity is always the request's tool name (epic #2905, + // RULING 3); there is no caller-supplied tool override. return new MCPToolInterceptor(client, connectorTypeFn, operation); } diff --git a/src/main/java/com/getaxonflow/sdk/adapters/MCPInterceptorOptions.java b/src/main/java/com/getaxonflow/sdk/adapters/MCPInterceptorOptions.java index c419e35..36248e9 100644 --- a/src/main/java/com/getaxonflow/sdk/adapters/MCPInterceptorOptions.java +++ b/src/main/java/com/getaxonflow/sdk/adapters/MCPInterceptorOptions.java @@ -34,8 +34,12 @@ private MCPInterceptorOptions(Builder builder) { } /** - * Returns the function that maps an MCP request to a connector type string. May be null, in which - * case the default "{serverName}.{toolName}" is used. + * Returns the function that maps an MCP request to a connector type string. May be null, in + * which case the default {@link MCPToolRequest#getServerName()} is used. + * + *

Connector type identifies the MCP server/connector itself; it is sent separately from the + * tool name (which is always {@link MCPToolRequest#getName()}) so policies can match on server + * identity, tool identity, or both. * * @return the connector type function, or null */ @@ -63,7 +67,12 @@ public static final class Builder { private Builder() {} /** - * Sets a custom function to derive the connector type from an MCP request. + * Sets a custom function to derive the connector type (MCP server identity) from an MCP + * request. Defaults to {@link MCPToolRequest#getServerName()}. + * + *

There is deliberately no {@code toolFn} override: the tool identity is always {@link + * MCPToolRequest#getName()} so a caller cannot write an arbitrary tool identity into the audit + * trail (epic #2905, RULING 3). * * @param connectorTypeFn mapping function * @return this builder diff --git a/src/main/java/com/getaxonflow/sdk/adapters/MCPToolInterceptor.java b/src/main/java/com/getaxonflow/sdk/adapters/MCPToolInterceptor.java index b79fcf3..5f406b9 100644 --- a/src/main/java/com/getaxonflow/sdk/adapters/MCPToolInterceptor.java +++ b/src/main/java/com/getaxonflow/sdk/adapters/MCPToolInterceptor.java @@ -79,12 +79,18 @@ public final class MCPToolInterceptor { */ public Object intercept(MCPToolRequest request, MCPToolHandler handler) throws Exception { String connectorType = connectorTypeFn.apply(request); + // The tool identity is always the request's tool name (epic #2905, + // RULING 3): there is no caller-supplied tool override, so an arbitrary + // identity can never be written into the audit trail. connectorTypeFn + // remains the escape hatch for the server/connector dimension only. + String tool = request.getName(); String argsStr = serializeArgs(request.getArgs()); - String statement = connectorType + "(" + argsStr + ")"; + String statement = connectorType + "." + tool + "(" + argsStr + ")"; // Pre-check: validate input Map inputOptions = new HashMap<>(); inputOptions.put("operation", operation); + inputOptions.put("tool", tool); if (request.getArgs() != null && !request.getArgs().isEmpty()) { inputOptions.put("parameters", request.getArgs()); } @@ -111,6 +117,7 @@ public Object intercept(MCPToolRequest request, MCPToolHandler handler) throws E Map outputOptions = new HashMap<>(); outputOptions.put("message", resultStr); + outputOptions.put("tool", tool); MCPCheckOutputResponse outputCheck = client.mcpCheckOutput(connectorType, null, outputOptions); if (!outputCheck.isAllowed()) { diff --git a/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java b/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java index b0bfe8c..579d188 100644 --- a/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java +++ b/src/main/java/com/getaxonflow/sdk/types/MCPCheckInputRequest.java @@ -51,6 +51,17 @@ public final class MCPCheckInputRequest { @JsonProperty("content_type") private final String contentType; + /** + * The specific tool/action name being invoked on the MCP server (e.g., "query", "search_docs"). + * Distinct from {@code connectorType}, which identifies the MCP server/connector itself. + * Optional; null when the caller doesn't distinguish per-tool identity from the connector. + * Consumed on {@code POST /api/v1/mcp/check-input} by platform v9.10.0+ (enterprise c8df2006b); + * silently ignored by platforms below v9.10.0. Source of truth: {@code platform/agent} + * {@code MCPCheckInputRequest.Tool} (epic #2905 / #2904). + */ + @JsonProperty("tool") + private final String tool; + /** * Creates a request with connector type and statement only. Operation defaults to "execute". * @@ -76,7 +87,7 @@ public MCPCheckInputRequest( } /** - * Creates a request with all fields, including the redaction content type. + * Creates a request with all fields, including the redaction content type. Tool is left null. * * @param connectorType the MCP connector type (e.g., "postgres") * @param statement the statement to validate @@ -91,11 +102,35 @@ public MCPCheckInputRequest( Map parameters, String operation, String contentType) { + this(connectorType, statement, parameters, operation, contentType, null); + } + + /** + * Creates a request with all fields, including the redaction content type and the specific tool + * name. + * + * @param connectorType the MCP connector type/server (e.g., "postgres") + * @param statement the statement to validate + * @param parameters optional query parameters + * @param operation the operation type (e.g., "query", "execute") + * @param contentType the redaction content type (e.g., {@code text/plain}); null defaults + * server-side + * @param tool the specific tool/action name (e.g., "query"); null when not distinguished from + * {@code connectorType} + */ + public MCPCheckInputRequest( + String connectorType, + String statement, + Map parameters, + String operation, + String contentType, + String tool) { this.connectorType = connectorType; this.statement = statement; this.parameters = parameters; this.operation = operation; this.contentType = contentType; + this.tool = tool; } public String getConnectorType() { @@ -119,6 +154,14 @@ public String getContentType() { return contentType; } + /** + * Returns the specific tool/action name being invoked, or null when not distinguished from + * {@code connectorType}. + */ + public String getTool() { + return tool; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -128,12 +171,13 @@ public boolean equals(Object o) { && Objects.equals(statement, that.statement) && Objects.equals(parameters, that.parameters) && Objects.equals(operation, that.operation) - && Objects.equals(contentType, that.contentType); + && Objects.equals(contentType, that.contentType) + && Objects.equals(tool, that.tool); } @Override public int hashCode() { - return Objects.hash(connectorType, statement, parameters, operation, contentType); + return Objects.hash(connectorType, statement, parameters, operation, contentType, tool); } @Override diff --git a/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputRequest.java b/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputRequest.java index 160985e..50191e7 100644 --- a/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputRequest.java +++ b/src/main/java/com/getaxonflow/sdk/types/MCPCheckOutputRequest.java @@ -45,6 +45,20 @@ public final class MCPCheckOutputRequest { @JsonProperty("row_count") private final int rowCount; + /** + * The specific tool/action name being invoked on the MCP server (e.g., "query", "search_docs"). + * Distinct from {@code connectorType}, which identifies the MCP server/connector itself. + * Optional; null when the caller doesn't distinguish per-tool identity from the connector. + * + *

NOTE: unlike the input plane, the platform's check-output schema has NO {@code tool} field + * on any released version yet — #2904 added it input-side only, and check-output support is + * tracked by #2955 (targeted for v9.11.0). Sending it here is forward-compatible and harmless + * (the agent ignores unknown keys), but it is not consumed server-side on any platform today. + * Source of truth: {@code platform/agent} {@code MCPCheckInputRequest.Tool} (epic #2905 / #2904). + */ + @JsonProperty("tool") + private final String tool; + /** * Creates a request with connector type and response data only. * @@ -56,7 +70,7 @@ public MCPCheckOutputRequest(String connectorType, List> res } /** - * Creates a request with all fields. + * Creates a request with all fields. Tool is left null. * * @param connectorType the MCP connector type (e.g., "postgres") * @param responseData the response data rows to validate @@ -70,11 +84,33 @@ public MCPCheckOutputRequest( String message, Map metadata, int rowCount) { + this(connectorType, responseData, message, metadata, rowCount, null); + } + + /** + * Creates a request with all fields, including the specific tool name. + * + * @param connectorType the MCP connector type/server (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 + * @param tool the specific tool/action name (e.g., "query"); null when not distinguished from + * {@code connectorType} + */ + public MCPCheckOutputRequest( + String connectorType, + List> responseData, + String message, + Map metadata, + int rowCount, + String tool) { this.connectorType = connectorType; this.responseData = responseData; this.message = message; this.metadata = metadata; this.rowCount = rowCount; + this.tool = tool; } public String getConnectorType() { @@ -97,6 +133,14 @@ public int getRowCount() { return rowCount; } + /** + * Returns the specific tool/action name being invoked, or null when not distinguished from + * {@code connectorType}. + */ + public String getTool() { + return tool; + } + @Override public boolean equals(Object o) { if (this == o) return true; @@ -106,12 +150,13 @@ public boolean equals(Object o) { && Objects.equals(connectorType, that.connectorType) && Objects.equals(responseData, that.responseData) && Objects.equals(message, that.message) - && Objects.equals(metadata, that.metadata); + && Objects.equals(metadata, that.metadata) + && Objects.equals(tool, that.tool); } @Override public int hashCode() { - return Objects.hash(connectorType, responseData, message, metadata, rowCount); + return Objects.hash(connectorType, responseData, message, metadata, rowCount, tool); } @Override diff --git a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java index 226b241..2c23113 100644 --- a/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java +++ b/src/test/java/com/getaxonflow/sdk/AxonFlowTest.java @@ -2175,6 +2175,33 @@ void mcpCheckInputWithOptionsShouldSendOperationAndParameters() { .withRequestBody(containing("\"operation\":\"execute\""))); } + @Test + @DisplayName("mcpCheckInput with a \"tool\" option should send connector_type and tool as " + + "separate fields") + void mcpCheckInputWithToolOptionShouldSendConnectorTypeAndToolSeparately() { + 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", "tool", "mytool"); + MCPCheckInputResponse response = + axonflow.mcpCheckInput("myserver", "myserver.mytool({})", options); + + assertThat(response.isAllowed()).isTrue(); + + verify( + postRequestedFor(urlEqualTo("/api/v1/mcp/check-input")) + .withRequestBody(containing("\"connector_type\":\"myserver\"")) + .withRequestBody(containing("\"tool\":\"mytool\""))); + } + @Test @DisplayName("mcpCheckInput should handle 403 as blocked result") void mcpCheckInputShouldHandle403AsBlockedResult() { @@ -2311,6 +2338,32 @@ void mcpCheckOutputWithOptionsShouldSendOptions() { .withRequestBody(containing("\"row_count\":1"))); } + @Test + @DisplayName("mcpCheckOutput with a \"tool\" option should send connector_type and tool as " + + "separate fields") + void mcpCheckOutputWithToolOptionShouldSendConnectorTypeAndToolSeparately() { + 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}}"))); + + Map options = Map.of("message", "ok", "tool", "mytool"); + MCPCheckOutputResponse response = axonflow.mcpCheckOutput("myserver", null, options); + + assertThat(response.isAllowed()).isTrue(); + + verify( + postRequestedFor(urlEqualTo("/api/v1/mcp/check-output")) + .withRequestBody(containing("\"connector_type\":\"myserver\"")) + .withRequestBody(containing("\"tool\":\"mytool\""))); + } + @Test @DisplayName("mcpCheckOutput should handle 403 as blocked result") void mcpCheckOutputShouldHandle403AsBlockedResult() { diff --git a/src/test/java/com/getaxonflow/sdk/adapters/LangGraphAdapterTest.java b/src/test/java/com/getaxonflow/sdk/adapters/LangGraphAdapterTest.java index 1963ad4..fcd1de0 100644 --- a/src/test/java/com/getaxonflow/sdk/adapters/LangGraphAdapterTest.java +++ b/src/test/java/com/getaxonflow/sdk/adapters/LangGraphAdapterTest.java @@ -874,7 +874,7 @@ void shouldReturnRedactedData() throws Exception { } @Test - @DisplayName("should use default connector type serverName.toolName") + @DisplayName("should use serverName as connectorType and name as tool, not concatenated") void shouldUseDefaultConnectorType() throws Exception { MCPCheckInputResponse inputOk = new MCPCheckInputResponse(true, null, 1, null); MCPCheckOutputResponse outputOk = new MCPCheckOutputResponse(true, null, null, 1, null, null); @@ -887,8 +887,16 @@ void shouldUseDefaultConnectorType() throws Exception { interceptor.intercept(request, req -> "ok"); - verify(client).mcpCheckInput(eq("myserver.mytool"), anyString(), any()); - verify(client).mcpCheckOutput(eq("myserver.mytool"), isNull(), any()); + // connectorType is the bare MCP server name -- NOT "myserver.mytool" -- and the tool + // name is threaded separately via the "tool" option, so policies can match on server + // identity, tool identity, or both instead of a single opaque concatenated string. + ArgumentCaptor> inputOptsCaptor = ArgumentCaptor.forClass(Map.class); + verify(client).mcpCheckInput(eq("myserver"), anyString(), inputOptsCaptor.capture()); + assertThat(inputOptsCaptor.getValue()).containsEntry("tool", "mytool"); + + ArgumentCaptor> outputOptsCaptor = ArgumentCaptor.forClass(Map.class); + verify(client).mcpCheckOutput(eq("myserver"), isNull(), outputOptsCaptor.capture()); + assertThat(outputOptsCaptor.getValue()).containsEntry("tool", "mytool"); } @Test @@ -914,6 +922,62 @@ void shouldUseCustomConnectorTypeFn() throws Exception { ArgumentCaptor> inputOptsCaptor = ArgumentCaptor.forClass(Map.class); verify(client).mcpCheckInput(eq("custom-pg"), anyString(), inputOptsCaptor.capture()); assertThat(inputOptsCaptor.getValue()).containsEntry("operation", "query"); + // Even with a custom connectorTypeFn, the tool name still defaults to + // MCPToolRequest#getName() and is threaded separately. + assertThat(inputOptsCaptor.getValue()).containsEntry("tool", "read"); + } + + @Test + @DisplayName("tool name is always getName(), never a caller override (RULING 3)") + void toolNameIsAlwaysRequestName() throws Exception { + MCPCheckInputResponse inputOk = new MCPCheckInputResponse(true, null, 1, null); + MCPCheckOutputResponse outputOk = new MCPCheckOutputResponse(true, null, null, 1, null, null); + + when(client.mcpCheckInput(anyString(), anyString(), any())).thenReturn(inputOk); + when(client.mcpCheckOutput(anyString(), isNull(), any())).thenReturn(outputOk); + + // A custom connectorTypeFn overrides only the connector/server dimension; + // the tool identity is always MCPToolRequest#getName() — there is no + // toolFn escape hatch that could write an arbitrary tool identity into + // the audit trail (epic #2905, RULING 3). + MCPInterceptorOptions opts = + MCPInterceptorOptions.builder().connectorTypeFn(req -> "renamed-server").build(); + + MCPToolInterceptor interceptor = adapter.mcpToolInterceptor(opts); + MCPToolRequest request = new MCPToolRequest("pg", "read", null); + + interceptor.intercept(request, req -> "data"); + + ArgumentCaptor> inputOptsCaptor = ArgumentCaptor.forClass(Map.class); + verify(client).mcpCheckInput(eq("renamed-server"), anyString(), inputOptsCaptor.capture()); + assertThat(inputOptsCaptor.getValue()).containsEntry("tool", "read"); + } + + @Test + @DisplayName("empty server name sends empty connector_type with the tool name (missing-server)") + void emptyServerNameSendsEmptyConnectorTypeAndTool() throws Exception { + // Missing-server edge (epic #2905): with the default resolver, + // connector_type is the server name, so an empty serverName sends + // connector_type="" while the tool name still travels in the "tool" + // option. A real platform rejects an empty connector_type with HTTP 400, + // which surfaces as an exception — the tool call is blocked (fail-closed) + // and the handler never runs. Before the de-concatenation the value was + // ".tool" (a non-empty string the platform accepted), so this is a + // deliberate, surfaced change for server-less tools. + MCPCheckInputResponse inputOk = new MCPCheckInputResponse(true, null, 1, null); + MCPCheckOutputResponse outputOk = new MCPCheckOutputResponse(true, null, null, 1, null, null); + + when(client.mcpCheckInput(anyString(), anyString(), any())).thenReturn(inputOk); + when(client.mcpCheckOutput(anyString(), isNull(), any())).thenReturn(outputOk); + + MCPToolInterceptor interceptor = adapter.mcpToolInterceptor(); + MCPToolRequest request = new MCPToolRequest("", "tool", null); + + interceptor.intercept(request, req -> "data"); + + ArgumentCaptor> inputOptsCaptor = ArgumentCaptor.forClass(Map.class); + verify(client).mcpCheckInput(eq(""), anyString(), inputOptsCaptor.capture()); + assertThat(inputOptsCaptor.getValue()).containsEntry("tool", "tool"); } @Test diff --git a/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java b/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java index 1a6af0f..8117667 100644 --- a/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java +++ b/src/test/java/com/getaxonflow/sdk/types/MoreTypesTest.java @@ -1640,6 +1640,39 @@ void shouldHaveToString() { assertThat(request.toString()).contains("MCPCheckInputRequest"); assertThat(request.toString()).contains("postgres"); } + + @Test + @DisplayName("should carry connector type and tool as separate fields, not concatenated") + void shouldCarryConnectorTypeAndToolSeparately() { + MCPCheckInputRequest request = + new MCPCheckInputRequest( + "myserver", "myserver.mytool({})", Map.of("timeout", 30), "execute", null, "mytool"); + + assertThat(request.getConnectorType()).isEqualTo("myserver"); + assertThat(request.getTool()).isEqualTo("mytool"); + } + + @Test + @DisplayName("should default tool to null and omit it from JSON when not supplied") + void shouldDefaultToolToNullAndOmitFromJson() throws Exception { + MCPCheckInputRequest request = new MCPCheckInputRequest("postgres", "SELECT 1"); + + assertThat(request.getTool()).isNull(); + String json = objectMapper.writeValueAsString(request); + assertThat(json).doesNotContain("\"tool\""); + } + + @Test + @DisplayName("should serialize tool field to JSON when present") + void shouldSerializeToolToJson() throws Exception { + MCPCheckInputRequest request = + new MCPCheckInputRequest("myserver", "SELECT 1", null, "execute", null, "mytool"); + + String json = objectMapper.writeValueAsString(request); + + assertThat(json).contains("\"connector_type\":\"myserver\""); + assertThat(json).contains("\"tool\":\"mytool\""); + } } @Nested @@ -1810,6 +1843,41 @@ void shouldHaveToString() { assertThat(request.toString()).contains("MCPCheckOutputRequest"); assertThat(request.toString()).contains("postgres"); } + + @Test + @DisplayName("should carry connector type and tool as separate fields, not concatenated") + void shouldCarryConnectorTypeAndToolSeparately() { + List> data = List.of(Map.of("id", 1)); + MCPCheckOutputRequest request = + new MCPCheckOutputRequest("myserver", data, "done", null, 1, "mytool"); + + assertThat(request.getConnectorType()).isEqualTo("myserver"); + assertThat(request.getTool()).isEqualTo("mytool"); + } + + @Test + @DisplayName("should default tool to null and omit it from JSON when not supplied") + void shouldDefaultToolToNullAndOmitFromJson() throws Exception { + List> data = List.of(Map.of("id", 1)); + MCPCheckOutputRequest request = new MCPCheckOutputRequest("postgres", data); + + assertThat(request.getTool()).isNull(); + String json = objectMapper.writeValueAsString(request); + assertThat(json).doesNotContain("\"tool\""); + } + + @Test + @DisplayName("should serialize tool field to JSON when present") + void shouldSerializeToolToJson() throws Exception { + List> data = List.of(Map.of("id", 1)); + MCPCheckOutputRequest request = + new MCPCheckOutputRequest("myserver", data, null, null, 0, "mytool"); + + String json = objectMapper.writeValueAsString(request); + + assertThat(json).contains("\"connector_type\":\"myserver\""); + assertThat(json).contains("\"tool\":\"mytool\""); + } } @Nested diff --git a/tests/fixtures/wire-shape-baseline.json b/tests/fixtures/wire-shape-baseline.json index 70e0417..d09fe4c 100644 --- a/tests/fixtures/wire-shape-baseline.json +++ b/tests/fixtures/wire-shape-baseline.json @@ -373,7 +373,8 @@ }, "MCPCheckInputRequest": { "sdk_only": [ - "content_type" + "content_type", + "tool" ], "spec_only": [ "client_id", @@ -392,7 +393,9 @@ "spec_only": [] }, "MCPCheckOutputRequest": { - "sdk_only": [], + "sdk_only": [ + "tool" + ], "spec_only": [ "client_id", "tenant_id",