Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
59 changes: 51 additions & 8 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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)

Expand Down
2 changes: 1 addition & 1 deletion pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

<groupId>com.getaxonflow</groupId>
<artifactId>axonflow-sdk</artifactId>
<version>8.5.1</version>
<version>9.0.0</version>
<packaging>jar</packaging>

<name>AxonFlow Java SDK</name>
Expand Down
120 changes: 120 additions & 0 deletions runtime-e2e/mcp_server_tool_split/McpServerToolSplitTest.java
Original file line number Diff line number Diff line change
@@ -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)");
}
}
31 changes: 31 additions & 0 deletions runtime-e2e/mcp_server_tool_split/README.md
Original file line number Diff line number Diff line change
@@ -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.
18 changes: 14 additions & 4 deletions src/main/java/com/getaxonflow/sdk/AxonFlow.java
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand All @@ -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);
}
Expand Down Expand Up @@ -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)
*/
Expand All @@ -2844,9 +2849,14 @@ public MCPCheckOutputResponse mcpCheckOutput(
Map<String, Object> metadata =
options != null ? (Map<String, Object>) 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)) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
* <p>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
*/
Expand Down Expand Up @@ -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()}.
*
* <p>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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object> inputOptions = new HashMap<>();
inputOptions.put("operation", operation);
inputOptions.put("tool", tool);
if (request.getArgs() != null && !request.getArgs().isEmpty()) {
inputOptions.put("parameters", request.getArgs());
}
Expand All @@ -111,6 +117,7 @@ public Object intercept(MCPToolRequest request, MCPToolHandler handler) throws E

Map<String, Object> outputOptions = new HashMap<>();
outputOptions.put("message", resultStr);
outputOptions.put("tool", tool);

MCPCheckOutputResponse outputCheck = client.mcpCheckOutput(connectorType, null, outputOptions);
if (!outputCheck.isAllowed()) {
Expand Down
Loading
Loading