diff --git a/README.md b/README.md index 058412e..7f24bf3 100644 --- a/README.md +++ b/README.md @@ -190,6 +190,18 @@ for agent in results: agent_summary = sdk.getAgent("11155111:123") ``` +### 4b. Create MCP/A2A Clients Directly from URL + +```python +# MCP: URL is treated as the direct MCP endpoint +mcp = sdk.createMCPClient("https://mcp.example.com/mcp") +tools = mcp.listTools() + +# A2A: URL can be an agent-card URL or base URL (discovery is applied) +a2a = sdk.createA2AClient("https://a2a.example.com") +reply = a2a.messageA2A("hello") +``` + ### 5. Give and Retrieve Feedback ```python diff --git a/agent0_sdk/__init__.py b/agent0_sdk/__init__.py index 144da10..80b7bd3 100644 --- a/agent0_sdk/__init__.py +++ b/agent0_sdk/__init__.py @@ -42,6 +42,21 @@ AgentCardAuth, ) from .core.a2a_summary_client import A2AClientFromSummary + from .core.a2a_summary_client import A2AClientFromUrl + from .core.mcp_client import MCPClient, create_mcp_handle + from .core.mcp_summary_client import MCPClientFromSummary + from .core.mcp_types import ( + MCPAuthOptions, + MCPClientInfo, + MCPClientOptions, + MCPInitializeResult, + MCPTool, + MCPPrompt, + MCPPromptGetResult, + MCPResource, + MCPResourceTemplate, + MCPPromptMessage, + ) _sdk_available = True except ImportError: SDK = None @@ -63,6 +78,7 @@ LoadTaskOptions = None AgentCardAuth = None A2AClientFromSummary = None + A2AClientFromUrl = None _sdk_available = False __version__ = "1.7.1" @@ -103,4 +119,18 @@ "LoadTaskOptions", "AgentCardAuth", "A2AClientFromSummary", + "A2AClientFromUrl", + "MCPClient", + "create_mcp_handle", + "MCPClientFromSummary", + "MCPAuthOptions", + "MCPClientInfo", + "MCPClientOptions", + "MCPInitializeResult", + "MCPTool", + "MCPPrompt", + "MCPPromptGetResult", + "MCPResource", + "MCPResourceTemplate", + "MCPPromptMessage", ] diff --git a/agent0_sdk/core/a2a_summary_client.py b/agent0_sdk/core/a2a_summary_client.py index 1e8345c..48bfc78 100644 --- a/agent0_sdk/core/a2a_summary_client.py +++ b/agent0_sdk/core/a2a_summary_client.py @@ -192,3 +192,155 @@ def pay_first_wrapper() -> AgentTask: resolved.get("tenant"), None, ) + + +class A2AClientFromUrl: + """ + A2A client backed directly by a URL (agent-card URL or base URL). + Resolves the A2A interface once on first use. + """ + + def __init__(self, sdk: SDKLike, url: str) -> None: + self._sdk = sdk + self._url = url + self._resolved: Optional[Dict[str, Any]] = None + + def _ensure_resolved(self) -> Dict[str, Any]: + if self._resolved is not None: + return self._resolved + if not self._url or not (str(self._url).startswith("http://") or str(self._url).startswith("https://")): + raise RuntimeError("A2A URL must be http or https") + self._resolved = resolve_a2a_from_endpoint_url(str(self._url)) + return self._resolved + + def messageA2A( + self, + content: Union[str, Dict[str, Any]], + options: Optional[MessageA2AOptions] = None, + ) -> Union[MessageResponse, TaskResponse, A2APaymentRequired]: + resolved = self._ensure_resolved() + x402_deps = self._sdk.getX402RequestDeps() if hasattr(self._sdk, "getX402RequestDeps") else None + return send_message( + resolved["baseUrl"], + resolved["a2aVersion"], + content, + options=options, + auth=resolved.get("auth"), + tenant=resolved.get("tenant"), + binding=resolved.get("binding"), + x402_deps=x402_deps, + ) + + def listTasks( + self, + options: Optional[ListTasksOptions] = None, + ) -> Union[List[TaskSummary], A2APaymentRequired]: + resolved = self._ensure_resolved() + x402_deps = self._sdk.getX402RequestDeps() if hasattr(self._sdk, "getX402RequestDeps") else None + auth_dict: Optional[Dict[str, Any]] = None + if resolved.get("auth"): + auth_dict = apply_credential((options.credential or "") if options else "", resolved["auth"]) + else: + auth_dict = {"headers": {}, "queryParams": {}} + return list_tasks( + resolved["baseUrl"], + resolved["a2aVersion"], + options=options, + auth=auth_dict, + tenant=resolved.get("tenant"), + x402_deps=x402_deps, + ) + + def loadTask( + self, + task_id: str, + options: Optional[LoadTaskOptions] = None, + ) -> Union[AgentTask, A2APaymentRequired]: + resolved = self._ensure_resolved() + x402_deps = self._sdk.getX402RequestDeps() if hasattr(self._sdk, "getX402RequestDeps") else None + resolved_auth: Optional[Dict[str, Any]] = None + if resolved.get("auth"): + resolved_auth = apply_credential((options.credential or "") if options else "", resolved["auth"]) + else: + resolved_auth = {"headers": {}, "queryParams": {}} + + result = get_task( + resolved["baseUrl"], + resolved["a2aVersion"], + task_id, + auth=resolved_auth, + x402_deps=x402_deps, + payment=options.payment if options else None, + tenant=resolved.get("tenant"), + ) + + if _is_x402_response(result): + x402_resp = result + x402_payment = getattr(x402_resp, "x402Payment", None) or x402_resp.get("x402Payment") + if not x402_payment: + raise RuntimeError("x402 response missing x402Payment") + orig_pay = getattr(x402_payment, "pay", None) or x402_payment.get("pay") + orig_pay_first = getattr(x402_payment, "pay_first", None) or x402_payment.get("pay_first") + + def pay_wrapper(accept: Any = None) -> AgentTask: + summary_result = orig_pay(accept) + tid = getattr(summary_result, "taskId", None) or (summary_result.get("taskId") if isinstance(summary_result, dict) else None) + cid = getattr(summary_result, "contextId", None) or (summary_result.get("contextId") if isinstance(summary_result, dict) else "") + if not tid: + raise RuntimeError("x402 pay() did not return taskId") + return create_task_handle( + resolved["baseUrl"], + resolved["a2aVersion"], + str(tid), + str(cid or ""), + x402_deps, + resolved_auth, + resolved.get("tenant"), + None, + ) + + def pay_first_wrapper() -> AgentTask: + if not orig_pay_first: + raise ValueError("x402: no pay_first available") + summary_result = orig_pay_first() + tid = getattr(summary_result, "taskId", None) or (summary_result.get("taskId") if isinstance(summary_result, dict) else None) + cid = getattr(summary_result, "contextId", None) or (summary_result.get("contextId") if isinstance(summary_result, dict) else "") + if not tid: + raise RuntimeError("x402 pay_first() did not return taskId") + return create_task_handle( + resolved["baseUrl"], + resolved["a2aVersion"], + str(tid), + str(cid or ""), + x402_deps, + resolved_auth, + resolved.get("tenant"), + None, + ) + + wrapped_payment = X402Payment( + accepts=getattr(x402_payment, "accepts", []) or x402_payment.get("accepts", []), + pay=pay_wrapper, + x402Version=getattr(x402_payment, "x402Version", None) or x402_payment.get("x402Version"), + error=getattr(x402_payment, "error", None) or x402_payment.get("error"), + resource=getattr(x402_payment, "resource", None) or x402_payment.get("resource"), + price=getattr(x402_payment, "price", None) or x402_payment.get("price"), + token=getattr(x402_payment, "token", None) or x402_payment.get("token"), + network=getattr(x402_payment, "network", None) or x402_payment.get("network"), + pay_first=pay_first_wrapper if orig_pay_first else None, + ) + return A2APaymentRequired(x402Required=True, x402Payment=wrapped_payment) + + summary = result + tid = getattr(summary, "taskId", None) or (summary.get("taskId") if isinstance(summary, dict) else task_id) + cid = getattr(summary, "contextId", None) or (summary.get("contextId") if isinstance(summary, dict) else "") + return create_task_handle( + resolved["baseUrl"], + resolved["a2aVersion"], + str(tid), + str(cid or ""), + x402_deps, + resolved_auth, + resolved.get("tenant"), + None, + ) diff --git a/agent0_sdk/core/agent.py b/agent0_sdk/core/agent.py index 000d5df..9d566ce 100644 --- a/agent0_sdk/core/agent.py +++ b/agent0_sdk/core/agent.py @@ -25,6 +25,7 @@ logger = logging.getLogger(__name__) from .transaction_handle import TransactionHandle +from .mcp_client import create_mcp_handle from .a2a import ( MessageResponse, TaskResponse, @@ -60,6 +61,7 @@ def __init__(self, sdk: "SDK", registration_file: RegistrationFile): self._endpoint_crawler = EndpointCrawler(timeout=5) # Lazy A2A resolution cache (baseUrl, a2aVersion, binding, tenant, auth) self._cached_a2a: Optional[Dict[str, Any]] = None + self._mcp_handle: Optional[Any] = None # Read-only properties for direct access @property @@ -167,6 +169,34 @@ def mcpEndpoint(self) -> Optional[str]: return endpoint.value return None + @property + def mcp(self) -> Any: + """Lazy MCP JSON-RPC handle (Streamable HTTP); uses SDK x402 deps when configured.""" + if self._mcp_handle is not None: + return self._mcp_handle + endpoint = self.mcpEndpoint + if not endpoint or not ( + str(endpoint).startswith("http://") or str(endpoint).startswith("https://") + ): + raise RuntimeError("Agent has no MCP endpoint") + mcp_ep = next( + ( + e + for e in self.registration_file.endpoints + if e.type == EndpointType.MCP + ), + None, + ) + version = "2025-06-18" + if mcp_ep and mcp_ep.meta.get("version") is not None: + version = str(mcp_ep.meta.get("version")) + self._mcp_handle = create_mcp_handle( + str(endpoint), + {"protocolVersion": version}, + self.sdk.getX402RequestDeps(), + ) + return self._mcp_handle + @property def a2aEndpoint(self) -> Optional[str]: """Get A2A endpoint value (read-only).""" @@ -515,6 +545,8 @@ def removeEndpoint( ] self.registration_file.updatedAt = int(time.time()) + if self.mcpEndpoint is None: + self._mcp_handle = None return self def removeEndpoints(self) -> 'Agent': diff --git a/agent0_sdk/core/mcp_client.py b/agent0_sdk/core/mcp_client.py new file mode 100644 index 0000000..9bf4e52 --- /dev/null +++ b/agent0_sdk/core/mcp_client.py @@ -0,0 +1,543 @@ +""" +MCP Streamable HTTP JSON-RPC client with optional x402. Mirrors agent0-ts src/core/mcp-client.ts. +All methods are synchronous (blocking). +""" + +from __future__ import annotations + +import json +import re +import uuid +from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING + +from .x402_request import request_with_x402 +from .x402_types import X402RequiredResponse, isX402Required + +from .mcp_types import ( + MCPAuthOptions, + MCPClientInfo, + MCPClientOptions, + MCPInitializeResult, + MCPPrompt, + MCPPromptGetResult, + MCPResource, + MCPResourceTemplate, + MCPTool, +) + +if TYPE_CHECKING: + from .x402_request import X402RequestDeps + +DEFAULT_PROTOCOL_VERSION = "2025-06-18" +SESSION_HEADER = "Mcp-Session-Id" + +_IDENTIFIER_SAFE = re.compile(r"^[A-Za-z_$][A-Za-z0-9_$]*$") + + +def _is_identifier_safe(name: str) -> bool: + return bool(_IDENTIFIER_SAFE.match(name)) + + +def _normalize_bearer(credential: Optional[str]) -> Optional[str]: + if not credential: + return None + trimmed = credential.strip() + if not trimmed: + return None + if trimmed.lower().startswith("bearer "): + return trimmed + return f"Bearer {trimmed}" + + +def _parse_sse_json(text: str) -> Optional[Dict[str, Any]]: + for line in text.split("\n"): + if not line.startswith("data:"): + continue + payload = line[5:].strip() + if not payload: + continue + try: + return json.loads(payload) + except json.JSONDecodeError: + continue + return None + + +def _extract_json_rpc_body(text: str, content_type: str) -> Dict[str, Any]: + trimmed = text.strip() + if not trimmed: + raise RuntimeError("MCP server returned empty response") + ct_lower = content_type.lower() + if "text/event-stream" in ct_lower: + parsed = _parse_sse_json(trimmed) + if not parsed: + raise RuntimeError("MCP server returned invalid SSE JSON-RPC response") + return parsed + try: + return json.loads(trimmed) + except json.JSONDecodeError: + parsed = _parse_sse_json(trimmed) + if parsed: + return parsed + raise RuntimeError("MCP server returned non-JSON response") from None + + +def _parse_json_rpc_result(data: Dict[str, Any], method: str) -> Any: + err = data.get("error") + if err and isinstance(err, dict): + raise RuntimeError(f"MCP {method} failed: {err.get('message') or err.get('code') or 'unknown error'}") + if "result" not in data: + raise RuntimeError(f"MCP {method} failed: missing JSON-RPC result") + return data["result"] + + +def _response_header(resp: Any, name: str) -> Optional[str]: + h = getattr(resp, "headers", None) + if not h: + return None + if hasattr(h, "get"): + v = h.get(name) + if v is not None: + return str(v) + try: + for k, v in h.items(): + if str(k).lower() == name.lower(): + return str(v) + except Exception: + pass + return None + + +def _response_status(resp: Any) -> int: + return int(getattr(resp, "status_code", None) or getattr(resp, "status", None) or 0) + + +def _response_text(resp: Any) -> str: + t = getattr(resp, "text", None) + if t is not None: + return str(t) + content = getattr(resp, "content", None) + if content is not None: + return content.decode("utf-8", errors="replace") + return "" + + +def _cast_x402(result: X402RequiredResponse) -> X402RequiredResponse: + return result + + +class MCPClient: + """MCP client over HTTP POST JSON-RPC.""" + + def __init__( + self, + endpoint: str, + options: Optional[MCPClientOptions] = None, + x402_deps: Optional["X402RequestDeps"] = None, + ): + self._endpoint = endpoint + self._options: Dict[str, Any] = dict(options or {}) + self._x402_deps = x402_deps + self._initialized = False + self._session_id: Optional[str] = self._options.get("sessionId") + self._protocol_version: str = ( + self._options.get("protocolVersion") or DEFAULT_PROTOCOL_VERSION + ) + self._server_caps: Optional[Dict[str, Any]] = None + self._tools_cache: Optional[List[MCPTool]] = None + self._dynamic_tools: Dict[str, Callable[..., Any]] = {} + + def _base_headers(self, auth: Optional[MCPAuthOptions] = None) -> Dict[str, str]: + opts = self._options + cred = None + if auth and auth.get("credential"): + cred = auth.get("credential") + elif opts.get("credential"): + cred = opts.get("credential") + bearer = _normalize_bearer(cred) + headers: Dict[str, str] = { + "Content-Type": "application/json", + "Accept": "application/json, text/event-stream", + "MCP-Protocol-Version": self._protocol_version, + } + headers.update(opts.get("headers") or {}) + if auth and auth.get("headers"): + headers.update(auth["headers"]) + if bearer: + headers["Authorization"] = bearer + if self._session_id: + headers[SESSION_HEADER] = self._session_id + return headers + + def _post_json_rpc( + self, + method: str, + params: Optional[Dict[str, Any]], + auth: Optional[MCPAuthOptions] = None, + ) -> Any: + body: Dict[str, Any] = { + "jsonrpc": "2.0", + "id": f"{method}-{uuid.uuid4().hex[:12]}", + "method": method, + } + if params is not None: + body["params"] = params + headers = self._base_headers(auth) + body_str = json.dumps(body) + + def parse_response(res: Any) -> Any: + new_session = _response_header(res, SESSION_HEADER) + if new_session: + self._session_id = new_session + status = _response_status(res) + if self._session_id and status == 404: + self._initialized = False + self._session_id = None + raise RuntimeError("MCP session expired") + text = _response_text(res) + ct = _response_header(res, "content-type") or "" + data = _extract_json_rpc_body(text, ct) + return _parse_json_rpc_result(data, method) + + if self._x402_deps is not None: + result = request_with_x402( + { + "url": self._endpoint, + "method": "POST", + "headers": headers, + "body": body_str, + "parseResponse": parse_response, + }, + self._x402_deps, + ) + if isX402Required(result): + return result + return result + + import requests + + res = requests.request("POST", self._endpoint, headers=headers, data=body_str) + new_session = _response_header(res, SESSION_HEADER) + if new_session: + self._session_id = new_session + status = _response_status(res) + if self._session_id and status == 404: + self._initialized = False + self._session_id = None + raise RuntimeError("MCP session expired") + if not res.ok: + raise RuntimeError(f"MCP {method} failed: HTTP {status}") + text = _response_text(res) + ct = _response_header(res, "content-type") or "" + data = _extract_json_rpc_body(text, ct) + return _parse_json_rpc_result(data, method) + + def _ensure_initialized(self, auth: Optional[MCPAuthOptions] = None) -> Optional[X402RequiredResponse]: + if self._initialized: + return None + client_info: MCPClientInfo = self._options.get("clientInfo") or { + "name": "agent0-py", + "version": "1.0.0", + } + init_result = self._post_json_rpc( + "initialize", + { + "protocolVersion": self._protocol_version, + "capabilities": {}, + "clientInfo": client_info, + }, + auth, + ) + if isX402Required(init_result): + return init_result # type: ignore[return-value] + initialized = init_result # type: MCPInitializeResult + pv = initialized.get("protocolVersion") + if pv: + self._protocol_version = str(pv) + caps = initialized.get("capabilities") + if caps is not None and isinstance(caps, dict): + self._server_caps = caps + else: + self._server_caps = None + + notif = {"jsonrpc": "2.0", "method": "notifications/initialized"} + notif_headers = self._base_headers(auth) + notif_str = json.dumps(notif) + + def parse_notif(res: Any) -> Any: + sid = _response_header(res, SESSION_HEADER) + if sid: + self._session_id = sid + status = _response_status(res) + if status != 202 and not (200 <= status < 300): + raise RuntimeError( + f"MCP initialized notification failed: HTTP {status}" + ) + return {} + + if self._x402_deps is not None: + request_with_x402( + { + "url": self._endpoint, + "method": "POST", + "headers": notif_headers, + "body": notif_str, + "parseResponse": parse_notif, + }, + self._x402_deps, + ) + else: + import requests + + res = requests.request( + "POST", + self._endpoint, + headers=notif_headers, + data=notif_str, + ) + sid = _response_header(res, SESSION_HEADER) + if sid: + self._session_id = sid + status = _response_status(res) + if status != 202 and not (200 <= status < 300): + raise RuntimeError( + f"MCP initialized notification failed: HTTP {status}" + ) + + self._initialized = True + return None + + def _advertises(self, feature: str) -> bool: + if self._server_caps is None: + return True + return feature in self._server_caps + + def initialize(self, options: Optional[MCPAuthOptions] = None) -> Any: + res = self._ensure_initialized(options) + if res is not None and isX402Required(res): + return _cast_x402(res) + return {"protocolVersion": self._protocol_version} + + def listTools(self, options: Optional[MCPAuthOptions] = None) -> Any: + init = self._ensure_initialized(options) + if init is not None and isX402Required(init): + return _cast_x402(init) + if self._tools_cache is not None: + return self._tools_cache + out: List[MCPTool] = [] + cursor: Optional[str] = None + while True: + page = self._post_json_rpc( + "tools/list", + {"cursor": cursor} if cursor else {}, + options, + ) + if isX402Required(page): + return _cast_x402(page) # type: ignore[arg-type] + p = page # type: Dict[str, Any] + out.extend(p.get("tools") or []) + cursor = p.get("nextCursor") + if not cursor: + break + self._tools_cache = out + self._rebuild_dynamic_tools(out) + return out + + def _rebuild_dynamic_tools(self, tools: List[MCPTool]) -> None: + self._dynamic_tools = {} + for tool in tools: + name = tool.get("name") + if not name: + continue + + def make_caller( + n: str, + ) -> Callable[..., Any]: + def _fn( + args: Optional[Dict[str, Any]] = None, + opts: Optional[MCPAuthOptions] = None, + ) -> Any: + return self.call(n, args, opts) + + return _fn + + self._dynamic_tools[str(name)] = make_caller(str(name)) + + def call( + self, + name: str, + args: Optional[Dict[str, Any]] = None, + options: Optional[MCPAuthOptions] = None, + ) -> Any: + init = self._ensure_initialized(options) + if init is not None and isX402Required(init): + return _cast_x402(init) + return self._post_json_rpc( + "tools/call", + {"name": name, "arguments": args or {}}, + options, + ) + + @property + def prompts(self) -> Any: + return _MCPPromptsNamespace(self) + + @property + def resources(self) -> Any: + return _MCPResourcesNamespace(self) + + @property + def tools(self) -> Dict[str, Callable[..., Any]]: + return self._dynamic_tools + + def getSessionId(self) -> Optional[str]: + return self._session_id + + def setSessionId(self, session_id: Optional[str] = None) -> None: + self._session_id = session_id + if session_id: + self._initialized = True + + def resetSession(self) -> None: + self._session_id = None + self._initialized = False + self._server_caps = None + self._tools_cache = None + + +class _MCPPromptsNamespace: + def __init__(self, client: MCPClient): + self._c = client + + def list(self, options: Optional[MCPAuthOptions] = None) -> Any: + init = self._c._ensure_initialized(options) + if init is not None and isX402Required(init): + return _cast_x402(init) + if not self._c._advertises("prompts"): + return [] # type: ignore[return-value] + out: List[MCPPrompt] = [] + cursor: Optional[str] = None + while True: + page = self._c._post_json_rpc( + "prompts/list", + {"cursor": cursor} if cursor else {}, + options, + ) + if isX402Required(page): + return _cast_x402(page) # type: ignore[arg-type] + p = page # type: Dict[str, Any] + out.extend(p.get("prompts") or []) + cursor = p.get("nextCursor") + if not cursor: + break + return out + + def get( + self, + name: str, + args: Optional[Dict[str, Any]] = None, + options: Optional[MCPAuthOptions] = None, + ) -> Any: + init = self._c._ensure_initialized(options) + if init is not None and isX402Required(init): + return _cast_x402(init) + if not self._c._advertises("prompts"): + raise RuntimeError("MCP server did not advertise prompts capability") + return self._c._post_json_rpc( + "prompts/get", + {"name": name, "arguments": args or {}}, + options, + ) + + +class _MCPResourcesTemplates: + def __init__(self, client: MCPClient): + self._c = client + + def list(self, options: Optional[MCPAuthOptions] = None) -> Any: + init = self._c._ensure_initialized(options) + if init is not None and isX402Required(init): + return _cast_x402(init) + if not self._c._advertises("resources"): + return [] # type: ignore[return-value] + out: List[MCPResourceTemplate] = [] + cursor: Optional[str] = None + while True: + page = self._c._post_json_rpc( + "resources/templates/list", + {"cursor": cursor} if cursor else {}, + options, + ) + if isX402Required(page): + return _cast_x402(page) # type: ignore[arg-type] + p = page # type: Dict[str, Any] + out.extend(p.get("resourceTemplates") or []) + cursor = p.get("nextCursor") + if not cursor: + break + return out + + +class _MCPResourcesNamespace: + def __init__(self, client: MCPClient): + self._c = client + self.templates = _MCPResourcesTemplates(client) + + def list(self, options: Optional[MCPAuthOptions] = None) -> Any: + init = self._c._ensure_initialized(options) + if init is not None and isX402Required(init): + return _cast_x402(init) + if not self._c._advertises("resources"): + return [] # type: ignore[return-value] + out: List[MCPResource] = [] + cursor: Optional[str] = None + while True: + page = self._c._post_json_rpc( + "resources/list", + {"cursor": cursor} if cursor else {}, + options, + ) + if isX402Required(page): + return _cast_x402(page) # type: ignore[arg-type] + p = page # type: Dict[str, Any] + out.extend(p.get("resources") or []) + cursor = p.get("nextCursor") + if not cursor: + break + return out + + def read(self, uri: str, options: Optional[MCPAuthOptions] = None) -> Any: + init = self._c._ensure_initialized(options) + if init is not None and isX402Required(init): + return _cast_x402(init) + if not self._c._advertises("resources"): + raise RuntimeError("MCP server did not advertise resources capability") + return self._c._post_json_rpc("resources/read", {"uri": uri}, options) + + +class _MCPHandleProxy: + """Proxy unknown identifier-safe attributes to tools/call.""" + + __slots__ = ("_client",) + + def __init__(self, client: MCPClient): + object.__setattr__(self, "_client", client) + + def __getattr__(self, name: str) -> Any: + if name.startswith("_"): + raise AttributeError(name) + client: MCPClient = object.__getattribute__(self, "_client") + if _is_identifier_safe(name) and not hasattr(MCPClient, name): + return lambda args=None, options=None: client.call( + name, args or {}, options + ) + return getattr(client, name) + + +def create_mcp_handle( + endpoint: str, + options: Optional[MCPClientOptions] = None, + x402_deps: Optional["X402RequestDeps"] = None, +) -> Any: + """Return MCP client with dynamic tool names as attributes (like TS Proxy).""" + return _MCPHandleProxy(MCPClient(endpoint, options, x402_deps)) diff --git a/agent0_sdk/core/mcp_summary_client.py b/agent0_sdk/core/mcp_summary_client.py new file mode 100644 index 0000000..bb1297d --- /dev/null +++ b/agent0_sdk/core/mcp_summary_client.py @@ -0,0 +1,73 @@ +""" +Lazy MCP client from AgentSummary only. Mirrors agent0-ts mcp-summary-client.ts. +""" + +from __future__ import annotations + +from typing import Any, Dict, Optional, Protocol, TYPE_CHECKING, Union + +from .mcp_client import create_mcp_handle + +if TYPE_CHECKING: + from .mcp_types import MCPAuthOptions, MCPClientOptions + from .models import AgentSummary + + +class SDKLikeMCP(Protocol): + """Minimal SDK surface for x402 deps.""" + + def getX402RequestDeps(self): # noqa: N802 + ... + + +class MCPClientFromSummary: + """Resolve MCP endpoint from AgentSummary.mcp on first use.""" + + def __init__( + self, + sdk: SDKLikeMCP, + summary: "AgentSummary", + options: Optional[Dict[str, Any]] = None, + ): + self._sdk = sdk + self._summary = summary + self._options: Dict[str, Any] = dict(options or {}) + self._client: Optional[Any] = None + + def _ensure_client(self) -> Any: + if self._client is not None: + return self._client + endpoint = getattr(self._summary, "mcp", None) + if not endpoint or not ( + str(endpoint).startswith("http://") or str(endpoint).startswith("https://") + ): + raise RuntimeError("Agent summary has no MCP endpoint") + self._client = create_mcp_handle( + str(endpoint), + self._options, + getattr(self._sdk, "getX402RequestDeps", lambda: None)(), + ) + return self._client + + @property + def prompts(self) -> Any: + return self._ensure_client().prompts + + @property + def resources(self) -> Any: + return self._ensure_client().resources + + @property + def tools(self) -> Any: + return self._ensure_client().tools + + def listTools(self, options: Optional["MCPAuthOptions"] = None) -> Any: + return self._ensure_client().listTools(options) + + def call( + self, + name: str, + args: Optional[Dict[str, Any]] = None, + options: Optional["MCPAuthOptions"] = None, + ) -> Any: + return self._ensure_client().call(name, args, options) \ No newline at end of file diff --git a/agent0_sdk/core/mcp_types.py b/agent0_sdk/core/mcp_types.py new file mode 100644 index 0000000..d423383 --- /dev/null +++ b/agent0_sdk/core/mcp_types.py @@ -0,0 +1,88 @@ +""" +MCP client types (Streamable HTTP JSON-RPC). Mirrors agent0-ts src/models/mcp.ts. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, TypedDict + + +class MCPClientInfo(TypedDict, total=False): + name: str + title: str + version: str + + +class MCPAuthOptions(TypedDict, total=False): + credential: str + headers: Dict[str, str] + + +class MCPClientOptions(TypedDict, total=False): + credential: str + headers: Dict[str, str] + protocolVersion: str + sessionId: str + clientInfo: MCPClientInfo + + +class MCPInitializeResult(TypedDict, total=False): + protocolVersion: str + capabilities: Dict[str, Any] + serverInfo: Dict[str, Any] + instructions: str + + +class MCPTool(TypedDict, total=False): + name: str + title: str + description: str + inputSchema: Dict[str, Any] + outputSchema: Dict[str, Any] + annotations: Dict[str, Any] + + +class MCPPrompt(TypedDict, total=False): + name: str + title: str + description: str + arguments: List[Dict[str, Any]] + + +class MCPPromptMessage(TypedDict, total=False): + role: str + content: Dict[str, Any] + + +class MCPPromptGetResult(TypedDict, total=False): + description: str + messages: List[MCPPromptMessage] + + +class MCPResource(TypedDict, total=False): + uri: str + name: str + title: str + description: str + mimeType: str + size: int + annotations: Dict[str, Any] + + +class MCPResourceTemplate(TypedDict, total=False): + uriTemplate: str + name: str + title: str + description: str + mimeType: str + annotations: Dict[str, Any] + + +class MCPResourceContent(TypedDict, total=False): + uri: str + mimeType: str + text: str + blob: str + + +MCPOptions = MCPAuthOptions # alias for call sites diff --git a/agent0_sdk/core/models.py b/agent0_sdk/core/models.py index 69b98ac..69e5811 100644 --- a/agent0_sdk/core/models.py +++ b/agent0_sdk/core/models.py @@ -31,6 +31,29 @@ class EndpointType(Enum): OASF = "OASF" +# Legacy + human-readable service names in on-chain/IPFS registration JSON (mirrors TS sdk ENDPOINT_TYPE_MAP). +_ENDPOINT_LEGACY_LABEL_TO_TYPE: Dict[str, EndpointType] = { + "mcp": EndpointType.MCP, + "a2a": EndpointType.A2A, + "ens": EndpointType.ENS, + "did": EndpointType.DID, + "agentwallet": EndpointType.WALLET, + "wallet": EndpointType.WALLET, + "agent card": EndpointType.A2A, +} + + +def _coerce_endpoint_type(raw: str) -> EndpointType: + """Map registration endpoint label to EndpointType (case-insensitive).""" + key = str(raw).strip().lower() + if key in _ENDPOINT_LEGACY_LABEL_TO_TYPE: + return _ENDPOINT_LEGACY_LABEL_TO_TYPE[key] + for et in EndpointType: + if et.value.lower() == key or et.name.lower() == key: + return et + raise ValueError(f"Unknown endpoint type in registration: {raw!r}") + + class TrustModel(Enum): """Trust models supported by the SDK.""" REPUTATION = "reputation" @@ -120,16 +143,38 @@ def from_dict(cls, data: Dict[str, Any]) -> RegistrationFile: endpoints = [] raw_services = data.get("services", data.get("endpoints", [])) for ep_data in raw_services: + if not isinstance(ep_data, dict): + continue + # New shape: { type, value, meta?, ... } (matches TS _transformEndpoints when type+value set) + if ep_data.get("type") is not None and "value" in ep_data: + raw_type = str(ep_data["type"]) + if raw_type.strip().lower().replace(" ", "") in ("agentwallet",): + continue + ep_type = _coerce_endpoint_type(raw_type) + ep_value = str(ep_data["value"]) + ep_meta: Dict[str, Any] = ( + dict(ep_data["meta"]) if isinstance(ep_data.get("meta"), dict) else {} + ) + for k, v in ep_data.items(): + if k in ("type", "value", "meta"): + continue + ep_meta.setdefault(k, v) + endpoints.append(Endpoint(type=ep_type, value=ep_value, meta=ep_meta)) + continue + # Legacy shape: { name, endpoint, ... } + if "name" not in ep_data: + continue name = ep_data["name"] - # Special handling for agentWallet - it's not a standard endpoint type if name == "agentWallet": - # Skip agentWallet endpoints as they're handled separately via walletAddress field continue - - ep_type = EndpointType(name) - ep_value = ep_data["endpoint"] - ep_meta = {k: v for k, v in ep_data.items() if k not in ["name", "endpoint"]} - endpoints.append(Endpoint(type=ep_type, value=ep_value, meta=ep_meta)) + ep_type = _coerce_endpoint_type(str(name)) + ep_value = ep_data.get("endpoint", ep_data.get("value", "")) + ep_meta = { + k: v + for k, v in ep_data.items() + if k not in ("name", "endpoint", "value") + } + endpoints.append(Endpoint(type=ep_type, value=str(ep_value), meta=ep_meta)) trust_models = [] for tm in data.get("supportedTrust", []): diff --git a/agent0_sdk/core/sdk.py b/agent0_sdk/core/sdk.py index 0bf84ed..ef18162 100644 --- a/agent0_sdk/core/sdk.py +++ b/agent0_sdk/core/sdk.py @@ -8,7 +8,7 @@ import json import logging import time -from typing import Any, Dict, List, Optional, Union, Literal +from typing import Any, Dict, List, Optional, Union, Literal, cast from datetime import datetime logger = logging.getLogger(__name__) @@ -26,7 +26,10 @@ ) from .agent import Agent from .indexer import AgentIndexer -from .a2a_summary_client import A2AClientFromSummary +from .a2a_summary_client import A2AClientFromSummary, A2AClientFromUrl +from .mcp_summary_client import MCPClientFromSummary +from .mcp_types import MCPClientOptions +from .mcp_client import create_mcp_handle from .ipfs_client import IPFSClient from .feedback_manager import FeedbackManager from .transaction_handle import TransactionHandle @@ -378,13 +381,39 @@ def getX402RequestDeps(self) -> X402RequestDeps: def createA2AClient( self, - agent_or_summary: Union[Agent, AgentSummary], - ) -> Union[Agent, A2AClientFromSummary]: - """Return an A2A client: Agent as-is, AgentSummary wrapped in A2AClientFromSummary.""" + agent_or_summary: Union[Agent, AgentSummary, str], + ) -> Union[Agent, A2AClientFromSummary, A2AClientFromUrl]: + """Return an A2A client: Agent as-is, AgentSummary wrapped, or URL-backed client.""" if isinstance(agent_or_summary, Agent): return agent_or_summary + if isinstance(agent_or_summary, str): + return A2AClientFromUrl(self, agent_or_summary) return A2AClientFromSummary(self, agent_or_summary) + def createMCPClient( + self, + agent_or_summary: Union[Agent, AgentSummary, str], + options: Optional[MCPClientOptions] = None, + ) -> Any: + """MCP handle from a loaded Agent, AgentSummary.mcp, or direct MCP URL.""" + opts: Dict[str, Any] = dict(options) if options else {} + if isinstance(agent_or_summary, str): + return create_mcp_handle(agent_or_summary, opts, self.getX402RequestDeps()) + if isinstance(agent_or_summary, Agent): + sid = opts.get("sessionId") + if sid is not None: + agent_or_summary.mcp.setSessionId(str(sid)) + return agent_or_summary.mcp + return MCPClientFromSummary(self, agent_or_summary, opts) + + def create_mcp_client( + self, + agent_or_summary: Union[Agent, AgentSummary, str], + options: Optional[MCPClientOptions] = None, + ) -> Any: + """Backward-compatible alias for createMCPClient().""" + return self.createMCPClient(agent_or_summary, options) + # Agent lifecycle methods def createAgent( self, diff --git a/agent0_sdk/core/x402_payment.py b/agent0_sdk/core/x402_payment.py index 684c3fd..d9c9b6f 100644 --- a/agent0_sdk/core/x402_payment.py +++ b/agent0_sdk/core/x402_payment.py @@ -56,14 +56,14 @@ def _random_bytes32_hex() -> str: def _token_address(accept: X402Accept, web3_client: Any) -> str: - raw = accept.token or accept.asset or "" + raw = str(accept.token or accept.asset or "").strip() if not raw or not web3_client.is_address(raw): raise ValueError("x402: accept has no valid token/asset address") return web3_client.to_checksum_address(raw) def _destination_address(accept: X402Accept, web3_client: Any) -> str: - raw = accept.destination or accept.get("payTo") or "" + raw = str(accept.destination or accept.get("payTo") or "").strip() if not raw or not web3_client.is_address(raw): raise ValueError("x402: accept has no valid destination/payTo address") return web3_client.to_checksum_address(raw) @@ -107,20 +107,32 @@ def _get_token_domain( def check_evm_balance(accept: X402Accept, web3_client: Any) -> bool: - """Return True if signer has sufficient token balance for the accept.""" - try: - token = _token_address(accept, web3_client) - if not web3_client.account: + """Return True if signer has sufficient token balance for the accept. + + Retries once after a short sleep when the RPC error looks like HTTP 429 + (common on public Base RPC). Without this, pay() can reject with + 'no accept with sufficient balance' even when balance is fine, because + transient RPC errors are indistinguishable from failure here. + """ + for attempt in range(2): + try: + token = _token_address(accept, web3_client) + if not web3_client.account: + return False + signer = web3_client.account.address + contract = web3_client.get_contract(token, BALANCE_OF_ABI) + balance = web3_client.call_contract(contract, "balanceOf", signer) + price = int(_value_amount(accept)) + if hasattr(balance, "real"): + balance = int(balance) + return balance >= price + except Exception as ex: + err_s = str(ex).lower() + if attempt == 0 and ("429" in err_s or "too many requests" in err_s): + time.sleep(0.75) + continue return False - signer = web3_client.account.address - contract = web3_client.get_contract(token, BALANCE_OF_ABI) - balance = web3_client.call_contract(contract, "balanceOf", signer) - price = int(_value_amount(accept)) - if hasattr(balance, "real"): - balance = int(balance) - return balance >= price - except Exception: - return False + return False def build_evm_payment( @@ -189,17 +201,22 @@ def build_evm_payment( _compact = {"separators": (",", ":")} if is_v2: + # Match TS buildEvmPayment: top-level numeric maxTimeoutSeconds only; else 60 (not nested extra). + max_timeout = ( + int(accept.maxTimeoutSeconds) + if accept.maxTimeoutSeconds is not None + else 60 + ) accepted = { "scheme": scheme, "network": network_str, "amount": value, "asset": token, "payTo": pay_to, - "maxTimeoutSeconds": accept.get("maxTimeoutSeconds", 60), + "maxTimeoutSeconds": max_timeout, } if accept.extra: - # Send only server-style extra (e.g. name, version); omit maxTimeoutSeconds so it's only at top level (match TS) - accepted["extra"] = {k: v for k, v in accept.extra.items() if k != "maxTimeoutSeconds"} + accepted["extra"] = dict(accept.extra) payload_v2 = {"x402Version": 2, "scheme": scheme, "network": network_str} if snapshot and snapshot.resource: payload_v2["resource"] = { diff --git a/agent0_sdk/core/x402_types.py b/agent0_sdk/core/x402_types.py index cc01b4d..0d780f8 100644 --- a/agent0_sdk/core/x402_types.py +++ b/agent0_sdk/core/x402_types.py @@ -32,12 +32,16 @@ class X402Accept: destination: Optional[str] = None asset: Optional[str] = None extra: Dict[str, Any] = None + # TS buildEvmPayment: only typeof accept.maxTimeoutSeconds === 'number' counts; nested extra does not. + maxTimeoutSeconds: Optional[int] = None def __post_init__(self): if self.extra is None: self.extra = {} def get(self, key: str, default: Any = None) -> Any: + if key == "maxTimeoutSeconds" and self.maxTimeoutSeconds is not None: + return self.maxTimeoutSeconds return self.extra.get(key, default) @@ -173,10 +177,13 @@ def _normalize_accept_entry(entry: Dict[str, Any]) -> X402Accept: # Match TS: extra = only the server's "extra" field (e.g. { name, version }), not the whole entry extra_raw = pr.get("extra") or entry.get("extra") extra = dict(extra_raw) if isinstance(extra_raw, dict) else {} - # Preserve maxTimeoutSeconds so accept.get("maxTimeoutSeconds", 60) returns server value (TS has it from ...entry) - for key in ("maxTimeoutSeconds",): - if (pr.get(key) is not None) or (entry.get(key) is not None): - extra[key] = pr.get(key) if pr.get(key) is not None else entry.get(key) + mt: Optional[int] = None + for src in (entry, pr): + if isinstance(src, dict): + v = src.get("maxTimeoutSeconds") + if isinstance(v, (int, float)): + mt = int(v) + break return X402Accept( price=price, token=token, @@ -187,6 +194,7 @@ def _normalize_accept_entry(entry: Dict[str, Any]) -> X402Accept: destination=pr.get("destination") or pr.get("payTo"), asset=pr.get("asset"), extra=extra, + maxTimeoutSeconds=mt, ) diff --git a/examples/.env.example b/examples/.env.example index a0f948d..e747f13 100644 --- a/examples/.env.example +++ b/examples/.env.example @@ -7,8 +7,13 @@ # Required for x402/A2A demo (PRIVATE_KEY or AGENT_PRIVATE_KEY — same as TS demo) PRIVATE_KEY=0x... +# Primary SDK RPC (default CHAIN_ID=84532 Base Sepolia in x402_a2a_demo.py — keep this on testnet) RPC_URL=https://base-sepolia.drpc.org +# MCP demo (examples/mcp_demo.py) uses chain 8453 + agent 8453:28350 — needs Base MAINNET, not Sepolia. +# If RPC_URL points to Base Sepolia, set this so loadAgent() hits the right chain: +DELX_RPC_URL=https://mainnet.base.org + # Chain (default 84532 = Base Sepolia, same as TS demo) # CHAIN_ID=84532 diff --git a/examples/mcp_demo.py b/examples/mcp_demo.py new file mode 100644 index 0000000..39bb2f5 --- /dev/null +++ b/examples/mcp_demo.py @@ -0,0 +1,132 @@ +#!/usr/bin/env python3 +""" +MCP demo: list tools, get_affirmation, then generate_controller_brief (may charge via x402 on 402). + +generate_controller_brief needs a Delx session_id; run quick_session once and parse the UUID from the reply text. +On 402, logs pay.accepts (payment options), then pay() (SDK picks first accept with sufficient balance), then prints the tool result. + +Loads .env from examples/ or project root. Env: RPC_URL / DELX_RPC_URL, PRIVATE_KEY / AGENT_PRIVATE_KEY (optional for unpaid steps). + + python examples/mcp_demo.py +""" + +from __future__ import annotations + +import json +import os +import re +import sys +from dataclasses import asdict, is_dataclass +from pathlib import Path +from typing import Any, Optional + +_root = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(_root)) + +try: + from dotenv import load_dotenv + + _env_file = Path(__file__).resolve().parent / ".env" + if not _env_file.exists(): + _env_file = _root / ".env" + load_dotenv(dotenv_path=_env_file) +except ImportError: + pass + +from agent0_sdk import SDK, isX402Required # noqa: E402 + +DELX_AGENT_ID = "8453:28350" + + +def _env(key: str, default: str = "") -> str: + return os.environ.get(key, default).strip() + + +def _json_serializable(obj: Any) -> Any: + if is_dataclass(obj) and not isinstance(obj, type): + return asdict(obj) + if isinstance(obj, dict): + return {k: _json_serializable(v) for k, v in obj.items()} + if isinstance(obj, (list, tuple)): + return [_json_serializable(v) for v in obj] + return obj + + +def session_id_from_quick_session(result: Any) -> Optional[str]: + if not isinstance(result, dict): + return None + content = result.get("content") or [] + if not content: + return None + first = content[0] + text = first.get("text") if isinstance(first, dict) else None + if not text: + return None + m = re.search(r"Session ID:\s*([0-9a-f-]{36})", text, re.I) + return m.group(1) if m else None + + +def main() -> None: + private_key = _env("PRIVATE_KEY") or _env("AGENT_PRIVATE_KEY") + rpc_url = _env("DELX_RPC_URL") or _env("RPC_URL") or "https://mainnet.base.org" + + sdk = SDK( + chainId=8453, + rpcUrl=rpc_url, + signer=private_key if private_key else None, + ) + + agent = sdk.loadAgent(DELX_AGENT_ID) + + tools = agent.mcp.listTools() + if isX402Required(tools): + print("listTools returned 402; pay then re-run list if needed.") + return + print("Tools:", ", ".join(t.get("name", "") for t in tools if isinstance(t, dict))) + + aff_res = agent.mcp.call("get_affirmation", {}) + if isX402Required(aff_res): + paid = aff_res.x402Payment.pay() + print("get_affirmation:", json.dumps(_json_serializable(paid), indent=2)) + else: + print("get_affirmation:", json.dumps(_json_serializable(aff_res), indent=2)) + + if not private_key: + print("Skip paid tool: set PRIVATE_KEY or AGENT_PRIVATE_KEY.") + return + + qs_res = agent.mcp.call( + "quick_session", + { + "agent_id": "agent0-py-mcp-demo", + "feeling": "mcp_demo before generate_controller_brief", + }, + ) + if isX402Required(qs_res): + qs = qs_res.x402Payment.pay() + else: + qs = qs_res + session_id = session_id_from_quick_session(qs) + if not session_id: + print("Could not parse Session ID from quick_session.") + return + + brief_res = agent.mcp.call( + "generate_controller_brief", + { + "session_id": session_id, + "focus": "x402 demo from agent0-py", + }, + ) + if not isX402Required(brief_res): + print("generate_controller_brief:", json.dumps(_json_serializable(brief_res), indent=2)) + return + + pay = brief_res.x402Payment + print("x402 accepts:", json.dumps(_json_serializable(pay.accepts), indent=2)) + paid = pay.pay() + print("generate_controller_brief:", json.dumps(_json_serializable(paid), indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tests/test_a2a_client.py b/tests/test_a2a_client.py index 1a52507..e19cce4 100644 --- a/tests/test_a2a_client.py +++ b/tests/test_a2a_client.py @@ -14,6 +14,7 @@ parts_for_send, _part_from_dict, ) +from agent0_sdk.core.a2a_summary_client import A2AClientFromUrl class TestNormalizeBinding: @@ -111,3 +112,24 @@ def test_flat_dict(self): p = _part_from_dict({"text": "flat", "url": "https://u"}) assert p.text == "flat" assert p.url == "https://u" + + +class TestA2AClientFromUrl: + def test_resolves_on_first_message(self): + sdk = Mock() + sdk.getX402RequestDeps.return_value = None + client = A2AClientFromUrl(sdk, "https://a2a.example.com") + with patch("agent0_sdk.core.a2a_summary_client.resolve_a2a_from_endpoint_url") as resolve_mock, \ + patch("agent0_sdk.core.a2a_summary_client.send_message") as send_mock: + resolve_mock.return_value = { + "baseUrl": "https://a2a.example.com", + "a2aVersion": "0.3", + "binding": "HTTP+JSON", + "tenant": None, + "auth": None, + } + send_mock.return_value = {"content": "ok"} + result = client.messageA2A("ping") + assert result["content"] == "ok" + resolve_mock.assert_called_once_with("https://a2a.example.com") + send_mock.assert_called_once() diff --git a/tests/test_mcp_client.py b/tests/test_mcp_client.py new file mode 100644 index 0000000..8722f42 --- /dev/null +++ b/tests/test_mcp_client.py @@ -0,0 +1,426 @@ +""" +Tests for MCP client (agent0_sdk.core.mcp_client), summary wrapper, and SDK wiring. +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any, Dict, List, Optional +from unittest.mock import MagicMock, patch + +import pytest + +from agent0_sdk.core.agent import Agent +from agent0_sdk.core.mcp_client import MCPClient, create_mcp_handle +from agent0_sdk.core.mcp_summary_client import MCPClientFromSummary +from agent0_sdk.core.models import AgentSummary, Endpoint, EndpointType, RegistrationFile, TrustModel +from agent0_sdk.core.sdk import SDK +from agent0_sdk.core.x402_request import X402RequestDeps +from agent0_sdk.core.x402_types import X402Accept, RequestSnapshot, isX402Required + + +def _json_resp( + status: int, + body: Optional[Any] = None, + headers: Optional[Dict[str, str]] = None, + content_type: str = "application/json", +) -> MagicMock: + r = MagicMock() + r.status_code = status + r.ok = 200 <= status < 300 + h: Dict[str, str] = {"content-type": content_type} + if headers: + h.update(headers) + + class _Hdr: + def get(self, name: str, default=None): + for k, v in h.items(): + if k.lower() == name.lower(): + return v + return default + + r.headers = _Hdr() + if body is None: + r.text = "" + elif isinstance(body, str): + r.text = body + else: + r.text = json.dumps(body) + return r + + +class TestMCPClient: + def test_initialize_notification_then_list_tools_order(self): + responses = [ + _json_resp( + 200, + { + "jsonrpc": "2.0", + "id": "1", + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {"tools": {}}, + }, + }, + headers={"Mcp-Session-Id": "sess-1"}, + ), + _json_resp(202, ""), + _json_resp( + 200, + { + "jsonrpc": "2.0", + "id": "2", + "result": {"tools": [{"name": "get_weather"}]}, + }, + ), + ] + + with patch("requests.request", side_effect=responses) as req: + client = MCPClient("https://mcp.example.com/mcp") + tools = client.listTools() + + assert tools == [{"name": "get_weather"}] + assert req.call_count == 3 + methods: List[str] = [] + for call in req.call_args_list: + data = call.kwargs.get("data") + if data: + methods.append(json.loads(data)["method"]) + assert methods == ["initialize", "notifications/initialized", "tools/list"] + hdrs = req.call_args_list[2].kwargs["headers"] + assert hdrs.get("Mcp-Session-Id") == "sess-1" + + def test_tool_call(self): + responses = [ + _json_resp( + 200, + {"jsonrpc": "2.0", "id": "1", "result": {"protocolVersion": "2025-06-18"}}, + ), + _json_resp(202, ""), + _json_resp( + 200, + { + "jsonrpc": "2.0", + "id": "2", + "result": { + "content": [{"type": "text", "text": "Sunny"}], + "isError": False, + }, + }, + ), + ] + with patch("requests.request", side_effect=responses): + client = MCPClient("https://mcp.example.com/mcp") + result = client.call("weather/get", {"location": "Paris"}) + assert result["content"][0]["text"] == "Sunny" + + def test_proxy_dynamic_tool(self): + responses = [ + _json_resp( + 200, + {"jsonrpc": "2.0", "id": "1", "result": {"protocolVersion": "2025-06-18"}}, + ), + _json_resp(202, ""), + _json_resp( + 200, + { + "jsonrpc": "2.0", + "id": "2", + "result": {"content": [{"type": "text", "text": "ok"}]}, + }, + ), + ] + with patch("requests.request", side_effect=responses): + handle = create_mcp_handle("https://mcp.example.com/mcp") + result = handle.get_weather({"location": "Rome"}) + assert result["content"][0]["text"] == "ok" + + def test_prompts_list_and_get(self): + responses = [ + _json_resp( + 200, + {"jsonrpc": "2.0", "id": "1", "result": {"protocolVersion": "2025-06-18"}}, + ), + _json_resp(202, ""), + _json_resp( + 200, + { + "jsonrpc": "2.0", + "id": "2", + "result": {"prompts": [{"name": "code_review"}]}, + }, + ), + _json_resp( + 200, + { + "jsonrpc": "2.0", + "id": "3", + "result": { + "messages": [ + {"role": "user", "content": {"type": "text", "text": "review this"}} + ] + }, + }, + ), + ] + with patch("requests.request", side_effect=responses): + client = MCPClient("https://mcp.example.com/mcp") + prompts = client.prompts.list() + got = client.prompts.get("code_review", {"code": "x"}) + assert prompts == [{"name": "code_review"}] + assert got["messages"][0]["role"] == "user" + + def test_resources_list_read_templates(self): + responses = [ + _json_resp( + 200, + {"jsonrpc": "2.0", "id": "1", "result": {"protocolVersion": "2025-06-18"}}, + ), + _json_resp(202, ""), + _json_resp( + 200, + { + "jsonrpc": "2.0", + "id": "2", + "result": {"resources": [{"uri": "file:///a", "name": "a"}]}, + }, + ), + _json_resp( + 200, + { + "jsonrpc": "2.0", + "id": "3", + "result": {"contents": [{"uri": "file:///a", "text": "hello"}]}, + }, + ), + _json_resp( + 200, + { + "jsonrpc": "2.0", + "id": "4", + "result": { + "resourceTemplates": [ + {"uriTemplate": "file:///{path}", "name": "files"} + ] + }, + }, + ), + ] + with patch("requests.request", side_effect=responses): + client = MCPClient("https://mcp.example.com/mcp") + lst = client.resources.list() + read = client.resources.read("file:///a") + templates = client.resources.templates.list() + assert lst == [{"uri": "file:///a", "name": "a"}] + assert read["contents"][0]["text"] == "hello" + assert templates == [{"uriTemplate": "file:///{path}", "name": "files"}] + + def test_bearer_from_credential(self): + responses = [ + _json_resp( + 200, + {"jsonrpc": "2.0", "id": "1", "result": {"protocolVersion": "2025-06-18"}}, + ), + _json_resp(202, ""), + _json_resp( + 200, + {"jsonrpc": "2.0", "id": "2", "result": {"tools": []}}, + ), + ] + with patch("requests.request", side_effect=responses) as req: + client = MCPClient("https://mcp.example.com/mcp", {"credential": "token-123"}) + client.listTools() + last = req.call_args_list[2].kwargs["headers"] + assert last.get("Authorization") == "Bearer token-123" + + def test_initialize_402_pay_retry(self): + accepts_payload = { + "accepts": [ + { + "price": "100", + "token": "0x0000000000000000000000000000000000000001", + "network": "84532", + "destination": "0x0000000000000000000000000000000000000002", + } + ] + } + pay_header = base64.b64encode(json.dumps(accepts_payload).encode()).decode() + r402 = _json_resp(402, headers={"payment-required": pay_header}) + r200 = _json_resp( + 200, + {"jsonrpc": "2.0", "id": "1", "result": {"protocolVersion": "2025-06-18"}}, + ) + n_calls: List[int] = [] + + def fetch(url, method, headers, body, payment_header_name=None, payment_payload=None): + n_calls.append(1) + if len(n_calls) == 1: + return r402 + return r200 + + def build_payment(accept: X402Accept, snapshot: RequestSnapshot) -> str: + return base64.b64encode( + json.dumps( + {"x402Version": 1, "payload": {"signature": "0x" + "a" * 130, "authorization": {}}} + ).encode() + ).decode() + + deps = X402RequestDeps(fetch=fetch, build_payment=build_payment) + client = MCPClient("https://mcp.example.com/mcp", {}, deps) + init = client.initialize() + assert isX402Required(init) + init.x402Payment.pay() + assert len(n_calls) == 2 + + +class TestMCPSummaryAndSDK: + def test_summary_raises_without_mcp_url(self): + summary = AgentSummary( + chainId=1, + agentId="1:1", + name="x", + image=None, + description="x", + owners=[], + operators=[], + ens=None, + did=None, + walletAddress=None, + supportedTrusts=[], + a2aSkills=[], + mcpTools=[], + mcpPrompts=[], + mcpResources=[], + active=True, + mcp=None, + ) + client = MCPClientFromSummary(MagicMock(), summary) + with pytest.raises(RuntimeError, match="no MCP endpoint"): + client.listTools() + + def test_create_mcp_client_agent_same_as_property(self): + with patch("agent0_sdk.core.sdk.Web3Client") as mock_web3: + mock_web3.return_value.chain_id = 84532 + sdk = SDK( + chainId=84532, + rpcUrl="https://base-sepolia.drpc.org", + signer="0x1234567890abcdef", + ) + reg = RegistrationFile( + name="x", + description="x", + endpoints=[ + Endpoint( + type=EndpointType.MCP, + value="https://mcp.example.com/mcp", + meta={"version": "2025-06-18"}, + ) + ], + trustModels=[TrustModel.REPUTATION], + owners=[], + operators=[], + active=True, + x402support=False, + metadata={}, + updatedAt=0, + ) + agent = Agent(sdk, reg) + assert sdk.create_mcp_client(agent) is agent.mcp + + def test_create_mcp_client_applies_session_id_option(self): + with patch("agent0_sdk.core.sdk.Web3Client") as mock_web3: + mock_web3.return_value.chain_id = 84532 + sdk = SDK( + chainId=84532, + rpcUrl="https://base-sepolia.drpc.org", + signer="0x1234567890abcdef", + ) + reg = RegistrationFile( + name="x", + description="x", + endpoints=[ + Endpoint( + type=EndpointType.MCP, + value="https://mcp.example.com/mcp", + meta={"version": "2025-06-18"}, + ) + ], + trustModels=[TrustModel.REPUTATION], + owners=[], + operators=[], + active=True, + x402support=False, + metadata={}, + updatedAt=0, + ) + agent = Agent(sdk, reg) + sdk.create_mcp_client(agent, {"sessionId": "pre-set-session"}) + assert agent.mcp.getSessionId() == "pre-set-session" + + def test_create_mcp_client_summary_lazy(self): + responses = [ + _json_resp( + 200, + {"jsonrpc": "2.0", "id": "1", "result": {"protocolVersion": "2025-06-18"}}, + ), + _json_resp(202, ""), + _json_resp( + 200, + {"jsonrpc": "2.0", "id": "2", "result": {"tools": []}}, + ), + ] + with patch("agent0_sdk.core.sdk.Web3Client") as mock_web3: + mock_web3.return_value.chain_id = 84532 + sdk = SDK( + chainId=84532, + rpcUrl="https://base-sepolia.drpc.org", + signer="0x1234567890abcdef", + ) + summary = AgentSummary( + chainId=1, + agentId="1:1", + name="x", + image=None, + description="x", + owners=[], + operators=[], + ens=None, + did=None, + walletAddress=None, + supportedTrusts=[], + a2aSkills=[], + mcpTools=[], + mcpPrompts=[], + mcpResources=[], + active=True, + mcp="https://mcp.example.com/mcp", + ) + with patch("requests.request", side_effect=responses) as req: + client = sdk.create_mcp_client(summary) + client.listTools() + assert req.call_count == 3 + + def test_create_mcp_client_url(self): + responses = [ + _json_resp( + 200, + {"jsonrpc": "2.0", "id": "1", "result": {"protocolVersion": "2025-06-18"}}, + ), + _json_resp(202, ""), + _json_resp( + 200, + {"jsonrpc": "2.0", "id": "2", "result": {"tools": []}}, + ), + ] + with patch("agent0_sdk.core.sdk.Web3Client") as mock_web3: + mock_web3.return_value.chain_id = 84532 + sdk = SDK( + chainId=84532, + rpcUrl="https://base-sepolia.drpc.org", + signer="0x1234567890abcdef", + ) + with patch("requests.request", side_effect=responses) as req: + client = sdk.create_mcp_client("https://mcp.example.com/mcp") + client.listTools() + assert req.call_count == 3 diff --git a/tests/test_models.py b/tests/test_models.py index f2f9663..f5d0888 100644 --- a/tests/test_models.py +++ b/tests/test_models.py @@ -127,6 +127,43 @@ def test_registration_file_from_dict(self): assert len(rf.trustModels) == 1 assert rf.trustModels[0] == TrustModel.REPUTATION + def test_registration_file_from_dict_agent_card_maps_to_a2a(self): + """Human-readable A2A label used in some on-chain registration files.""" + data = { + "name": "X", + "description": "Y", + "services": [ + { + "name": "Agent Card", + "endpoint": "https://example.com/a2a.json", + "version": "0.3", + } + ], + "supportedTrust": [], + } + rf = RegistrationFile.from_dict(data) + assert len(rf.endpoints) == 1 + assert rf.endpoints[0].type == EndpointType.A2A + assert rf.endpoints[0].value == "https://example.com/a2a.json" + + def test_registration_file_from_dict_type_value_shape(self): + data = { + "name": "X", + "description": "Y", + "services": [ + { + "type": "MCP", + "value": "https://mcp.example.com/", + "meta": {"version": "2025-06-18"}, + } + ], + "supportedTrust": [], + } + rf = RegistrationFile.from_dict(data) + assert rf.endpoints[0].type == EndpointType.MCP + assert rf.endpoints[0].value == "https://mcp.example.com/" + assert rf.endpoints[0].meta.get("version") == "2025-06-18" + class TestAgentSummary: """Test AgentSummary class.""" diff --git a/tests/test_sdk.py b/tests/test_sdk.py index 3f6da92..43c2af0 100644 --- a/tests/test_sdk.py +++ b/tests/test_sdk.py @@ -7,6 +7,7 @@ from agent0_sdk.core.sdk import SDK from agent0_sdk.core.models import EndpointType, TrustModel +from agent0_sdk.core.a2a_summary_client import A2AClientFromUrl class TestSDK: @@ -124,6 +125,17 @@ def test_sdk_init_accepts_registration_data_uri_max_bytes(self): ) assert sdk.registrationDataUriMaxBytes == 1234 + def test_create_a2a_client_accepts_url(self): + with patch('agent0_sdk.core.sdk.Web3Client') as mock_web3: + mock_web3.return_value.chain_id = 11155111 + sdk = SDK( + chainId=11155111, + signer="0x1234567890abcdef", + rpcUrl="https://eth-sepolia.g.alchemy.com/v2/test" + ) + client = sdk.createA2AClient("https://a2a.example.com") + assert isinstance(client, A2AClientFromUrl) + class TestAgent: """Test Agent class.""" diff --git a/tests/test_x402_payment.py b/tests/test_x402_payment.py index 192c215..c083e69 100644 --- a/tests/test_x402_payment.py +++ b/tests/test_x402_payment.py @@ -22,6 +22,18 @@ def test_balance_insufficient(self): accept = X402Accept(price="100", token="0xtoken", network="eip155:1") assert check_evm_balance(accept, mock_client) is False + def test_balance_retries_on_429_then_succeeds(self): + mock_client = Mock() + mock_client.call_contract = Mock( + side_effect=[ + Exception("429 Client Error: Too Many Requests for url: https://mainnet.base.org/"), + 1_000_000, + ] + ) + accept = X402Accept(price="100", token="0xtoken", network="eip155:1") + assert check_evm_balance(accept, mock_client) is True + assert mock_client.call_contract.call_count == 2 + class TestBuildEvmPayment: def test_returns_string_payload(self): @@ -39,3 +51,57 @@ def test_returns_string_payload(self): payload = build_evm_payment(accept, mock_client, snapshot) assert isinstance(payload, str) assert len(payload) > 0 + + def test_v2_maxtimeout_matches_ts_only_top_level_counts(self): + """Nested extra maxTimeoutSeconds must not set accepted.maxTimeoutSeconds (TS parity).""" + import base64 + import json + + mock_contract = Mock() + mock_client = Mock() + mock_client.get_contract = Mock(return_value=mock_contract) + mock_client.call_contract = Mock(side_effect=["USDC", "2", 10_000_000_000]) + mock_client.sign_typed_data = Mock(return_value=b"\x00" * 65) + mock_client.account = Mock(address="0x" + "11" * 20) + mock_client.chain_id = 8453 + mock_client.is_address = Mock(return_value=True) + mock_client.to_checksum_address = lambda x: x + accept = X402Accept( + price="10000", + token="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + network="eip155:8453", + destination="0x9f8bd9875b3E0b632a24A3A7C73f7787175e73A2", + extra={"maxTimeoutSeconds": 300, "provider": "coinbase"}, + ) + snapshot = RequestSnapshot(url="https://example.com/mcp", method="POST", headers={}) + b64 = build_evm_payment(accept, mock_client, snapshot) + obj = json.loads(base64.b64decode(b64).decode()) + assert obj.get("x402Version") == 2 + assert obj["accepted"]["maxTimeoutSeconds"] == 60 + assert obj["accepted"]["extra"]["maxTimeoutSeconds"] == 300 + + def test_v2_maxtimeout_top_level_from_server(self): + import base64 + import json + + mock_contract = Mock() + mock_client = Mock() + mock_client.get_contract = Mock(return_value=mock_contract) + mock_client.call_contract = Mock(side_effect=["USDC", "2", 10_000_000_000]) + mock_client.sign_typed_data = Mock(return_value=b"\x00" * 65) + mock_client.account = Mock(address="0x" + "11" * 20) + mock_client.chain_id = 8453 + mock_client.is_address = Mock(return_value=True) + mock_client.to_checksum_address = lambda x: x + accept = X402Accept( + price="10000", + token="0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", + network="eip155:8453", + destination="0x9f8bd9875b3E0b632a24A3A7C73f7787175e73A2", + extra={"provider": "coinbase"}, + maxTimeoutSeconds=120, + ) + snapshot = RequestSnapshot(url="https://example.com", method="POST", headers={}) + b64 = build_evm_payment(accept, mock_client, snapshot) + obj = json.loads(base64.b64decode(b64).decode()) + assert obj["accepted"]["maxTimeoutSeconds"] == 120