From ad99e32dad48fd245551425922f8e3cc6e77bb06 Mon Sep 17 00:00:00 2001 From: Gregory Zak Date: Thu, 16 Jul 2026 15:24:00 -0700 Subject: [PATCH 1/4] fix(langgraph): stop concatenating server/tool into connector_type MCPCheckInputRequest/MCPCheckOutputRequest now carry a genuine two-field (server, tool) identity contract (epic #2905, platform sub-issue #2904). Client.mcp_check_input/mcp_check_output gain a `tool` parameter sent as its own wire field instead of being folded into `connector_type`. mcp_tool_interceptor's default connector-type resolution now sends connector_type=request.server_name and tool=request.name as two separate values instead of the dotted "server.tool" string, and this holds even when a caller supplies a custom connector_type_fn. In tool_output_wrapper, ToolNode's call_request carries no server/origin info to improve connector_type's default beyond the bare tool name, but the tool name itself is now always forwarded via the new `tool` field rather than only living inside connector_type. Signed-off-by: Gregory Zak --- CHANGELOG.md | 13 ++ axonflow/adapters/langgraph.py | 33 +++- axonflow/client.py | 25 +++ axonflow/types.py | 13 ++ .../langgraph_server_tool_split/test.py | 187 ++++++++++++++++++ tests/fixtures/wire_shape_baseline.json | 12 +- tests/test_client.py | 92 +++++++++ tests/test_langgraph_adapter.py | 62 +++++- 8 files changed, 425 insertions(+), 12 deletions(-) create mode 100644 runtime-e2e/langgraph_server_tool_split/test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index d930cae..a0fcf57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 and tag v{X.Y.Z}. The release workflow's preflight checks the section header matches the tag. --> +## [Unreleased] + +### Fixed + +- **LangGraph `mcp_tool_interceptor` no longer concatenates the MCP server + name and tool name into a single `connector_type` string.** `mcp_check_input`/ + `mcp_check_output` (and their `check_tool_input`/`check_tool_output` aliases) + now accept an optional `tool` parameter, sent alongside `connector_type` on + the wire, matching the platform's two-field (server, tool) identity contract + (epic #2905 / #2904). The interceptor sends `connector_type=request.server_name` + and `tool=request.name` as two distinct values instead of + `f"{server_name}.{name}"`. + ## [8.5.1] - 2026-07-09 — Interceptor sync bridge + async-client detection + example fixes Hostile-testing sweep ahead of the BukuWarung integration diff --git a/axonflow/adapters/langgraph.py b/axonflow/adapters/langgraph.py index 6868ad2..7ed135d 100644 --- a/axonflow/adapters/langgraph.py +++ b/axonflow/adapters/langgraph.py @@ -93,7 +93,10 @@ class MCPInterceptorOptions: Attributes: connector_type_fn: Optional callable that maps an MCP request to a - connector type string. Defaults to ``"{server_name}.{tool_name}"``. + connector type string. Defaults to the request's ``server_name``. + The tool name is always sent separately as the ``tool`` field + (see :meth:`~axonflow.client.Client.mcp_check_input`) — it is no + longer folded into ``connector_type``. operation: Operation type passed to ``mcp_check_input``. Defaults to ``"execute"``. Set to ``"query"`` for known read-only tool calls. """ @@ -522,11 +525,12 @@ def mcp_tool_interceptor( ... tool_interceptors=[adapter.mcp_tool_interceptor()], ... ) - With custom options: + With custom options (the tool name is always sent separately as + ``tool`` — ``connector_type_fn`` only controls ``connector_type``): Example: >>> opts = MCPInterceptorOptions( - ... connector_type_fn=lambda req: req.server_name, + ... connector_type_fn=lambda req: f"prod.{req.server_name}", ... operation="query", ... ) >>> tool_interceptors=[adapter.mcp_tool_interceptor(opts)] @@ -564,17 +568,22 @@ def mcp_tool_interceptor( opts = options or MCPInterceptorOptions() def _default_connector_type(request: Any) -> str: - return f"{request.server_name}.{request.name}" + return str(request.server_name) resolve_connector_type = opts.connector_type_fn or _default_connector_type async def _interceptor(request: Any, handler: Callable[..., Any]) -> Any: connector_type = resolve_connector_type(request) + # The tool name is a genuine second identity field (epic #2905 / + # #2904) — send it as `tool`, never fold it back into + # `connector_type`, even when connector_type_fn is customized. + tool = request.name args_str = json.dumps(request.args, default=str) if request.args else "{}" - statement = f"{connector_type}({args_str})" + statement = f"{connector_type}.{tool}({args_str})" pre_check = await self.client.mcp_check_input( connector_type=connector_type, + tool=tool, statement=statement, operation=opts.operation, parameters=request.args, @@ -590,6 +599,7 @@ async def _interceptor(request: Any, handler: Callable[..., Any]) -> Any: result_str = str(result) output_check = await self.client.mcp_check_output( connector_type=connector_type, + tool=tool, message=result_str, ) if not output_check.allowed: @@ -620,11 +630,12 @@ def tool_output_wrapper( >>> wrapper = adapter.tool_output_wrapper() >>> tool_node = ToolNode(tools, awrap_tool_call=wrapper) - With custom options: + With custom options (the tool name is always sent separately as + ``tool`` — ``connector_type_fn`` only controls ``connector_type``): Example: >>> opts = MCPInterceptorOptions( - ... connector_type_fn=lambda call: f"local.{call['name']}", + ... connector_type_fn=lambda call: "local", ... ) >>> tool_node = ToolNode(tools, awrap_tool_call=adapter.tool_output_wrapper(opts)) @@ -648,6 +659,11 @@ def tool_output_wrapper( opts = options or MCPInterceptorOptions() def _default_connector_type(call_request: dict[str, Any]) -> str: + # ToolNode's call_request carries no server/origin info to + # distinguish from the bare tool name — this is the best + # available default. The tool name itself is never dropped: it + # is always sent separately as `tool` below (epic #2905 / + # #2904), even when it also ends up equal to connector_type here. name: str = call_request.get("name", "unknown_tool") return name @@ -655,11 +671,13 @@ def _default_connector_type(call_request: dict[str, Any]) -> str: async def _wrapper(call_request: dict[str, Any], execute: Callable[..., Any]) -> Any: connector_type = resolve_connector_type(call_request) + tool: str = call_request.get("name", "unknown_tool") args = call_request.get("args", {}) statement = json.dumps(args, default=str) input_check = await self.client.mcp_check_input( connector_type=connector_type, + tool=tool, statement=statement, operation=opts.operation, ) @@ -685,6 +703,7 @@ async def _wrapper(call_request: dict[str, Any], execute: Callable[..., Any]) -> output_check = await self.client.mcp_check_output( connector_type=connector_type, + tool=tool, message=serialized, ) if not output_check.allowed: diff --git a/axonflow/client.py b/axonflow/client.py index c138b01..6824cee 100644 --- a/axonflow/client.py +++ b/axonflow/client.py @@ -1438,6 +1438,7 @@ async def mcp_check_input( operation: str = "execute", parameters: dict[str, Any] | None = None, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -1455,6 +1456,10 @@ async def mcp_check_input( statement: The SQL query or command to validate. operation: Operation type - "query" or "execute" (default). parameters: Optional query parameters. + tool: Optional tool name, distinct from ``connector_type``. Lets a + PEP report the (server, tool) identity as two separate values + instead of concatenating them into ``connector_type`` + (epic #2905 / #2904). client_id: Client identifier (overrides client config when set). tenant_id: Tenant identifier for multi-tenant scoping. user_id: End-user identifier for per-user policies. @@ -1481,6 +1486,8 @@ async def mcp_check_input( } if parameters: body["parameters"] = parameters + if tool: + body["tool"] = tool # Wire-canonical scoping fields surfaced in the v6 sweep. if client_id is not None: body["client_id"] = client_id @@ -1518,6 +1525,7 @@ async def check_tool_input( operation: str = "execute", parameters: dict[str, Any] | None = None, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -1530,6 +1538,7 @@ async def check_tool_input( statement, operation, parameters, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -1545,6 +1554,7 @@ async def mcp_check_output( metadata: dict[str, Any] | None = None, row_count: int = 0, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -1561,6 +1571,9 @@ async def mcp_check_output( message: Execute-style response message (e.g., "5 rows affected"). metadata: Connector metadata for SQLi scanning. row_count: Total number of rows returned. + tool: Optional tool name, distinct from ``connector_type``. Mirrors + the ``tool`` parameter on :meth:`mcp_check_input` (epic #2905 / + #2904). client_id: Client identifier (overrides client config when set). tenant_id: Tenant identifier for multi-tenant scoping. user_id: End-user identifier for per-user policies. @@ -1584,6 +1597,8 @@ async def mcp_check_output( body["metadata"] = metadata if row_count > 0: body["row_count"] = row_count + if tool: + body["tool"] = tool # Wire-canonical scoping fields surfaced in the v6 sweep. if client_id is not None: body["client_id"] = client_id @@ -1618,6 +1633,7 @@ async def check_tool_output( metadata: dict[str, Any] | None = None, row_count: int = 0, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -1630,6 +1646,7 @@ async def check_tool_output( message, metadata, row_count, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -7549,6 +7566,7 @@ def mcp_check_input( operation: str = "execute", parameters: dict[str, Any] | None = None, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -7563,6 +7581,7 @@ def mcp_check_input( statement, operation, parameters, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -7612,6 +7631,7 @@ def mcp_check_output( metadata: dict[str, Any] | None = None, row_count: int = 0, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -7625,6 +7645,7 @@ def mcp_check_output( message, metadata, row_count, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -7639,6 +7660,7 @@ def check_tool_input( operation: str = "execute", parameters: dict[str, Any] | None = None, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -7652,6 +7674,7 @@ def check_tool_input( statement, operation, parameters, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, @@ -7668,6 +7691,7 @@ def check_tool_output( metadata: dict[str, Any] | None = None, row_count: int = 0, *, + tool: str | None = None, client_id: str | None = None, tenant_id: str | None = None, user_id: str | None = None, @@ -7681,6 +7705,7 @@ def check_tool_output( message, metadata, row_count, + tool=tool, client_id=client_id, tenant_id=tenant_id, user_id=user_id, diff --git a/axonflow/types.py b/axonflow/types.py index 8e1d249..7864029 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -407,6 +407,12 @@ class MCPCheckInputRequest(BaseModel): """Request to validate input against MCP policies.""" connector_type: str + # Two-field (server, tool) identity contract (epic #2905 / #2904). `tool` + # carries the MCP tool name so a PEP no longer has to concatenate it into + # `connector_type` (e.g. "server.tool") to preserve tool identity. + # Source of truth: platform/agent MCPCheckInputRequest (epic #2905, #2904, + # merged to axonflow-enterprise main and live on the request-input plane). + tool: str | None = Field(default=None) statement: str parameters: dict[str, Any] | None = Field(default=None) operation: str = Field(default="execute") @@ -462,6 +468,13 @@ class MCPCheckOutputRequest(BaseModel): """Request to validate output against MCP policies.""" connector_type: str + # Two-field (server, tool) identity contract, mirrored from + # MCPCheckInputRequest.tool (epic #2905 / #2904). Note: unlike the + # input-phase field, the platform's MCPCheckOutputRequest has no matching + # `tool` field yet — sending this is forward-compatible and harmless (the + # agent's JSON decoder silently ignores unrecognized keys), but it is not + # yet consumed server-side. + tool: str | None = Field(default=None) response_data: list[dict[str, Any]] | None = Field(default=None) message: str | None = Field(default=None) metadata: dict[str, Any] | None = Field(default=None) diff --git a/runtime-e2e/langgraph_server_tool_split/test.py b/runtime-e2e/langgraph_server_tool_split/test.py new file mode 100644 index 0000000..32ab2f7 --- /dev/null +++ b/runtime-e2e/langgraph_server_tool_split/test.py @@ -0,0 +1,187 @@ +"""Real-stack assertion: LangGraph MCP interceptor sends server+tool as two +distinct wire fields instead of concatenating them (#2906, epic #2905/#2904). + +Per CLAUDE.md HARD RULE #0 this test MUST hit a real running AxonFlow agent — +no mocks. ``httpx.AsyncClient.request`` is wrapped only to OBSERVE the +outbound wire body (the real POST to the real agent still happens and its +response is asserted on); this mirrors the capture pattern already used in +``runtime-e2e/x-client-id/test.py``. + +Regression background: before this fix, ``mcp_tool_interceptor`` sent a +single ``connector_type`` string built by hand-concatenating the MCP +server name and tool name (e.g. ``"orders-server.list_orders"``), so a PEP +could not distinguish "which server" from "which tool" without parsing a +string. ``AxonFlowLangGraphAdapter.mcp_tool_interceptor`` and +``tool_output_wrapper`` now call ``client.mcp_check_input`` / +``mcp_check_output`` with ``connector_type=`` and +``tool=`` as two separate wire fields, matching the platform's +two-field (server, tool) identity contract. + +Assertions against a live agent: + + 1. ``mcp_tool_interceptor()`` — driven with a real (stand-in) MCP + ``CallToolRequest``-shaped object exposing ``server_name``/``name``/ + ``args`` — sends ``connector_type="orders-server"`` AND + ``tool="list_orders"`` as two distinct fields on BOTH the + check-input and check-output wire calls (not a folded + ``"orders-server.list_orders"`` string), and the live agent allows + the call. + 2. Backward compatibility: calling ``client.mcp_check_input`` the OLD + way — a single ``connector_type``, no ``tool`` argument at all — + still works against the live agent (200, allowed) and the wire body + omits the ``tool`` key entirely (no silent regression for callers + who haven't adopted the two-field contract yet). + +Run locally: + + AXONFLOW_AGENT_URL=http://localhost:8080 \ + python3 runtime-e2e/langgraph_server_tool_split/test.py +""" + +from __future__ import annotations + +import asyncio +import os +import sys +import warnings +from typing import Any + +import httpx + +from axonflow import AxonFlow +from axonflow.adapters.langgraph import AxonFlowLangGraphAdapter + +AGENT_URL = os.environ.get("AXONFLOW_AGENT_URL", "http://localhost:8080") +CLIENT_ID = os.environ.get("AXONFLOW_CLIENT_ID", "py-sdk-2906-runtime-e2e") + +_orig_request = httpx.AsyncClient.request +_captured: list[dict[str, Any]] = [] + + +async def _patched(self: httpx.AsyncClient, method: str, url: Any, **kw: Any) -> httpx.Response: + resp = await _orig_request(self, method, url, **kw) + if "/mcp/check-" in str(url): + _captured.append({"url": str(url), "json": kw.get("json") or {}}) + return resp + + +httpx.AsyncClient.request = _patched # type: ignore[method-assign] + +failures: list[str] = [] + + +def check(name: str, ok: bool, detail: str = "") -> None: + print(f"{'PASS' if ok else 'FAIL'} [{name}] {detail}") + if not ok: + failures.append(name) + + +class _FakeCallToolRequest: + """Stand-in for langchain-mcp-adapters' CallToolRequest. + + ``mcp_tool_interceptor`` only reads ``server_name``, ``name``, and + ``args`` off the request object, so a minimal stand-in exercises the + real interceptor code path (connector_type/tool resolution + the two + real HTTP calls to the live agent) without needing a full + MultiServerMCPClient + live MCP server stack. + """ + + def __init__(self, server_name: str, name: str, args: dict[str, Any]) -> None: + self.server_name = server_name + self.name = name + self.args = args + + +async def _fake_handler(_request: _FakeCallToolRequest) -> dict[str, Any]: + # Stand-in for the real tool execution the interceptor wraps. Returning + # a plain dict exercises the interceptor's own JSON-serialize-then- + # check-output path against the live agent exactly as it would for a + # real MCP tool result. + return {"orders": [{"id": 1, "status": "shipped"}]} + + +async def main() -> None: + client = AxonFlow(endpoint=AGENT_URL, client_id=CLIENT_ID) + adapter = AxonFlowLangGraphAdapter(client, "runtime-e2e-2906-workflow") + + async with client: + # 1. mcp_tool_interceptor: connector_type (server) and tool (tool + # name) must travel as two distinct wire fields. + with warnings.catch_warnings(): + warnings.simplefilter("ignore", DeprecationWarning) + interceptor = adapter.mcp_tool_interceptor() + + request = _FakeCallToolRequest( + server_name="orders-server", + name="list_orders", + args={"customer_id": "cust-42"}, + ) + + _captured.clear() + result = await interceptor(request, _fake_handler) + + check( + "interceptor-produced-a-result", + result == {"orders": [{"id": 1, "status": "shipped"}]}, + f"result={result!r}", + ) + check( + "interceptor-made-two-live-calls", + len(_captured) == 2, + f"observed {len(_captured)} check-input/check-output calls: " + f"{[c['url'] for c in _captured]}", + ) + + for phase, call in zip(("check-input", "check-output"), _captured, strict=False): + body = call["json"] + check( + f"{phase}-connector-type-is-server-name", + body.get("connector_type") == "orders-server", + f"connector_type={body.get('connector_type')!r}", + ) + check( + f"{phase}-tool-field-is-tool-name", + body.get("tool") == "list_orders", + f"tool={body.get('tool')!r}", + ) + check( + f"{phase}-not-folded-into-single-string", + body.get("connector_type") != "orders-server.list_orders", + f"connector_type={body.get('connector_type')!r} (must not be the old " + f"concatenated shape)", + ) + + # 2. Backward compat: the OLD single-connector_type call (no `tool` + # kwarg at all) must still work unchanged against the live agent, + # and must NOT gain a `tool` key on the wire. + _captured.clear() + legacy_check = await client.mcp_check_input( + connector_type="orders-server", + statement="list_orders(customer_id=cust-42)", + operation="execute", + ) + check( + "legacy-call-agent-allowed", + legacy_check.allowed, + f"allowed={legacy_check.allowed} policies_evaluated={legacy_check.policies_evaluated}", + ) + legacy_body = _captured[-1]["json"] if _captured else {} + check( + "legacy-call-connector-type-unchanged", + legacy_body.get("connector_type") == "orders-server", + f"connector_type={legacy_body.get('connector_type')!r}", + ) + check( + "legacy-call-omits-tool-field", + "tool" not in legacy_body, + f"body={legacy_body}", + ) + + if failures: + print(f"RESULT: FAIL ({len(failures)}): {failures}") + sys.exit(1) + print("RESULT: PASS (11/11)") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/fixtures/wire_shape_baseline.json b/tests/fixtures/wire_shape_baseline.json index 7d592a2..f349055 100644 --- a/tests/fixtures/wire_shape_baseline.json +++ b/tests/fixtures/wire_shape_baseline.json @@ -297,9 +297,10 @@ "spec_only": [] }, "MCPCheckInputRequest": { - "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `content_type` (request-redaction detector selector, ADR-056); the agent consumes it on POST /api/v1/mcp/check-input but agent-api.yaml doesn't yet declare it.", + "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `content_type` (request-redaction detector selector, ADR-056); the agent consumes it on POST /api/v1/mcp/check-input but agent-api.yaml doesn't yet declare it. Also declares `tool` (epic #2905, platform sub-issue #2904) \u2014 the two-field (server, tool) identity contract; #2904 is merged to axonflow-enterprise main and live server-side, this drift just tracks the OpenAPI spec pin (pinned to a community mirror commit that predates the merge) catching up.", "sdk_only": [ - "content_type" + "content_type", + "tool" ], "spec_only": [] }, @@ -312,6 +313,13 @@ ], "spec_only": [] }, + "MCPCheckOutputRequest": { + "note": "acknowledged-sdk-superset: tracked in epic #2905, platform sub-issue #2904 \u2014 SDK declares `tool`, mirroring MCPCheckInputRequest.tool for the two-field (server, tool) identity contract. Unlike the input-phase field, the platform's MCPCheckOutputRequest has no matching `tool` field at all yet (#2904 only added it input-side) \u2014 sending this is forward-compatible and harmless but not yet consumed server-side.", + "sdk_only": [ + "tool" + ], + "spec_only": [] + }, "MCPCheckOutputResponse": { "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `redaction_evaluated` for response-phase obligation fail-closed parity with check-input (ADR-056); platform predates the field on check-output, so it defaults False.", "sdk_only": [ diff --git a/tests/test_client.py b/tests/test_client.py index 334bec9..461914c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from typing import Any import httpx @@ -1480,3 +1481,94 @@ async def test_mcp_execute( assert result.success is True assert result.policy_info is not None assert result.policy_info.policies_evaluated == 3 + + +class TestCheckToolAliases: + """check_tool_input/check_tool_output (epic #2905 / #2904): these are + documented aliases for mcp_check_input/mcp_check_output with "identical + parameters" (axonflow-docs/docs/mcp/policy-enforcement.md) — the `tool` + parameter must forward through both alias pairs, not just the methods + they alias. + """ + + @pytest.mark.asyncio + async def test_check_tool_input_forwards_tool( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + httpx_mock.add_response( + url="https://test.axonflow.com/api/v1/mcp/check-input", + method="POST", + json={"allowed": True}, + ) + + await client.check_tool_input( + "postgres", + "SELECT 1", + tool="query", + ) + + request = httpx_mock.get_requests()[0] + sent_body = json.loads(request.content) + assert sent_body["connector_type"] == "postgres" + assert sent_body["tool"] == "query" + + @pytest.mark.asyncio + async def test_check_tool_output_forwards_tool( + self, + client: AxonFlow, + httpx_mock: HTTPXMock, + ) -> None: + httpx_mock.add_response( + url="https://test.axonflow.com/api/v1/mcp/check-output", + method="POST", + json={"allowed": True}, + ) + + await client.check_tool_output( + "postgres", + message="1 row", + tool="query", + ) + + request = httpx_mock.get_requests()[0] + sent_body = json.loads(request.content) + assert sent_body["connector_type"] == "postgres" + assert sent_body["tool"] == "query" + + def test_sync_check_tool_input_forwards_tool( + self, + sync_client: Any, + httpx_mock: HTTPXMock, + ) -> None: + httpx_mock.add_response( + url="https://test.axonflow.com/api/v1/mcp/check-input", + method="POST", + json={"allowed": True}, + ) + + sync_client.check_tool_input("postgres", "SELECT 1", tool="query") + + request = httpx_mock.get_requests()[0] + sent_body = json.loads(request.content) + assert sent_body["connector_type"] == "postgres" + assert sent_body["tool"] == "query" + + def test_sync_check_tool_output_forwards_tool( + self, + sync_client: Any, + httpx_mock: HTTPXMock, + ) -> None: + httpx_mock.add_response( + url="https://test.axonflow.com/api/v1/mcp/check-output", + method="POST", + json={"allowed": True}, + ) + + sync_client.check_tool_output("postgres", message="1 row", tool="query") + + request = httpx_mock.get_requests()[0] + sent_body = json.loads(request.content) + assert sent_body["connector_type"] == "postgres" + assert sent_body["tool"] == "query" diff --git a/tests/test_langgraph_adapter.py b/tests/test_langgraph_adapter.py index bfcfaf7..bd692c7 100644 --- a/tests/test_langgraph_adapter.py +++ b/tests/test_langgraph_adapter.py @@ -104,14 +104,16 @@ async def test_connector_type_derived_from_request( client.mcp_check_input.assert_awaited_once() call_kwargs = client.mcp_check_input.call_args.kwargs - assert call_kwargs["connector_type"] == "srv.tool" + # server and tool are sent as two separate fields, not concatenated + assert call_kwargs["connector_type"] == "srv" + assert call_kwargs["tool"] == "tool" assert call_kwargs["parameters"] == request.args # Statement uses JSON serialization, not Python repr expected_args = json.dumps(request.args, default=str) assert call_kwargs["statement"] == f"srv.tool({expected_args})" @pytest.mark.asyncio - async def test_same_connector_type_sent_to_check_output( + async def test_same_connector_type_and_tool_sent_to_check_output( self, adapter: AxonFlowLangGraphAdapter, client: AxonFlow ) -> None: handler = AsyncMock(return_value="ok") @@ -120,7 +122,8 @@ async def test_same_connector_type_sent_to_check_output( await adapter.mcp_tool_interceptor()(request, handler) call_kwargs = client.mcp_check_output.call_args.kwargs - assert call_kwargs["connector_type"] == "srv.tool" + assert call_kwargs["connector_type"] == "srv" + assert call_kwargs["tool"] == "tool" @pytest.mark.asyncio async def test_output_message_uses_json_serialization( @@ -241,6 +244,23 @@ async def test_custom_connector_type_fn( call_kwargs = client.mcp_check_input.call_args.kwargs assert call_kwargs["connector_type"] == "srv" + @pytest.mark.asyncio + async def test_custom_connector_type_fn_does_not_drop_tool( + self, adapter: AxonFlowLangGraphAdapter, client: AxonFlow + ) -> None: + """Even with a fully custom connector_type_fn, `tool` is still sent + (derived from request.name) — it is never folded into connector_type + nor silently dropped.""" + handler = AsyncMock(return_value="ok") + request = _make_request(server_name="srv", name="tool") + opts = MCPInterceptorOptions(connector_type_fn=lambda req: "custom-type") + + await adapter.mcp_tool_interceptor(opts)(request, handler) + + call_kwargs = client.mcp_check_input.call_args.kwargs + assert call_kwargs["connector_type"] == "custom-type" + assert call_kwargs["tool"] == "tool" + @pytest.mark.asyncio async def test_custom_connector_type_fn_also_used_for_output_check( self, adapter: AxonFlowLangGraphAdapter, client: AxonFlow @@ -252,6 +272,7 @@ async def test_custom_connector_type_fn_also_used_for_output_check( await adapter.mcp_tool_interceptor(opts)(request, handler) assert client.mcp_check_output.call_args.kwargs["connector_type"] == "custom-type" + assert client.mcp_check_output.call_args.kwargs["tool"] == "tool" # --- factory returns independent callables --- @@ -880,6 +901,41 @@ async def test_connector_type_derived_from_name( assert client.mcp_check_input.call_args.kwargs["connector_type"] == "bankInfo" assert client.mcp_check_output.call_args.kwargs["connector_type"] == "bankInfo" + @pytest.mark.asyncio + async def test_tool_field_sent_alongside_connector_type( + self, adapter: AxonFlowLangGraphAdapter, client: AxonFlow + ) -> None: + """The tool name is always sent as `tool`, in addition to whatever + connector_type resolves to (no server info is available at this + call site, so connector_type falls back to the bare name too — but + the tool identity must never be silently dropped).""" + execute = AsyncMock(return_value=_make_tool_message()) + await adapter.tool_output_wrapper()({"name": "bankInfo", "args": {}, "id": "c1"}, execute) + + assert client.mcp_check_input.call_args.kwargs["tool"] == "bankInfo" + assert client.mcp_check_output.call_args.kwargs["tool"] == "bankInfo" + + @pytest.mark.asyncio + async def test_tool_field_preserved_with_custom_connector_type_fn( + self, adapter: AxonFlowLangGraphAdapter, client: AxonFlow + ) -> None: + """A custom connector_type_fn only overrides connector_type — the + tool name is still forwarded separately, not dropped.""" + opts = MCPInterceptorOptions(connector_type_fn=lambda call: "local") + execute = AsyncMock(return_value=_make_tool_message()) + + await adapter.tool_output_wrapper(opts)( + {"name": "my_tool", "args": {}, "id": "c1"}, execute + ) + + input_kwargs = client.mcp_check_input.call_args.kwargs + assert input_kwargs["connector_type"] == "local" + assert input_kwargs["tool"] == "my_tool" + + output_kwargs = client.mcp_check_output.call_args.kwargs + assert output_kwargs["connector_type"] == "local" + assert output_kwargs["tool"] == "my_tool" + @pytest.mark.asyncio async def test_args_serialized_as_statement_for_input_check( self, adapter: AxonFlowLangGraphAdapter, client: AxonFlow From d19eec545507d4498364bab244265dadf985e403 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 17 Jul 2026 22:55:53 +0200 Subject: [PATCH 2/4] fix(langgraph)!: de-concatenate server/tool as breaking major (epic #2905) Mark the LangGraph server/tool de-concatenation as a BREAKING change (the connector_type wire value changes from "{server}.{tool}" to the bare server name) and document the migration: - Changelog: move to "Changed (BREAKING)", require a major SDK bump, add the policy re-scope note (connector_type_fn is the compat lever), the custom-fn statement-shape change, the missing-server fail-closed edge, and the platform-version pins (tool consumed on check-input at v9.10.0+; check-output tracked by #2955). - Add the missing-server edge test: empty server_name -> connector_type="" -> platform 400 -> ConnectorError (fail-closed); document + pin the wire. - Reconcile the shared client.py/types.py/wire_shape_baseline.json hunks with PR #217 to byte-identical, corrected text (#2916/c8df2006b merged, first released in v9.10.0); thread `tool` through the check_tool_input/ check_tool_output aliases too. Declare the #216-before-#217 merge order. - Regenerate .lint_baselines/falsey_clobber.json (line-shift only; CI green). Refs #2906, epic #2905. Signed-off-by: Saurabh Jain --- .lint_baselines/falsey_clobber.json | 32 ++++++------ CHANGELOG.md | 66 +++++++++++++++++++++---- axonflow/types.py | 15 +++--- tests/fixtures/wire_shape_baseline.json | 4 +- tests/test_langgraph_adapter.py | 26 ++++++++++ 5 files changed, 108 insertions(+), 35 deletions(-) diff --git a/.lint_baselines/falsey_clobber.json b/.lint_baselines/falsey_clobber.json index c710deb..6317360 100644 --- a/.lint_baselines/falsey_clobber.json +++ b/.lint_baselines/falsey_clobber.json @@ -11,12 +11,12 @@ "axonflow/adapters/langchain.py:160:15", "axonflow/adapters/langchain.py:160:64", "axonflow/adapters/langchain.py:169:16", - "axonflow/adapters/langgraph.py:569:33", - "axonflow/adapters/langgraph.py:583:43", - "axonflow/adapters/langgraph.py:597:20", - "axonflow/adapters/langgraph.py:654:33", - "axonflow/adapters/langgraph.py:668:20", - "axonflow/adapters/langgraph.py:692:20", + "axonflow/adapters/langgraph.py:573:33", + "axonflow/adapters/langgraph.py:592:43", + "axonflow/adapters/langgraph.py:607:20", + "axonflow/adapters/langgraph.py:670:33", + "axonflow/adapters/langgraph.py:686:20", + "axonflow/adapters/langgraph.py:711:20", "axonflow/adapters/langgraph_wrapper.py:374:19", "axonflow/adapters/tool_wrapper.py:176:20", "axonflow/adapters/tool_wrapper.py:190:20", @@ -24,20 +24,20 @@ "axonflow/adapters/tool_wrapper.py:220:20", "axonflow/client.py:1111:16", "axonflow/client.py:1188:16", - "axonflow/client.py:1669:37", - "axonflow/client.py:1710:18", - "axonflow/client.py:1768:37", - "axonflow/client.py:2286:24", - "axonflow/client.py:2307:33", - "axonflow/client.py:2308:31", - "axonflow/client.py:2320:25", - "axonflow/client.py:2381:28", - "axonflow/client.py:2422:69", + "axonflow/client.py:1686:37", + "axonflow/client.py:1727:18", + "axonflow/client.py:1785:37", + "axonflow/client.py:2303:24", + "axonflow/client.py:2324:33", + "axonflow/client.py:2325:31", + "axonflow/client.py:2337:25", + "axonflow/client.py:2398:28", + "axonflow/client.py:2439:69", "axonflow/client.py:300:14", "axonflow/client.py:305:24", "axonflow/client.py:306:20", "axonflow/client.py:529:44", - "axonflow/client.py:6463:25", + "axonflow/client.py:6480:25", "axonflow/client.py:845:20", "axonflow/client.py:931:20", "axonflow/execution.py:205:19", diff --git a/CHANGELOG.md b/CHANGELOG.md index a0fcf57..3de8f63 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,16 +11,62 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -### Fixed - -- **LangGraph `mcp_tool_interceptor` no longer concatenates the MCP server - name and tool name into a single `connector_type` string.** `mcp_check_input`/ - `mcp_check_output` (and their `check_tool_input`/`check_tool_output` aliases) - now accept an optional `tool` parameter, sent alongside `connector_type` on - the wire, matching the platform's two-field (server, tool) identity contract - (epic #2905 / #2904). The interceptor sends `connector_type=request.server_name` - and `tool=request.name` as two distinct values instead of - `f"{server_name}.{name}"`. +> **This release contains a breaking change and MUST be published as a major +> version bump.** The `connector_type` wire value emitted by the LangGraph +> adapter changes from `"{server}.{tool}"` to the bare server name; policies +> matching the old concatenated value stop matching until re-scoped (see the +> migration note below). +> +> **Merge order.** This PR (#216) and the Computer Use PR (#217) both add the +> shared optional `tool` parameter to `client.py`/`types.py` and the +> `wire_shape_baseline.json` entries. Those hunks are byte-identical across +> both PRs; **merge #216 first**, then #217 auto-resolves the shared hunks. + +### Changed (BREAKING) + +- **The LangGraph adapter now reports the (server, tool) identity as two + separate wire fields instead of concatenating them into `connector_type`.** + `mcp_check_input`/`mcp_check_output` (and their `check_tool_input`/ + `check_tool_output` aliases) gain an optional `tool` parameter, sent + alongside `connector_type` on the wire, matching the platform's two-field + (server, tool) identity contract (epic #2905 / #2904). The interceptor now + sends `connector_type=request.server_name` and `tool=request.name` as two + distinct values instead of `f"{server_name}.{name}"`; the default + `connector_type_fn` returns the bare `server_name`. `tool` is always sent + separately and is never folded back into `connector_type`, even when a + custom `connector_type_fn` is supplied. + + **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 `connector_type_fn` option is the compatibility lever: a caller can + restore any prior `connector_type` value (including the old concatenated + form, `lambda req: f"{req.server_name}.{req.name}"`) without losing the + separate `tool` field. + + **Statement text also changed** for policies that match on the + human-readable `statement`: it is now `"{connector_type}.{tool}(args)"` + (built from the *resolved* connector type). With a custom `connector_type_fn` + the statement shape shifts from the old `"{custom}(args)"` to + `"{custom}.{tool}(args)"`. + + **Missing-server edge.** With the default resolver, a tool whose + `server_name` is empty now sends `connector_type=""`, which the platform + rejects with HTTP 400 → the client raises `ConnectorError` and the tool call + is blocked (fail-closed), never run ungoverned. Previously the concatenated + value was `".tool"` (a non-empty string the platform accepted). Supply a + `connector_type_fn` for server-less MCP tools. + + **Minimum platform.** The `tool` field is consumed on `POST + /api/v1/mcp/check-input` by platform **v9.10.0+** (enterprise `c8df2006b`, + epic #2905 / #2904). 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.** The response plane (`check-output`) does **not** consume + `tool` on any released platform version yet (tracked by #2955, targeted for + v9.11.0); the SDK sends it forward-compatibly and current platforms ignore + it. ## [8.5.1] - 2026-07-09 — Interceptor sync bridge + async-client detection + example fixes diff --git a/axonflow/types.py b/axonflow/types.py index 7864029..d33053b 100644 --- a/axonflow/types.py +++ b/axonflow/types.py @@ -410,8 +410,9 @@ class MCPCheckInputRequest(BaseModel): # Two-field (server, tool) identity contract (epic #2905 / #2904). `tool` # carries the MCP tool name so a PEP no longer has to concatenate it into # `connector_type` (e.g. "server.tool") to preserve tool identity. - # Source of truth: platform/agent MCPCheckInputRequest (epic #2905, #2904, - # merged to axonflow-enterprise main and live on the request-input plane). + # Source of truth: platform/agent MCPCheckInputRequest (#2904, merged to + # axonflow-enterprise as c8df2006b and first released in platform v9.10.0); + # consumed on the request-input plane. Platforms below v9.10.0 ignore it. tool: str | None = Field(default=None) statement: str parameters: dict[str, Any] | None = Field(default=None) @@ -469,11 +470,11 @@ class MCPCheckOutputRequest(BaseModel): connector_type: str # Two-field (server, tool) identity contract, mirrored from - # MCPCheckInputRequest.tool (epic #2905 / #2904). Note: unlike the - # input-phase field, the platform's MCPCheckOutputRequest has no matching - # `tool` field yet — sending this is forward-compatible and harmless (the - # agent's JSON decoder silently ignores unrecognized keys), but it is not - # yet consumed server-side. + # MCPCheckInputRequest.tool (epic #2905 / #2904). Unlike the input-phase + # field, the platform's MCPCheckOutputRequest has no matching `tool` field + # on ANY released version yet (tracked by #2955); sending it is + # forward-compatible and harmless — the agent's JSON decoder ignores + # unrecognized keys — but it is not yet consumed server-side. tool: str | None = Field(default=None) response_data: list[dict[str, Any]] | None = Field(default=None) message: str | None = Field(default=None) diff --git a/tests/fixtures/wire_shape_baseline.json b/tests/fixtures/wire_shape_baseline.json index f349055..675550e 100644 --- a/tests/fixtures/wire_shape_baseline.json +++ b/tests/fixtures/wire_shape_baseline.json @@ -297,7 +297,7 @@ "spec_only": [] }, "MCPCheckInputRequest": { - "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `content_type` (request-redaction detector selector, ADR-056); the agent consumes it on POST /api/v1/mcp/check-input but agent-api.yaml doesn't yet declare it. Also declares `tool` (epic #2905, platform sub-issue #2904) \u2014 the two-field (server, tool) identity contract; #2904 is merged to axonflow-enterprise main and live server-side, this drift just tracks the OpenAPI spec pin (pinned to a community mirror commit that predates the merge) catching up.", + "note": "acknowledged-sdk-superset: tracked in #2563/#2571 \u2014 SDK declares `content_type` (request-redaction detector selector, ADR-056); the agent consumes it on POST /api/v1/mcp/check-input but agent-api.yaml doesn't yet declare it. Also declares `tool` (epic #2905, platform sub-issue #2904) \u2014 the two-field (server, tool) identity contract; #2904 merged to axonflow-enterprise (c8df2006b) and first released in platform v9.10.0, so this drift just tracks the pinned OpenAPI spec (agent-api.yaml) catching up.", "sdk_only": [ "content_type", "tool" @@ -314,7 +314,7 @@ "spec_only": [] }, "MCPCheckOutputRequest": { - "note": "acknowledged-sdk-superset: tracked in epic #2905, platform sub-issue #2904 \u2014 SDK declares `tool`, mirroring MCPCheckInputRequest.tool for the two-field (server, tool) identity contract. Unlike the input-phase field, the platform's MCPCheckOutputRequest has no matching `tool` field at all yet (#2904 only added it input-side) \u2014 sending this is forward-compatible and harmless but not yet consumed server-side.", + "note": "acknowledged-sdk-superset: tracked in epic #2905, platform sub-issue #2904 \u2014 SDK declares `tool`, mirroring MCPCheckInputRequest.tool for the two-field (server, tool) identity contract. Unlike the input-phase field, the platform's MCPCheckOutputRequest has no matching `tool` field on any released version yet (tracked by #2955) \u2014 sending it is forward-compatible and harmless but not yet consumed server-side.", "sdk_only": [ "tool" ], diff --git a/tests/test_langgraph_adapter.py b/tests/test_langgraph_adapter.py index bd692c7..c656113 100644 --- a/tests/test_langgraph_adapter.py +++ b/tests/test_langgraph_adapter.py @@ -261,6 +261,32 @@ async def test_custom_connector_type_fn_does_not_drop_tool( assert call_kwargs["connector_type"] == "custom-type" assert call_kwargs["tool"] == "tool" + @pytest.mark.asyncio + async def test_empty_server_name_sends_empty_connector_type_and_tool( + self, adapter: AxonFlowLangGraphAdapter, client: AxonFlow + ) -> None: + """Missing-server edge (epic #2905): with the default resolver, + connector_type is the server name. An empty ``server_name`` therefore + sends ``connector_type=""`` while ``tool`` still carries the tool + name. + + Documented behavior: a real platform rejects an empty + ``connector_type`` with HTTP 400, which the client surfaces as a + ``ConnectorError`` — the tool call is blocked (fail-closed), never run + ungoverned. Callers whose MCP tools have no server must supply a + ``connector_type_fn``. Before the de-concatenation the old value was + ``".tool"`` (a non-empty string the platform accepted), so this is a + deliberate, surfaced behavior change for server-less tools. + """ + handler = AsyncMock(return_value="ok") + request = _make_request(server_name="", name="tool") + + await adapter.mcp_tool_interceptor()(request, handler) + + call_kwargs = client.mcp_check_input.call_args.kwargs + assert call_kwargs["connector_type"] == "" + assert call_kwargs["tool"] == "tool" + @pytest.mark.asyncio async def test_custom_connector_type_fn_also_used_for_output_check( self, adapter: AxonFlowLangGraphAdapter, client: AxonFlow From b131d0b0eed48900ecad007adb8b3b8ea0ec59a4 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Fri, 17 Jul 2026 23:35:54 +0200 Subject: [PATCH 3/4] docs(changelog): add blank line before ### Added (MD022) Signed-off-by: Saurabh Jain --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b60784..58f66ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -67,6 +67,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 `tool` on any released platform version yet (tracked by #2955, targeted for v9.11.0); the SDK sends it forward-compatibly and current platforms ignore it. + ### Added - **`AuditToolCallRequest.caller_name`** (getaxonflow/axonflow-enterprise#2912, From fa38740b1192afd340341cde538739ce2f42cc91 Mon Sep 17 00:00:00 2001 From: Saurabh Jain Date: Sat, 18 Jul 2026 00:21:19 +0200 Subject: [PATCH 4/4] =?UTF-8?q?release:=20finalize=20v9.0.0=20=E2=80=94=20?= =?UTF-8?q?version=20bump=20+=20clean=20[9.0.0]=20changelog?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump pyproject.toml + axonflow/_version.py to 9.0.0 and finalize the tool-identity content under ## [9.0.0] - 2026-07-18 (banner dropped). The LangGraph and Computer Use de-concatenation is described in a single combined entry (byte-identical across #216/#217 — merge #216 first). Changelog scrubbed of internal issue/PR numbers and internal review terminology; minimum platform stated as versions (check-input tool = platform v9.10.0+, response-plane tool = platform v9.11.0+). Signed-off-by: Saurabh Jain --- CHANGELOG.md | 106 +++++++++++++++++++------------------------ axonflow/_version.py | 2 +- pyproject.toml | 2 +- 3 files changed, 49 insertions(+), 61 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 58f66ad..0a2570f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,75 +11,63 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] -> **This release contains a breaking change and MUST be published as a major -> version bump.** The `connector_type` wire value emitted by the LangGraph -> adapter changes from `"{server}.{tool}"` to the bare server name; policies -> matching the old concatenated value stop matching until re-scoped (see the -> migration note below). -> -> **Merge order.** This PR (#216) and the Computer Use PR (#217) both add the -> shared optional `tool` parameter to `client.py`/`types.py` and the -> `wire_shape_baseline.json` entries. Those hunks are byte-identical across -> both PRs; **merge #216 first**, then #217 auto-resolves the shared hunks. +## [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 `connector_type`.** - `mcp_check_input`/`mcp_check_output` (and their `check_tool_input`/ - `check_tool_output` aliases) gain an optional `tool` parameter, sent - alongside `connector_type` on the wire, matching the platform's two-field - (server, tool) identity contract (epic #2905 / #2904). The interceptor now - sends `connector_type=request.server_name` and `tool=request.name` as two - distinct values instead of `f"{server_name}.{name}"`; the default - `connector_type_fn` returns the bare `server_name`. `tool` is always sent - separately and is never folded back into `connector_type`, even when a - custom `connector_type_fn` is supplied. +- **The LangGraph and Computer Use adapters now report the (server, tool) + identity as two separate wire fields instead of concatenating them into + `connector_type`.** `mcp_check_input`/`mcp_check_output` (and their + `check_tool_input`/`check_tool_output` aliases) gain an optional `tool` + parameter, sent alongside `connector_type` on the wire — the platform's + two-field (server, tool) identity contract. The tool name is never folded + back into `connector_type`. + + - **LangGraph** (`mcp_tool_interceptor`) now sends + `connector_type = request.server_name` and `tool = request.name` instead + of `f"{server_name}.{name}"`; the default `connector_type_fn` returns the + bare `server_name`. `connector_type_fn` is the compatibility lever — a + caller can restore any prior `connector_type` value (including the old + concatenated form, `lambda req: f"{req.server_name}.{req.name}"`) without + losing the separate `tool` field. The human-readable `statement` is now + `"{connector_type}.{tool}(args)"` built from the resolved connector type; + with a custom `connector_type_fn` its shape shifts from `"{custom}(args)"` + to `"{custom}.{tool}(args)"`. With the default resolver, a tool whose + `server_name` is empty sends `connector_type=""`, which the platform + rejects with HTTP 400 — the call raises `ConnectorError` and is blocked + (fail-closed); supply a `connector_type_fn` for server-less MCP tools. + + - **Computer Use** (`ComputerUseGovernor`) now sends the constant + `connector_type = "computer_use"` and `tool =` the tool name (`computer`, + `bash`, `text_editor`). The action (e.g. `left_click`) is preserved inside + the serialized `statement`, not in `tool` (and not in `operation`, which + is constrained to `{query, execute}`). This adapter has **no** + `connector_type_fn` escape hatch, so re-scoping policies is the only + migration path. **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 `connector_type_fn` option is the compatibility lever: a caller can - restore any prior `connector_type` value (including the old concatenated - form, `lambda req: f"{req.server_name}.{req.name}"`) without losing the - separate `tool` field. - - **Statement text also changed** for policies that match on the - human-readable `statement`: it is now `"{connector_type}.{tool}(args)"` - (built from the *resolved* connector type). With a custom `connector_type_fn` - the statement shape shifts from the old `"{custom}(args)"` to - `"{custom}.{tool}(args)"`. - - **Missing-server edge.** With the default resolver, a tool whose - `server_name` is empty now sends `connector_type=""`, which the platform - rejects with HTTP 400 → the client raises `ConnectorError` and the tool call - is blocked (fail-closed), never run ungoverned. Previously the concatenated - value was `".tool"` (a non-empty string the platform accepted). Supply a - `connector_type_fn` for server-less MCP tools. + concatenated value — e.g. `connector_type == "filesystem.read_file"` or + `"computer_use.left_click"` — stop matching after upgrade. Re-scope them to + match `connector_type` (the bare server name, or `"computer_use"`) together + with the `tool` field (e.g. `tool == "read_file"`). **Minimum platform.** The `tool` field is consumed on `POST - /api/v1/mcp/check-input` by platform **v9.10.0+** (enterprise `c8df2006b`, - epic #2905 / #2904). 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.** The response plane (`check-output`) does **not** consume - `tool` on any released platform version yet (tracked by #2955, targeted for - v9.11.0); the SDK sends it forward-compatibly and current platforms ignore - it. + /api/v1/mcp/check-input` by **AxonFlow platform v9.10.0+**. On older + platforms it is silently dropped and identity degrades to the bare + `connector_type` — 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. ### Added -- **`AuditToolCallRequest.caller_name`** (getaxonflow/axonflow-enterprise#2912, - sub-issue of epic #2905) — identifies which client made a non-LLM tool - call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`). Replaces the - misleadingly-named `tool_type` field, which every real caller actually - used to identify the calling client rather than any property of the - tool. `tool_type` is kept as a deprecated input fallback (not removed): - the server resolves `caller_name` if supplied, else the legacy - `tool_type`, else a default. See - `runtime-e2e/caller_name_audit/` for the real-stack proof that - `caller_name` reaches `policy_details.caller_name` on the audit row. +- **`AuditToolCallRequest.caller_name`** — identifies which client made a + non-LLM tool call (e.g. `claude_code`, `codex`, `cursor`, `openclaw`). + Replaces the misleadingly-named `tool_type` field, which every real caller + actually used to identify the calling client rather than any property of the + tool. `tool_type` is kept as a deprecated input fallback (not removed): the + server resolves `caller_name` if supplied, else the legacy `tool_type`, else + a default. ## [8.5.1] - 2026-07-09 — Interceptor sync bridge + async-client detection + example fixes diff --git a/axonflow/_version.py b/axonflow/_version.py index 4ea87e2..7c29405 100644 --- a/axonflow/_version.py +++ b/axonflow/_version.py @@ -1,3 +1,3 @@ """Single source of truth for the AxonFlow SDK version.""" -__version__ = "8.5.1" +__version__ = "9.0.0" diff --git a/pyproject.toml b/pyproject.toml index bc076e7..cace8e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "axonflow" -version = "8.5.1" +version = "9.0.0" description = "AxonFlow Python SDK - Enterprise AI Governance in 3 Lines of Code" readme = "README.md" license = {text = "MIT"}