From dd7ceac0b7d03d3695f332eb6ae8a5c038048510 Mon Sep 17 00:00:00 2001 From: George-iam Date: Sun, 1 Mar 2026 13:04:07 +0000 Subject: [PATCH 1/2] feat: add MCP tool-adapter helpers to Python SDK Add MCP initialize/tools-list/tools-call client methods with schema-aware argument checks, owner propagation, retry behavior, and observer hooks to advance Track C parity. Made-with: Cursor --- README.md | 11 +++ axme_sdk/client.py | 183 ++++++++++++++++++++++++++++++++++++++++++- tests/test_client.py | 177 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 370 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 04f2221..d8cff8e 100644 --- a/README.md +++ b/README.md @@ -155,6 +155,17 @@ with AxmeClient(config) as client: owner_agent="agent://example/receiver", ) print(events["event_id"]) + mcp_info = client.mcp_initialize() + print(mcp_info["protocolVersion"]) + tools = client.mcp_list_tools() + print(len(tools.get("tools", []))) + mcp_result = client.mcp_call_tool( + "axme.send", + arguments={"to": "agent://example/receiver", "text": "hello from MCP"}, + owner_agent="agent://example/receiver", + idempotency_key="mcp-send-001", + ) + print(mcp_result.get("status")) ``` ## Development diff --git a/axme_sdk/client.py b/axme_sdk/client.py index 61deebc..d2f739d 100644 --- a/axme_sdk/client.py +++ b/axme_sdk/client.py @@ -2,7 +2,7 @@ from dataclasses import dataclass import time -from typing import Any +from typing import Any, Callable from uuid import uuid4 import httpx @@ -24,6 +24,10 @@ class AxmeClientConfig: max_retries: int = 2 retry_backoff_seconds: float = 0.2 auto_trace_id: bool = True + default_owner_agent: str | None = None + mcp_endpoint_path: str = "/mcp" + mcp_protocol_version: str = "2024-11-05" + mcp_observer: Callable[[dict[str, Any]], None] | None = None class AxmeClient: @@ -38,6 +42,7 @@ def __init__(self, config: AxmeClientConfig, *, http_client: httpx.Client | None "Content-Type": "application/json", }, ) + self._mcp_tool_schemas: dict[str, dict[str, Any]] = {} def close(self) -> None: if self._owns_http_client: @@ -485,6 +490,70 @@ def replay_webhook_event( ) return response + def mcp_initialize(self, *, protocol_version: str | None = None, trace_id: str | None = None) -> dict[str, Any]: + payload = { + "jsonrpc": "2.0", + "id": str(uuid4()), + "method": "initialize", + "params": {"protocolVersion": protocol_version or self._config.mcp_protocol_version}, + } + return self._mcp_request(payload=payload, trace_id=trace_id, retryable=True) + + def mcp_list_tools(self, *, trace_id: str | None = None) -> dict[str, Any]: + payload = { + "jsonrpc": "2.0", + "id": str(uuid4()), + "method": "tools/list", + "params": {}, + } + result = self._mcp_request(payload=payload, trace_id=trace_id, retryable=True) + tools = result.get("tools") + if isinstance(tools, list): + self._mcp_tool_schemas = {} + for tool in tools: + if not isinstance(tool, dict): + continue + name = tool.get("name") + input_schema = tool.get("inputSchema") + if isinstance(name, str) and isinstance(input_schema, dict): + self._mcp_tool_schemas[name] = input_schema + return result + + def mcp_call_tool( + self, + name: str, + *, + arguments: dict[str, Any] | None = None, + owner_agent: str | None = None, + idempotency_key: str | None = None, + trace_id: str | None = None, + validate_input_schema: bool = True, + retryable: bool | None = None, + ) -> dict[str, Any]: + if not isinstance(name, str) or not name.strip(): + raise ValueError("tool name must be non-empty string") + args = dict(arguments or {}) + resolved_owner = owner_agent or self._config.default_owner_agent + if resolved_owner and "owner_agent" not in args: + args["owner_agent"] = resolved_owner + if idempotency_key and "idempotency_key" not in args: + args["idempotency_key"] = idempotency_key + + if validate_input_schema: + self._validate_mcp_tool_arguments(name=name.strip(), arguments=args) + + params: dict[str, Any] = {"name": name.strip(), "arguments": args} + if resolved_owner: + params["owner_agent"] = resolved_owner + payload = { + "jsonrpc": "2.0", + "id": str(uuid4()), + "method": "tools/call", + "params": params, + } + should_retry = retryable if retryable is not None else bool(idempotency_key) + return self._mcp_request(payload=payload, trace_id=trace_id, retryable=should_retry) + def _request_json( self, method: str, @@ -529,6 +598,99 @@ def _request_json( raise RuntimeError("unreachable retry loop state") + def _mcp_request( + self, + *, + payload: dict[str, Any], + trace_id: str | None, + retryable: bool, + ) -> dict[str, Any]: + self._notify_mcp_observer( + { + "phase": "request", + "method": payload.get("method"), + "rpc_id": payload.get("id"), + "retryable": retryable, + } + ) + response = self._request_json( + "POST", + self._config.mcp_endpoint_path, + json_body=payload, + trace_id=trace_id, + retryable=retryable, + ) + if isinstance(response.get("error"), dict): + self._raise_mcp_rpc_error(response) + result = response.get("result") + if not isinstance(result, dict): + raise AxmeHttpError(502, "invalid MCP response: missing result object", body=response) + self._notify_mcp_observer( + { + "phase": "response", + "method": payload.get("method"), + "rpc_id": payload.get("id"), + "result_keys": sorted(result.keys()), + } + ) + return result + + def _raise_mcp_rpc_error(self, response_payload: dict[str, Any]) -> None: + error = response_payload.get("error") + if not isinstance(error, dict): + raise AxmeHttpError(502, "invalid MCP response: error is not object", body=response_payload) + code = error.get("code") + message = error.get("message") + if not isinstance(code, int): + code = -32000 + if not isinstance(message, str) or not message: + message = "MCP RPC error" + data = error.get("data") + kwargs = {"body": {"code": code, "message": message, "data": data}} + if code in {-32001, -32003}: + raise AxmeAuthError(403, message, **kwargs) + if code == -32004: + raise AxmeRateLimitError(429, message, **kwargs) + if code == -32602: + raise AxmeValidationError(422, message, **kwargs) + if code <= -32000: + raise AxmeServerError(502, message, **kwargs) + raise AxmeHttpError(400, message, **kwargs) + + def _validate_mcp_tool_arguments(self, *, name: str, arguments: dict[str, Any]) -> None: + schema = self._mcp_tool_schemas.get(name) + if not isinstance(schema, dict): + return + required = schema.get("required") + if isinstance(required, list): + missing = [item for item in required if isinstance(item, str) and item not in arguments] + if missing: + raise ValueError(f"missing required MCP tool arguments for {name}: {', '.join(sorted(missing))}") + properties = schema.get("properties") + if not isinstance(properties, dict): + return + for key, value in arguments.items(): + if key not in properties: + continue + prop = properties[key] + if not isinstance(prop, dict): + continue + declared_type = prop.get("type") + if isinstance(declared_type, list): + accepted_types = [item for item in declared_type if isinstance(item, str)] + elif isinstance(declared_type, str): + accepted_types = [declared_type] + else: + accepted_types = [] + if accepted_types and not _matches_json_type(value=value, accepted_types=accepted_types): + raise ValueError(f"invalid MCP argument type for {name}.{key}: expected {accepted_types}") + + def _notify_mcp_observer(self, event: dict[str, Any]) -> None: + observer = self._config.mcp_observer + if observer is None: + return + observer(event) + def _sleep_before_retry(self, attempt_idx: int, *, retry_after: int | None) -> None: if retry_after is not None: time.sleep(max(0, retry_after)) @@ -599,3 +761,22 @@ def _parse_retry_after(value: str | None) -> int | None: def _is_retryable_status(status_code: int) -> bool: return status_code == 429 or status_code >= 500 + + +def _matches_json_type(*, value: Any, accepted_types: list[str]) -> bool: + for type_name in accepted_types: + if type_name == "null" and value is None: + return True + if type_name == "string" and isinstance(value, str): + return True + if type_name == "boolean" and isinstance(value, bool): + return True + if type_name == "integer" and isinstance(value, int) and not isinstance(value, bool): + return True + if type_name == "number" and isinstance(value, (int, float)) and not isinstance(value, bool): + return True + if type_name == "object" and isinstance(value, dict): + return True + if type_name == "array" and isinstance(value, list): + return True + return False diff --git a/tests/test_client.py b/tests/test_client.py index b0693d3..ce3c965 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -977,3 +977,180 @@ def handler(request: httpx.Request) -> httpx.Response: correlation_id="11111111-1111-1111-1111-111111111111", ) assert attempts == 1 + + +def test_mcp_initialize_success() -> None: + def handler(request: httpx.Request) -> httpx.Response: + assert request.method == "POST" + assert request.url.path == "/mcp" + body = json.loads(request.read().decode("utf-8")) + assert body["jsonrpc"] == "2.0" + assert body["method"] == "initialize" + assert body["params"]["protocolVersion"] == "2024-11-05" + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": {"protocolVersion": "2024-11-05", "capabilities": {"tools": {"listChanged": False}}}, + }, + ) + + client = _client(handler) + result = client.mcp_initialize() + assert result["protocolVersion"] == "2024-11-05" + + +def test_mcp_list_tools_and_call_tool_with_schema_validation() -> None: + calls: list[dict[str, object]] = [] + + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.read().decode("utf-8")) + calls.append(body) + if body["method"] == "tools/list": + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": { + "tools": [ + { + "name": "axme.send", + "inputSchema": { + "type": "object", + "required": ["to"], + "properties": { + "to": {"type": "string"}, + "text": {"type": "string"}, + "idempotency_key": {"type": "string"}, + "owner_agent": {"type": "string"}, + }, + }, + } + ] + }, + }, + ) + assert body["method"] == "tools/call" + assert body["params"]["name"] == "axme.send" + assert body["params"]["owner_agent"] == "agent://owner/default" + args = body["params"]["arguments"] + assert args["owner_agent"] == "agent://owner/default" + assert args["idempotency_key"] == "mcp-idem-1" + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": {"ok": True, "tool": "axme.send", "status": "completed"}, + }, + ) + + observed_events: list[dict[str, object]] = [] + cfg = AxmeClientConfig( + base_url="https://api.axme.test", + api_key="token", + default_owner_agent="agent://owner/default", + mcp_observer=lambda event: observed_events.append(event), + ) + http_client = httpx.Client( + transport=_transport(handler), + base_url=cfg.base_url, + headers={ + "Authorization": f"Bearer {cfg.api_key}", + "Content-Type": "application/json", + }, + ) + client = AxmeClient(cfg, http_client=http_client) + tools = client.mcp_list_tools() + assert isinstance(tools["tools"], list) + call_result = client.mcp_call_tool( + "axme.send", + arguments={"to": "agent://bob", "text": "hello"}, + idempotency_key="mcp-idem-1", + ) + assert call_result["ok"] is True + assert len(calls) == 2 + assert any(event.get("phase") == "request" for event in observed_events) + assert any(event.get("phase") == "response" for event in observed_events) + + +def test_mcp_call_tool_validates_required_arguments() -> None: + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.read().decode("utf-8")) + if body["method"] == "tools/list": + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body["id"], + "result": { + "tools": [ + { + "name": "axme.reply", + "inputSchema": { + "type": "object", + "required": ["thread_id", "message"], + "properties": { + "thread_id": {"type": "string"}, + "message": {"type": "string"}, + }, + }, + } + ] + }, + }, + ) + return httpx.Response(500, json={"error": "unexpected"}) + + client = _client(handler) + client.mcp_list_tools() + with pytest.raises(ValueError, match="missing required MCP tool arguments"): + client.mcp_call_tool("axme.reply", arguments={"thread_id": "t-1"}) + + +def test_mcp_call_tool_maps_rpc_errors() -> None: + def handler(request: httpx.Request) -> httpx.Response: + body = json.loads(request.read().decode("utf-8")) + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body.get("id"), + "error": {"code": -32602, "message": "invalid params"}, + }, + ) + + client = _client(handler) + with pytest.raises(AxmeValidationError) as exc_info: + client.mcp_call_tool("axme.send", arguments={"to": "agent://bob"}) + assert exc_info.value.status_code == 422 + + +def test_mcp_call_tool_retries_http_failure_when_retryable() -> None: + attempts = 0 + + def handler(request: httpx.Request) -> httpx.Response: + nonlocal attempts + attempts += 1 + if attempts == 1: + return httpx.Response(500, json={"error": "temporary"}) + body = json.loads(request.read().decode("utf-8")) + return httpx.Response( + 200, + json={ + "jsonrpc": "2.0", + "id": body.get("id"), + "result": {"ok": True, "tool": "axme.send", "status": "completed"}, + }, + ) + + client = _client(handler) + result = client.mcp_call_tool( + "axme.send", + arguments={"to": "agent://bob", "text": "hello"}, + idempotency_key="idem-1", + ) + assert result["ok"] is True + assert attempts == 2 From 4470c6144061da9578b0f883b3d3eb9cf1a048f7 Mon Sep 17 00:00:00 2001 From: George-iam Date: Sun, 1 Mar 2026 13:45:29 +0000 Subject: [PATCH 2/2] docs: align Python SDK README with AXP positioning Add canonical protocol positioning to SDK documentation so integration guidance stays consistent with the Intent Protocol (durable execution layer) concept. Made-with: Cursor --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index d8cff8e..fa3f65e 100644 --- a/README.md +++ b/README.md @@ -2,6 +2,10 @@ Official Python SDK for Axme APIs and workflows. +Canonical protocol positioning: + +- **AXP is the Intent Protocol (durable execution layer).** + ## Status Initial v1 skeleton in progress.