diff --git a/contributing/tools/agentpay/README.md b/contributing/tools/agentpay/README.md new file mode 100644 index 00000000..b16aa0fa --- /dev/null +++ b/contributing/tools/agentpay/README.md @@ -0,0 +1,114 @@ +# AgentPay x402 Payment Tools for Google ADK + +Enable Google ADK agents to make autonomous HTTP payments using the +[x402 payment protocol](https://www.x402.org/) — HTTP 402 Payment Required, +handled automatically. + +Powered by [agentpay-mcp](https://www.npmjs.com/package/agentpay-mcp) and +[agentwallet-sdk](https://www.npmjs.com/package/agentwallet-sdk) (patent pending). + +## What is x402? + +x402 is an open protocol that revives HTTP's original `402 Payment Required` +status code. When an agent hits a paid API endpoint, the server returns a 402 +with payment details. The agent's wallet pays automatically and retries the +request — no manual intervention, no API keys for every service. + +## What is AgentPay? + +AgentPay is a non-custodial smart contract wallet system for AI agents. Wallet +ownership is represented by an NFT — your agent controls funds without a +custodian. On-chain **spend limits** cap per-transaction and daily totals, +protecting against runaway agent behavior. + +- **Chain:** Base (live), multi-chain coming +- **Protocol:** x402 / HTTP 402 +- **NPM:** [`agentpay-mcp`](https://www.npmjs.com/package/agentpay-mcp) v4.0.0 +- **SDK:** [`agentwallet-sdk`](https://www.npmjs.com/package/agentwallet-sdk) v6.0.4 +- **Status:** Patent pending + +## Installation + +```bash +# Python ADK package +pip install google-adk-community + +# AgentPay MCP server (requires Node.js ≥ 18) +npm install -g agentpay-mcp +``` + +## Setup + +```bash +# Your AgentPay wallet private key (non-custodial) +export AGENTPAY_PRIVATE_KEY="0x..." + +# Optional: custom RPC endpoint (defaults to Base mainnet) +export AGENTPAY_RPC_URL="https://mainnet.base.org" +``` + +Get a wallet at [agentpay.xyz](https://agentpay.xyz) or via the +`agentwallet-sdk` CLI. + +## Usage + +```python +from google.adk.agents import Agent +from google.adk_community.tools.agentpay import ( + fetch_paid_api, + get_wallet_info, + check_spend_limit, + send_payment, +) + +agent = Agent( + model="gemini-2.0-flash", + name="payment_agent", + description="An agent that can pay for API access autonomously using x402", + tools=[fetch_paid_api, get_wallet_info, check_spend_limit, send_payment], +) + +# The agent can now call paid APIs, check its wallet, and send payments +response = agent.run( + "Fetch the latest market data from https://api.example.com/market" + " and check my remaining daily spend limit." +) +``` + +## Available Tools + +| Tool | Description | +|------|-------------| +| `fetch_paid_api` | Make an HTTP request; auto-pay x402 challenge if needed and retry | +| `get_wallet_info` | Return wallet address, USDC balance, spend limits, and remaining allowance | +| `check_spend_limit` | Pre-check whether a payment amount is within on-chain spend limits | +| `send_payment` | Send USDC directly to an address within autonomous spend policy | + +## How `fetch_paid_api` Works + +``` +Agent calls fetch_paid_api(url="https://paid-api.example.com/data") + │ + ├─ agentpay-mcp sends GET /data + │ Server returns HTTP 402 + payment details + │ + ├─ agentpay-mcp pays on-chain (Base, USDC) + │ On-chain spend limit checked before signing + │ + └─ agentpay-mcp retries GET /data with payment proof + Server returns 200 + data → returned to agent +``` + +## Security Model + +- **Non-custodial** — private key stays in your environment; no third party holds funds +- **On-chain spend limits** — per-transaction and daily caps enforced by smart contract +- **NFT ownership** — wallet controlled by NFT; rotate ownership without moving funds +- **Audit trail** — every payment recorded on-chain + +## Links + +- [AgentPay NPM](https://www.npmjs.com/package/agentpay-mcp) +- [agentwallet-sdk NPM](https://www.npmjs.com/package/agentwallet-sdk) +- [x402 Protocol](https://www.x402.org/) +- [ADK Integrations](https://google.github.io/adk-docs/integrations/) diff --git a/src/google/adk_community/tools/__init__.py b/src/google/adk_community/tools/__init__.py new file mode 100644 index 00000000..0a2669d7 --- /dev/null +++ b/src/google/adk_community/tools/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/src/google/adk_community/tools/agentpay/__init__.py b/src/google/adk_community/tools/agentpay/__init__.py new file mode 100644 index 00000000..2ec10675 --- /dev/null +++ b/src/google/adk_community/tools/agentpay/__init__.py @@ -0,0 +1,33 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""AgentPay x402 payment tools for Google ADK. + +Enables ADK agents to make autonomous HTTP payments using the x402 protocol +(HTTP 402 Payment Required) via the agentpay-mcp MCP server. + +See: https://www.npmjs.com/package/agentpay-mcp +""" + +from .agentpay_tools import check_spend_limit +from .agentpay_tools import fetch_paid_api +from .agentpay_tools import get_wallet_info +from .agentpay_tools import send_payment + +__all__ = [ + "fetch_paid_api", + "get_wallet_info", + "check_spend_limit", + "send_payment", +] diff --git a/src/google/adk_community/tools/agentpay/agentpay_tools.py b/src/google/adk_community/tools/agentpay/agentpay_tools.py new file mode 100644 index 00000000..50160d87 --- /dev/null +++ b/src/google/adk_community/tools/agentpay/agentpay_tools.py @@ -0,0 +1,379 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""AgentPay x402 payment tools for Google Agent Development Kit. + +These tools enable ADK agents to make autonomous HTTP payments using the x402 +protocol (HTTP 402 Payment Required). When an agent requests a paid API +endpoint, the server returns HTTP 402 with payment details. AgentPay handles +the on-chain payment automatically and retries the request. + +Requires: + Node.js >= 18 + npm install -g agentpay-mcp + +Environment variables: + AGENTPAY_PRIVATE_KEY: Wallet private key (0x-prefixed hex string). + AGENTPAY_RPC_URL: (Optional) RPC endpoint. Defaults to Base mainnet. + +Installation: + pip install google-adk-community + npm install -g agentpay-mcp + +Usage: + from google.adk.agents import Agent + from google.adk_community.tools.agentpay import ( + fetch_paid_api, + get_wallet_info, + check_spend_limit, + send_payment, + ) + + agent = Agent( + model="gemini-2.0-flash", + name="payment_agent", + description="Agent that pays for API access autonomously via x402", + tools=[fetch_paid_api, get_wallet_info, check_spend_limit, send_payment], + ) + +Protocol: + x402 is an open standard that revives HTTP's 402 Payment Required status + code for machine-to-machine micropayments. See https://www.x402.org/ + +Package: + agentpay-mcp: https://www.npmjs.com/package/agentpay-mcp (patent pending) + agentwallet-sdk: https://www.npmjs.com/package/agentwallet-sdk +""" + +from __future__ import annotations + +import json +import logging +import os +import shutil +import subprocess +from typing import Any, Optional + +logger = logging.getLogger("google_adk." + __name__) + +# MCP server executable name (installed globally via npm) +_MCP_SERVER = "agentpay-mcp" + +# MCP protocol framing: each message is a JSON-RPC 2.0 object on one line +_JSONRPC_VERSION = "2.0" + + +def _check_mcp_server() -> Optional[str]: + """Return path to agentpay-mcp binary, or None if not installed.""" + path = shutil.which(_MCP_SERVER) + if path: + return path + # Also check common npx/node_modules/.bin locations + npx = shutil.which("npx") + if npx: + return None # caller will use npx -y agentpay-mcp + return None + + +def _build_env() -> dict[str, str]: + """Build subprocess environment with required AgentPay variables.""" + env = os.environ.copy() + return env + + +def _mcp_call(method: str, params: dict[str, Any]) -> dict[str, Any]: + """Send a single JSON-RPC 2.0 request to the agentpay-mcp stdio server. + + Spawns the MCP server as a subprocess, sends one request, reads the + response, and terminates the process. This is the standard MCP stdio + transport pattern. + + Args: + method: MCP tool name to invoke (e.g. "fetch_paid_api"). + params: Parameters dict for the tool call. + + Returns: + Parsed JSON response dict, or an error dict on failure. + """ + private_key = os.environ.get("AGENTPAY_PRIVATE_KEY") + if not private_key: + return { + "status": "error", + "error": ( + "AGENTPAY_PRIVATE_KEY environment variable not set. " + "Set it to your AgentPay wallet private key (0x-prefixed)." + ), + } + + # Build the JSON-RPC initialize + call sequence for MCP stdio transport + init_request = { + "jsonrpc": _JSONRPC_VERSION, + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": {"name": "google-adk-community", "version": "1.0.0"}, + }, + } + tool_request = { + "jsonrpc": _JSONRPC_VERSION, + "id": 2, + "method": "tools/call", + "params": {"name": method, "arguments": params}, + } + # MCP stdio: newline-delimited JSON + stdin_payload = ( + json.dumps(init_request) + "\n" + json.dumps(tool_request) + "\n" + ) + + server_path = _check_mcp_server() + if server_path: + cmd = [server_path] + elif shutil.which("npx"): + cmd = ["npx", "--yes", _MCP_SERVER] + else: + return { + "status": "error", + "error": ( + f"'{_MCP_SERVER}' not found. Install with: npm install -g" + f" {_MCP_SERVER}" + ), + } + + env = _build_env() + try: + result = subprocess.run( + cmd, + input=stdin_payload, + capture_output=True, + text=True, + timeout=60, + env=env, + ) + except subprocess.TimeoutExpired: + return {"status": "error", "error": "agentpay-mcp timed out after 60s."} + except FileNotFoundError: + return { + "status": "error", + "error": ( + f"'{_MCP_SERVER}' not found. Install with: npm install -g" + f" {_MCP_SERVER}" + ), + } + except OSError as exc: + return {"status": "error", "error": f"Failed to launch agentpay-mcp: {exc}"} + + if result.returncode != 0: + stderr_snippet = result.stderr[:500] if result.stderr else "" + logger.debug("agentpay-mcp stderr: %s", result.stderr) + return { + "status": "error", + "error": f"agentpay-mcp exited with code {result.returncode}.", + "detail": stderr_snippet, + } + + # Parse last JSON-RPC response (id=2) from stdout + tool_response: Optional[dict[str, Any]] = None + for line in result.stdout.splitlines(): + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + if obj.get("id") == 2: + tool_response = obj + except json.JSONDecodeError: + logger.debug("agentpay-mcp non-JSON line: %s", line) + + if tool_response is None: + return { + "status": "error", + "error": "No response received from agentpay-mcp.", + "stdout": result.stdout[:500], + } + + if "error" in tool_response: + rpc_err = tool_response["error"] + return { + "status": "error", + "error": rpc_err.get("message", str(rpc_err)), + "code": rpc_err.get("code"), + } + + # MCP tools/call result: {"result": {"content": [{"type": "text", "text": "..."}]}} + mcp_result = tool_response.get("result", {}) + content = mcp_result.get("content", []) + if content and content[0].get("type") == "text": + text = content[0]["text"] + try: + return json.loads(text) + except json.JSONDecodeError: + return {"status": "ok", "result": text} + + return {"status": "ok", "result": mcp_result} + + +# --------------------------------------------------------------------------- +# Public tool functions +# --------------------------------------------------------------------------- + + +def fetch_paid_api( + url: str, + method: str = "GET", + headers: Optional[dict[str, str]] = None, + body: Optional[str] = None, + max_payment_usdc: Optional[float] = None, +) -> dict: + """Fetch a URL, automatically paying any x402 HTTP 402 challenge. + + If the server responds with HTTP 402 Payment Required, AgentPay handles + the on-chain USDC payment on Base and retries the request automatically. + Spend limits are enforced on-chain before any payment is signed. + + Args: + url: Full URL of the API endpoint to fetch. + method: HTTP method (GET, POST, PUT, DELETE). Default: GET. + headers: Optional additional HTTP headers as a dict. + body: Optional request body string (for POST/PUT). + max_payment_usdc: Optional ceiling on what the agent will pay for this + request (USDC). If the 402 challenge exceeds this, the request is + aborted without payment. + + Returns: + A dict with keys: + - status: "ok" or "error" + - http_status: HTTP status code of the final response (int) + - body: Response body string + - payment_made: True if an x402 payment was executed + - amount_paid_usdc: Amount paid in USDC (float), 0.0 if no payment + - tx_hash: On-chain transaction hash if payment was made, else None + - error: Error message string (only present on failure) + """ + params: dict[str, Any] = {"url": url, "method": method.upper()} + if headers: + params["headers"] = headers + if body: + params["body"] = body + if max_payment_usdc is not None: + params["max_payment_usdc"] = max_payment_usdc + + logger.info("fetch_paid_api: %s %s", method.upper(), url) + return _mcp_call("fetch_paid_api", params) + + +def get_wallet_info() -> dict: + """Return AgentPay wallet address, USDC balance, and spend limits. + + Queries the AgentPay smart contract on Base for the current wallet state, + including on-chain spend limits set by the wallet owner. + + Returns: + A dict with keys: + - status: "ok" or "error" + - address: Wallet address (0x-prefixed string) + - balance_usdc: Current USDC balance (float) + - spend_limit_per_tx_usdc: Maximum single-transaction spend limit (float) + - spend_limit_daily_usdc: Maximum daily spend limit (float) + - spent_today_usdc: Amount spent today so far (float) + - remaining_daily_usdc: Remaining daily allowance (float) + - chain: Chain name (e.g. "base") + - error: Error message string (only present on failure) + """ + logger.info("get_wallet_info: querying AgentPay wallet") + return _mcp_call("get_wallet_info", {}) + + +def check_spend_limit( + amount_usdc: float, +) -> dict: + """Pre-check whether a payment is within on-chain spend limits. + + Validates against both per-transaction and daily spend limits enforced + by the AgentPay smart contract. Use this before committing to a payment. + + Args: + amount_usdc: Proposed payment amount in USDC. + + Returns: + A dict with keys: + - status: "ok" or "error" + - allowed: True if the payment is within all limits (bool) + - amount_usdc: The amount checked (float) + - spend_limit_per_tx_usdc: Per-transaction limit (float) + - spend_limit_daily_usdc: Daily limit (float) + - spent_today_usdc: Amount spent so far today (float) + - remaining_daily_usdc: Remaining daily allowance (float) + - reason: Human-readable explanation if not allowed (str or None) + - error: Error message string (only present on failure) + """ + if amount_usdc <= 0: + return { + "status": "error", + "error": "amount_usdc must be greater than 0.", + } + + logger.info("check_spend_limit: checking %.6f USDC", amount_usdc) + return _mcp_call("check_spend_limit", {"amount_usdc": amount_usdc}) + + +def send_payment( + recipient: str, + amount_usdc: float, + memo: Optional[str] = None, +) -> dict: + """Send USDC directly to a wallet address within spend policy limits. + + Executes an on-chain USDC transfer on Base. The payment is validated + against on-chain spend limits before signing. The transaction is + non-custodial — the private key never leaves the agent's environment. + + Args: + recipient: Destination wallet address (0x-prefixed). + amount_usdc: Amount of USDC to send (e.g. 1.50 for $1.50). + memo: Optional memo string stored in transaction calldata. + + Returns: + A dict with keys: + - status: "ok" or "error" + - tx_hash: On-chain transaction hash (str) + - recipient: Destination address (str) + - amount_usdc: Amount sent (float) + - chain: Chain name (e.g. "base") + - block_number: Block number of the transaction (int) + - error: Error message string (only present on failure) + """ + if amount_usdc <= 0: + return { + "status": "error", + "error": "amount_usdc must be greater than 0.", + } + if not recipient or not recipient.startswith("0x"): + return { + "status": "error", + "error": "recipient must be a 0x-prefixed Ethereum address.", + } + + params: dict[str, Any] = { + "recipient": recipient, + "amount_usdc": amount_usdc, + } + if memo: + params["memo"] = memo + + logger.info( + "send_payment: sending %.6f USDC to %s", amount_usdc, recipient + ) + return _mcp_call("send_payment", params) diff --git a/tests/unittests/tools/__init__.py b/tests/unittests/tools/__init__.py new file mode 100644 index 00000000..0a2669d7 --- /dev/null +++ b/tests/unittests/tools/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unittests/tools/agentpay/__init__.py b/tests/unittests/tools/agentpay/__init__.py new file mode 100644 index 00000000..0a2669d7 --- /dev/null +++ b/tests/unittests/tools/agentpay/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unittests/tools/agentpay/test_agentpay_tools.py b/tests/unittests/tools/agentpay/test_agentpay_tools.py new file mode 100644 index 00000000..c58bd61d --- /dev/null +++ b/tests/unittests/tools/agentpay/test_agentpay_tools.py @@ -0,0 +1,466 @@ +# Copyright 2025 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Unit tests for AgentPay x402 payment tools. + +These tests verify tool behavior across the full matrix of conditions: +missing env vars, subprocess failures, MCP protocol errors, and happy-path +success cases. All external calls (subprocess.run, shutil.which) are mocked +so tests run without Node.js or agentpay-mcp installed. +""" + +import json +import subprocess +from unittest.mock import MagicMock, patch + +import pytest + +from google.adk_community.tools.agentpay.agentpay_tools import ( + check_spend_limit, + fetch_paid_api, + get_wallet_info, + send_payment, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _mcp_response(id_: int, result_text: dict) -> str: + """Build a newline-delimited MCP JSON-RPC response line.""" + return json.dumps( + { + "jsonrpc": "2.0", + "id": id_, + "result": { + "content": [{"type": "text", "text": json.dumps(result_text)}] + }, + } + ) + + +def _completed_process(stdout: str, returncode: int = 0) -> MagicMock: + """Return a mock subprocess.CompletedProcess.""" + mock = MagicMock(spec=subprocess.CompletedProcess) + mock.returncode = returncode + mock.stdout = stdout + mock.stderr = "" + return mock + + +# --------------------------------------------------------------------------- +# Missing AGENTPAY_PRIVATE_KEY +# --------------------------------------------------------------------------- + + +class TestMissingPrivateKey: + """All tools must return an informative error when the key is absent.""" + + def test_fetch_paid_api_missing_key(self, monkeypatch): + monkeypatch.delenv("AGENTPAY_PRIVATE_KEY", raising=False) + result = fetch_paid_api(url="https://example.com/api") + assert result["status"] == "error" + assert "AGENTPAY_PRIVATE_KEY" in result["error"] + + def test_get_wallet_info_missing_key(self, monkeypatch): + monkeypatch.delenv("AGENTPAY_PRIVATE_KEY", raising=False) + result = get_wallet_info() + assert result["status"] == "error" + assert "AGENTPAY_PRIVATE_KEY" in result["error"] + + def test_check_spend_limit_missing_key(self, monkeypatch): + monkeypatch.delenv("AGENTPAY_PRIVATE_KEY", raising=False) + result = check_spend_limit(amount_usdc=1.0) + assert result["status"] == "error" + assert "AGENTPAY_PRIVATE_KEY" in result["error"] + + def test_send_payment_missing_key(self, monkeypatch): + monkeypatch.delenv("AGENTPAY_PRIVATE_KEY", raising=False) + result = send_payment(recipient="0xABC123", amount_usdc=1.0) + assert result["status"] == "error" + assert "AGENTPAY_PRIVATE_KEY" in result["error"] + + +# --------------------------------------------------------------------------- +# Input validation (independent of subprocess) +# --------------------------------------------------------------------------- + + +class TestInputValidation: + """Tools should validate inputs before spawning a subprocess.""" + + def test_check_spend_limit_zero_amount(self, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + result = check_spend_limit(amount_usdc=0) + assert result["status"] == "error" + assert "greater than 0" in result["error"] + + def test_check_spend_limit_negative_amount(self, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + result = check_spend_limit(amount_usdc=-5.0) + assert result["status"] == "error" + + def test_send_payment_zero_amount(self, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + result = send_payment(recipient="0xABC123", amount_usdc=0) + assert result["status"] == "error" + assert "greater than 0" in result["error"] + + def test_send_payment_negative_amount(self, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + result = send_payment(recipient="0xABC123", amount_usdc=-1.0) + assert result["status"] == "error" + + def test_send_payment_invalid_recipient(self, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + result = send_payment(recipient="not-an-address", amount_usdc=1.0) + assert result["status"] == "error" + assert "0x-prefixed" in result["error"] + + def test_send_payment_empty_recipient(self, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + result = send_payment(recipient="", amount_usdc=1.0) + assert result["status"] == "error" + + +# --------------------------------------------------------------------------- +# MCP server not installed +# --------------------------------------------------------------------------- + + +class TestMcpServerNotFound: + """When agentpay-mcp and npx are both absent, return a clear error.""" + + @patch("google.adk_community.tools.agentpay.agentpay_tools.shutil.which", return_value=None) + def test_fetch_paid_api_no_mcp(self, mock_which, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + result = fetch_paid_api(url="https://example.com/api") + assert result["status"] == "error" + assert "agentpay-mcp" in result["error"] + + @patch("google.adk_community.tools.agentpay.agentpay_tools.shutil.which", return_value=None) + def test_get_wallet_info_no_mcp(self, mock_which, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + result = get_wallet_info() + assert result["status"] == "error" + assert "agentpay-mcp" in result["error"] + + @patch("google.adk_community.tools.agentpay.agentpay_tools.shutil.which", return_value=None) + def test_check_spend_limit_no_mcp(self, mock_which, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + result = check_spend_limit(amount_usdc=5.0) + assert result["status"] == "error" + + @patch("google.adk_community.tools.agentpay.agentpay_tools.shutil.which", return_value=None) + def test_send_payment_no_mcp(self, mock_which, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + result = send_payment(recipient="0xABC123", amount_usdc=1.0) + assert result["status"] == "error" + + +# --------------------------------------------------------------------------- +# Subprocess failure +# --------------------------------------------------------------------------- + + +class TestSubprocessFailure: + """When agentpay-mcp exits non-zero, propagate the error.""" + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_fetch_paid_api_subprocess_error( + self, mock_which, mock_run, monkeypatch + ): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + mock_run.return_value = _completed_process("", returncode=1) + mock_run.return_value.stderr = "internal error" + result = fetch_paid_api(url="https://example.com/api") + assert result["status"] == "error" + assert "1" in result["error"] + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_timeout(self, mock_which, mock_run, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + mock_run.side_effect = subprocess.TimeoutExpired(cmd="agentpay-mcp", timeout=60) + result = fetch_paid_api(url="https://example.com/api") + assert result["status"] == "error" + assert "timed out" in result["error"].lower() + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_no_response_from_mcp(self, mock_which, mock_run, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + # Return only the init response (id=1), no tool response (id=2) + init_resp = json.dumps( + {"jsonrpc": "2.0", "id": 1, "result": {"capabilities": {}}} + ) + mock_run.return_value = _completed_process(init_resp + "\n") + result = fetch_paid_api(url="https://example.com/api") + assert result["status"] == "error" + assert "No response" in result["error"] + + +# --------------------------------------------------------------------------- +# MCP protocol errors +# --------------------------------------------------------------------------- + + +class TestMcpProtocolError: + """JSON-RPC error responses from agentpay-mcp should surface cleanly.""" + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_rpc_error_propagated(self, mock_which, mock_run, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + error_resp = json.dumps( + { + "jsonrpc": "2.0", + "id": 2, + "error": {"code": -32601, "message": "Method not found"}, + } + ) + mock_run.return_value = _completed_process(error_resp + "\n") + result = get_wallet_info() + assert result["status"] == "error" + assert "Method not found" in result["error"] + + +# --------------------------------------------------------------------------- +# Happy-path success cases +# --------------------------------------------------------------------------- + + +class TestHappyPath: + """Verify correct parsing of well-formed MCP responses.""" + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_fetch_paid_api_success_no_payment( + self, mock_which, mock_run, monkeypatch + ): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + payload = { + "status": "ok", + "http_status": 200, + "body": '{"price": 42000}', + "payment_made": False, + "amount_paid_usdc": 0.0, + "tx_hash": None, + } + stdout = _mcp_response(2, payload) + "\n" + mock_run.return_value = _completed_process(stdout) + result = fetch_paid_api(url="https://api.example.com/price") + assert result["status"] == "ok" + assert result["http_status"] == 200 + assert result["payment_made"] is False + assert result["amount_paid_usdc"] == 0.0 + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_fetch_paid_api_success_with_payment( + self, mock_which, mock_run, monkeypatch + ): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + payload = { + "status": "ok", + "http_status": 200, + "body": '{"data": "premium"}', + "payment_made": True, + "amount_paid_usdc": 0.001, + "tx_hash": "0xabcdef1234567890", + } + stdout = _mcp_response(2, payload) + "\n" + mock_run.return_value = _completed_process(stdout) + result = fetch_paid_api( + url="https://paid-api.example.com/data", + max_payment_usdc=0.01, + ) + assert result["status"] == "ok" + assert result["payment_made"] is True + assert result["tx_hash"] == "0xabcdef1234567890" + assert result["amount_paid_usdc"] == 0.001 + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_get_wallet_info_success(self, mock_which, mock_run, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + payload = { + "status": "ok", + "address": "0x1234567890abcdef1234567890abcdef12345678", + "balance_usdc": 100.0, + "spend_limit_per_tx_usdc": 10.0, + "spend_limit_daily_usdc": 50.0, + "spent_today_usdc": 5.0, + "remaining_daily_usdc": 45.0, + "chain": "base", + } + stdout = _mcp_response(2, payload) + "\n" + mock_run.return_value = _completed_process(stdout) + result = get_wallet_info() + assert result["status"] == "ok" + assert result["balance_usdc"] == 100.0 + assert result["chain"] == "base" + assert result["remaining_daily_usdc"] == 45.0 + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_check_spend_limit_allowed(self, mock_which, mock_run, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + payload = { + "status": "ok", + "allowed": True, + "amount_usdc": 2.5, + "spend_limit_per_tx_usdc": 10.0, + "spend_limit_daily_usdc": 50.0, + "spent_today_usdc": 5.0, + "remaining_daily_usdc": 45.0, + "reason": None, + } + stdout = _mcp_response(2, payload) + "\n" + mock_run.return_value = _completed_process(stdout) + result = check_spend_limit(amount_usdc=2.5) + assert result["status"] == "ok" + assert result["allowed"] is True + assert result["reason"] is None + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_check_spend_limit_blocked(self, mock_which, mock_run, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + payload = { + "status": "ok", + "allowed": False, + "amount_usdc": 100.0, + "spend_limit_per_tx_usdc": 10.0, + "spend_limit_daily_usdc": 50.0, + "spent_today_usdc": 5.0, + "remaining_daily_usdc": 45.0, + "reason": "Exceeds per-transaction limit of $10.00", + } + stdout = _mcp_response(2, payload) + "\n" + mock_run.return_value = _completed_process(stdout) + result = check_spend_limit(amount_usdc=100.0) + assert result["status"] == "ok" + assert result["allowed"] is False + assert "per-transaction" in result["reason"] + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_send_payment_success(self, mock_which, mock_run, monkeypatch): + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + payload = { + "status": "ok", + "tx_hash": "0xdeadbeef1234567890abcdef", + "recipient": "0xABCDEF1234567890abcdef1234567890ABCDEF12", + "amount_usdc": 5.0, + "chain": "base", + "block_number": 12345678, + } + stdout = _mcp_response(2, payload) + "\n" + mock_run.return_value = _completed_process(stdout) + result = send_payment( + recipient="0xABCDEF1234567890abcdef1234567890ABCDEF12", + amount_usdc=5.0, + memo="Test payment", + ) + assert result["status"] == "ok" + assert result["tx_hash"] == "0xdeadbeef1234567890abcdef" + assert result["amount_usdc"] == 5.0 + assert result["chain"] == "base" + + @patch("google.adk_community.tools.agentpay.agentpay_tools.subprocess.run") + @patch( + "google.adk_community.tools.agentpay.agentpay_tools.shutil.which", + return_value="/usr/local/bin/agentpay-mcp", + ) + def test_fetch_paid_api_passes_method_and_headers( + self, mock_which, mock_run, monkeypatch + ): + """Verify that HTTP method and headers are forwarded to the MCP call.""" + monkeypatch.setenv("AGENTPAY_PRIVATE_KEY", "0xdeadbeef") + payload = {"status": "ok", "http_status": 200, "body": "ok", + "payment_made": False, "amount_paid_usdc": 0.0, "tx_hash": None} + stdout = _mcp_response(2, payload) + "\n" + mock_run.return_value = _completed_process(stdout) + result = fetch_paid_api( + url="https://api.example.com/submit", + method="POST", + headers={"Content-Type": "application/json"}, + body='{"query": "hello"}', + ) + assert result["status"] == "ok" + # Verify subprocess was called with the correct stdin containing our params + call_args = mock_run.call_args + stdin_payload = call_args.kwargs.get("input", "") + # The tool_request line should contain method=POST and our headers + assert "POST" in stdin_payload + assert "Content-Type" in stdin_payload + + +# --------------------------------------------------------------------------- +# Module imports +# --------------------------------------------------------------------------- + + +class TestModuleImports: + """Ensure all public symbols are importable from the package.""" + + def test_import_all_tools(self): + from google.adk_community.tools.agentpay import ( # noqa: F401 + check_spend_limit, + fetch_paid_api, + get_wallet_info, + send_payment, + ) + + def test_all_symbols_in_dunder_all(self): + import google.adk_community.tools.agentpay as pkg + + assert "fetch_paid_api" in pkg.__all__ + assert "get_wallet_info" in pkg.__all__ + assert "check_spend_limit" in pkg.__all__ + assert "send_payment" in pkg.__all__