From 19b865f9a4fe92cb7920414480d4143a0c13abe5 Mon Sep 17 00:00:00 2001 From: nourzakhama2003 Date: Sun, 15 Mar 2026 03:12:17 +0100 Subject: [PATCH 1/3] feat(agentpay): add native MCP payment layer for Dify agents (#33434) and add community AgentPay MCP plugin with provider, tools, client, manifest --- .gitignore | 4 + README.md | 7 + docs/agentpay-mcp-integration.md | 90 +++++++ .../community/agentpay-mcp-payments/README.md | 136 ++++++++++ .../agentpay-mcp-payments/_assets/icon.svg | 5 + .../agentpay_mcp_payments.py | 23 ++ .../community/agentpay-mcp-payments/main.py | 16 ++ .../agentpay-mcp-payments/manifest.yaml | 33 +++ .../agentpay-mcp-payments/privacy.md | 19 ++ .../provider/agentpay_mcp_payments.yaml | 88 +++++++ .../tests/test_agentpay_client.py | 19 ++ .../agentpay-mcp-payments/tools/call_tool.py | 26 ++ .../tools/call_tool.yaml | 33 +++ .../tools/check_balance.py | 18 ++ .../tools/check_balance.yaml | 13 + .../agentpay-mcp-payments/tools/client.py | 238 ++++++++++++++++++ .../agentpay-mcp-payments/tools/common.py | 20 ++ .../tools/discover_tools.py | 21 ++ .../tools/discover_tools.yaml | 32 +++ .../tools/fund_wallet_stripe.py | 19 ++ .../tools/fund_wallet_stripe.yaml | 39 +++ .../agentpay-mcp-payments/tools/get_usage.py | 20 ++ .../tools/get_usage.yaml | 23 ++ .../agentpay-mcp-payments/tools/list_tools.py | 20 ++ .../tools/list_tools.yaml | 23 ++ .../__tests__/use-mcp-modal-form.spec.ts | 43 ++++ .../tools/mcp/hooks/use-mcp-modal-form.ts | 34 ++- 27 files changed, 1061 insertions(+), 1 deletion(-) create mode 100644 docs/agentpay-mcp-integration.md create mode 100644 plugins/community/agentpay-mcp-payments/README.md create mode 100644 plugins/community/agentpay-mcp-payments/_assets/icon.svg create mode 100644 plugins/community/agentpay-mcp-payments/agentpay_mcp_payments.py create mode 100644 plugins/community/agentpay-mcp-payments/main.py create mode 100644 plugins/community/agentpay-mcp-payments/manifest.yaml create mode 100644 plugins/community/agentpay-mcp-payments/privacy.md create mode 100644 plugins/community/agentpay-mcp-payments/provider/agentpay_mcp_payments.yaml create mode 100644 plugins/community/agentpay-mcp-payments/tests/test_agentpay_client.py create mode 100644 plugins/community/agentpay-mcp-payments/tools/call_tool.py create mode 100644 plugins/community/agentpay-mcp-payments/tools/call_tool.yaml create mode 100644 plugins/community/agentpay-mcp-payments/tools/check_balance.py create mode 100644 plugins/community/agentpay-mcp-payments/tools/check_balance.yaml create mode 100644 plugins/community/agentpay-mcp-payments/tools/client.py create mode 100644 plugins/community/agentpay-mcp-payments/tools/common.py create mode 100644 plugins/community/agentpay-mcp-payments/tools/discover_tools.py create mode 100644 plugins/community/agentpay-mcp-payments/tools/discover_tools.yaml create mode 100644 plugins/community/agentpay-mcp-payments/tools/fund_wallet_stripe.py create mode 100644 plugins/community/agentpay-mcp-payments/tools/fund_wallet_stripe.yaml create mode 100644 plugins/community/agentpay-mcp-payments/tools/get_usage.py create mode 100644 plugins/community/agentpay-mcp-payments/tools/get_usage.yaml create mode 100644 plugins/community/agentpay-mcp-payments/tools/list_tools.py create mode 100644 plugins/community/agentpay-mcp-payments/tools/list_tools.yaml diff --git a/.gitignore b/.gitignore index 8200d70afe..0ccfbb8735 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,10 @@ __pycache__/ *.py[cod] *$py.class +# AgentPay MCP local debug artifacts +plugins/community/agentpay-mcp-payments/.tmp/ +plugins/community/agentpay-mcp-payments/*.local.md +plugins/community/agentpay-mcp-payments/*.local.log # C extensions *.so diff --git a/README.md b/README.md index 90961a5346..9c65cd90b7 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,13 @@ SUGGESTED_QUESTIONS_TEMPERATURE=0.3 See the [Suggested Questions Configuration Guide](docs/suggested-questions-configuration.md) for detailed examples and usage instructions. +#### External MCP Payment Integration (AgentPay Example) + +If you want your agents to execute payment-capable workflows, follow the +[AgentPay MCP Integration Guide](docs/agentpay-mcp-integration.md). +The guide is beginner-friendly and uses Dify's existing MCP provider flow. + + ### Metrics Monitoring with Grafana Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more. diff --git a/docs/agentpay-mcp-integration.md b/docs/agentpay-mcp-integration.md new file mode 100644 index 0000000000..78f485173a --- /dev/null +++ b/docs/agentpay-mcp-integration.md @@ -0,0 +1,90 @@ +# AgentPay MCP Payments Integration Guide + +This guide explains how to connect and use the AgentPay MCP Payments plugin with Dify, enabling agents and workflows to discover, fund, and invoke paid tools securely. + +--- + +## 1. Prerequisites +- Dify instance (v1.13.0+ recommended) +- AgentPay MCP server (HTTP or stdio) +- AgentPay Gateway Key (starts with `apg_...`) +- (Optional) Node.js for local stdio mode + +--- + +## 2. Get Your AgentPay Gateway Key +Register for a gateway key: +```bash +curl -X POST https://agentpay.metaltorque.dev/gateway/register \ + -H "Content-Type: application/json" \ + -d '{"email": "you@example.com"}' +``` +Save the returned key (format: `apg_...`). + +--- + +## 3. Start the AgentPay MCP Server +- **Recommended (HTTP):** + ```bash + npx -y supergateway --stdio "npx -y mcp-server-agentpay" --port 8000 + # HTTP endpoints: http://localhost:8000/message (POST), http://localhost:8000/sse (SSE) + ``` +- **Direct stdio (fallback):** + ```bash + npx -y mcp-server-agentpay + ``` + +--- + +## 4. Add MCP Server in Dify +1. Go to **Tools → MCP → Add MCP Server (HTTP)** +2. Fill in: + - **Server URL:** `http://localhost:8000/message` (or your HTTP endpoint) + - **Name:** `AgentPay` + - **Server Identifier:** `agentpay` + - **Headers:** Add `X-AgentPay-Gateway-Key: apg_...` + - **Dynamic Client Registration:** OFF + - Leave Client ID/Secret blank +3. Save and authorize. +4. Click **Fetch Tools** to load available tools. + +--- + +## 5. Attach Tools to Agents/Workflows +- Enable tools like `discover_tools`, `check_balance`, `call_tool` in your workflow or agent config. +- Example prompts: + - "Search for web scraping tools." + - "Check my AgentPay balance." + - "Call a paid tool with arguments: { ... }" + +--- + +## 6. Troubleshooting +- **No tools appear:** + - Check MCP server logs for errors + - Ensure gateway key is correct and in headers + - Try both `/message` and `/sse` endpoints for Server URL +- **Tool call fails:** + - Check Dify plugin logs for error details + - Confirm MCP server is running and reachable + - Test with curl (see below) +- **Test MCP directly:** + ```bash + curl -X POST http://localhost:8000/message \ + -H "Content-Type: application/json" \ + -H "X-AgentPay-Gateway-Key: apg_..." \ + -d '{"tool":"check_balance","args":{}}' + ``` + +--- + +## 7. Security & Best Practices +- Never commit gateway keys to source control +- Rotate keys if exposed +- Restrict tool usage in production +- All payments are routed via AgentPay gateway; no private key custody + +--- + +## 8. Support +- For advanced troubleshooting, see the operator runbook or contact plugin maintainers. diff --git a/plugins/community/agentpay-mcp-payments/README.md b/plugins/community/agentpay-mcp-payments/README.md new file mode 100644 index 0000000000..975af0d853 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/README.md @@ -0,0 +1,136 @@ +# AgentPay Payment Layer (Dify Tool Plugin) + +Community tool plugin for AgentPay MCP operations in Dify workflows. + +## Scope + +This plugin is intended for tool execution in workflows (direct tool nodes). + +Provided tools: + +- `discover_tools` +- `list_tools` +- `check_balance` +- `fund_wallet_stripe` +- `call_tool` +- `get_usage` + +## Prerequisites + +- Dify running and plugin daemon healthy +- Valid AgentPay gateway key (`apg_...`) +- Reachable MCP endpoint + +Register key: + +```bash +curl -X POST https://agentpay.metaltorque.dev/gateway/register \ + -H "Content-Type: application/json" \ + -d '{"email":"you@example.com"}' +``` + +## MCP Runtime Topology + +Use one topology only during a test run. + +### Option A: Host runtime (recommended for local source-mode API) + +Run on host: + +```bash +npx -y supergateway@3.4.3 --stdio "npx -y mcp-server-agentpay" --port 8000 --outputTransport streamableHttp +``` + +Set MCP URL in Dify: + +```text +http://127.0.0.1:8000/mcp +``` + +### Option B: Docker runtime + +Run in Docker with published port: + +```bash +docker run --name agentpay-supergateway --rm -d -p 8000:8000 node:20-alpine sh -c "npx -y supergateway@3.4.3 --stdio 'npx -y mcp-server-agentpay' --port 8000 --outputTransport streamableHttp" +``` + +Set MCP URL in Dify: + +```text +http://127.0.0.1:8000/mcp +``` + +## Dify Provider Configuration + +Required credential: + +- `AgentPay Gateway Key` + +Optional credential overrides: + +- `AgentPay Gateway URL` +- `AgentPay MCP HTTP URL` +- `Launch mode` (`auto`, `http`, `stdio`) +- `stdio command` (default `npx`) + +## Workflow Wiring (Direct Tool Execution) + +Minimal flow: + +1. `Start` +2. `check_balance` tool node +3. `Answer` node + +Answer template: + +```text +{{#.text#}} +``` + +Insert variable from picker (`CHECK_BALANCE -> text`) so node ID stays correct. + +## Verification Checklist + +Success requires both: + +1. Tool node last run status = `SUCCESS` +2. Supergateway log shows `"method":"tools/call"` with `"name":"check_balance"` + +If you only see `initialize` and `tools/list`, execution did not reach tool invoke path. + +## Common Issues + +### Tool timeout + +Cause: plugin daemon cannot reach MCP URL. + +Fix: + +- ensure MCP URL matches active topology +- avoid mixed host/container URLs in the same test session + +### Empty answer + +Cause: wrong output mapping. + +Fix: + +- map `CHECK_BALANCE -> text` in `Answer` + +### Accept header error in manual tests + +Manual probes against streamable HTTP must send: + +- `Accept: application/json, text/event-stream` + +This does not affect Dify normal MCP calls. + +## Contribution Notes + +Aligned with Dify contribution guidance: + +- keep changes scoped and minimal +- include reproducible logs for bug fixes +- link an issue in PR description +- include screenshots for workflow/tool verification diff --git a/plugins/community/agentpay-mcp-payments/_assets/icon.svg b/plugins/community/agentpay-mcp-payments/_assets/icon.svg new file mode 100644 index 0000000000..3035f36e59 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/_assets/icon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/plugins/community/agentpay-mcp-payments/agentpay_mcp_payments.py b/plugins/community/agentpay-mcp-payments/agentpay_mcp_payments.py new file mode 100644 index 0000000000..cfc0fdea02 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/agentpay_mcp_payments.py @@ -0,0 +1,23 @@ +# pyright: reportMissingImports=false + +from typing import Any + +from dify_plugin import ToolProvider +from dify_plugin.errors.tool import ToolProviderCredentialValidationError + +from tools.client import AgentPayMCPClient, AgentPayMCPError + + +class AgentPayMCPPaymentsProvider(ToolProvider): + """Credential validation and provider-level lifecycle for AgentPay MCP tools.""" + + def _validate_credentials(self, credentials: dict[str, Any]) -> None: + try: + client = AgentPayMCPClient.from_credentials(credentials) + client.call_tool("check_balance", {}) + except AgentPayMCPError as exc: + raise ToolProviderCredentialValidationError(str(exc)) from exc + except Exception as exc: # noqa: BLE001 + raise ToolProviderCredentialValidationError( + f"Failed to validate AgentPay credentials: {exc}" + ) from exc diff --git a/plugins/community/agentpay-mcp-payments/main.py b/plugins/community/agentpay-mcp-payments/main.py new file mode 100644 index 0000000000..28a8c10253 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/main.py @@ -0,0 +1,16 @@ +# pyright: reportMissingImports=false + +from collections.abc import Mapping + +from dify_plugin import Plugin + + +class AgentPayMCPPaymentsPlugin(Plugin): + """Plugin entrypoint for AgentPay MCP Payments.""" + + def _invoke(self, payload: Mapping[str, object]) -> Mapping[str, object]: + return payload + + +if __name__ == "__main__": + AgentPayMCPPaymentsPlugin().run() diff --git a/plugins/community/agentpay-mcp-payments/manifest.yaml b/plugins/community/agentpay-mcp-payments/manifest.yaml new file mode 100644 index 0000000000..bf19d3dbcc --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/manifest.yaml @@ -0,0 +1,33 @@ +version: 0.0.1 +type: plugin +author: AI Agent Economy Community +name: agentpay_mcp_payments +label: + en_US: AgentPay MCP Payments + zh_Hans: AgentPay MCP 支付层 +description: + en_US: MCP-based payment layer for discovering and invoking metered tools through AgentPay. +created_at: "2026-03-14T12:00:00Z" +icon: _assets/icon.svg +resource: + memory: 268435456 + permission: + tool: + enabled: true + app: + enabled: true + storage: + enabled: false +plugins: + tools: + - provider/agentpay_mcp_payments.yaml +meta: + version: 0.0.1 + arch: + - amd64 + - arm64 + runner: + language: python + version: "3.12" + entrypoint: main +privacy: ./privacy.md diff --git a/plugins/community/agentpay-mcp-payments/privacy.md b/plugins/community/agentpay-mcp-payments/privacy.md new file mode 100644 index 0000000000..bf223b9f2b --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/privacy.md @@ -0,0 +1,19 @@ +# Privacy Policy + +This plugin sends user-supplied tool parameters and credential-backed requests to AgentPay endpoints for payment operations. + +## Data processed + +- provider credentials configured by workspace admins +- tool input parameters passed at runtime +- response payloads returned by AgentPay MCP server/gateway + +## Data retention + +This plugin does not intentionally persist user data beyond runtime process memory. + +## Security notes + +- Never commit gateway keys into source control. +- Rotate keys if exposed. +- Restrict tool usage in production with least-privilege principles. diff --git a/plugins/community/agentpay-mcp-payments/provider/agentpay_mcp_payments.yaml b/plugins/community/agentpay-mcp-payments/provider/agentpay_mcp_payments.yaml new file mode 100644 index 0000000000..f04d0fc8cc --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/provider/agentpay_mcp_payments.yaml @@ -0,0 +1,88 @@ +identity: + author: AI Agent Economy Community + name: agentpay_mcp_payments + label: + en_US: AgentPay Payment Layer + zh_Hans: AgentPay 支付层 + description: + en_US: Discover and invoke paid MCP tools with AgentPay metering and funding flows. + zh_Hans: 使用 AgentPay 计费能力发现并调用付费 MCP 工具。 + icon: _assets/icon.svg + tags: + - finance + - business + - utilities + +credentials_for_provider: + agentpay_gateway_key: + type: secret-input + required: true + label: + en_US: AgentPay Gateway Key + zh_Hans: AgentPay 网关密钥 + placeholder: + en_US: apg_xxx + zh_Hans: 输入 apg_ 开头的密钥 + help: + en_US: Register once to create a wallet and key. + zh_Hans: 先注册获取钱包和网关密钥。 + url: https://agentpay.metaltorque.dev/docs + agentpay_url: + type: text-input + required: false + label: + en_US: AgentPay Gateway URL + zh_Hans: AgentPay 网关地址 + placeholder: + en_US: https://agentpay.metaltorque.dev + zh_Hans: https://agentpay.metaltorque.dev + agentpay_mcp_http_url: + type: text-input + required: false + label: + en_US: AgentPay MCP HTTP URL (optional) + zh_Hans: AgentPay MCP HTTP 地址(可选) + placeholder: + en_US: https://your-agentpay-host/mcp + zh_Hans: https://your-agentpay-host/mcp + agentpay_launch_mode: + type: select + required: false + label: + en_US: Launch Mode + zh_Hans: 启动模式 + options: + - value: auto + label: + en_US: auto (HTTP first, stdio fallback) + zh_Hans: 自动(优先 HTTP,失败回退 stdio) + - value: http + label: + en_US: HTTP only + zh_Hans: 仅 HTTP + - value: stdio + label: + en_US: stdio only + zh_Hans: 仅 stdio + default: auto + agentpay_command: + type: text-input + required: false + label: + en_US: stdio command + zh_Hans: stdio 命令 + placeholder: + en_US: npx + zh_Hans: npx + +tools: + - tools/discover_tools.yaml + - tools/list_tools.yaml + - tools/check_balance.yaml + - tools/fund_wallet_stripe.yaml + - tools/call_tool.yaml + - tools/get_usage.yaml + +extra: + python: + source: agentpay_mcp_payments.py diff --git a/plugins/community/agentpay-mcp-payments/tests/test_agentpay_client.py b/plugins/community/agentpay-mcp-payments/tests/test_agentpay_client.py new file mode 100644 index 0000000000..11ae90b50e --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tests/test_agentpay_client.py @@ -0,0 +1,19 @@ +import sys +import os +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) +from tools.client import AgentPayMCPClient + + +def test_requires_gateway_key() -> None: + try: + AgentPayMCPClient.from_credentials({}) + raise AssertionError("Expected missing key error") + except Exception as exc: # noqa: BLE001 + assert "gateway key" in str(exc).lower() + + +def test_default_credential_values() -> None: + client = AgentPayMCPClient.from_credentials({"agentpay_gateway_key": "apg_test"}) + assert client.gateway_key == "apg_test" + assert client.gateway_url == "https://agentpay.metaltorque.dev" + assert client.launch_mode == "auto" diff --git a/plugins/community/agentpay-mcp-payments/tools/call_tool.py b/plugins/community/agentpay-mcp-payments/tools/call_tool.py new file mode 100644 index 0000000000..a4e9321d47 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/call_tool.py @@ -0,0 +1,26 @@ +# pyright: reportMissingImports=false + +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from tools.client import AgentPayMCPClient +from tools.common import parse_json_object + + +class CallToolTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + tool_name = str(tool_parameters.get("tool_name") or "").strip() + if not tool_name: + raise ValueError("tool_name is required") + + arguments = parse_json_object( + tool_parameters.get("arguments_json"), + field_name="arguments_json", + ) + + client = AgentPayMCPClient.from_credentials(self.runtime.credentials) + result = client.call_tool("call_tool", {"tool": tool_name, "arguments": arguments}) + yield self.create_json_message(result) diff --git a/plugins/community/agentpay-mcp-payments/tools/call_tool.yaml b/plugins/community/agentpay-mcp-payments/tools/call_tool.yaml new file mode 100644 index 0000000000..cec2ebd79d --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/call_tool.yaml @@ -0,0 +1,33 @@ +identity: + name: call_tool + author: AI Agent Economy Community + label: + en_US: Paid Tool Call + description: + human: + en_US: Invoke a paid tool through AgentPay metering. + llm: Call a third-party paid tool by name with JSON arguments. AgentPay auto-provisions and meters usage. +parameters: + - name: tool_name + type: string + required: true + form: llm + label: + en_US: Tool Name + human_description: + en_US: Registered tool name in AgentPay marketplace. + llm_description: Name of the paid tool to call. + - name: arguments_json + type: string + required: false + form: llm + label: + en_US: Arguments JSON + human_description: + en_US: JSON object string for tool input arguments. + llm_description: JSON string object for the tool arguments. + placeholder: + en_US: '{"url":"https://example.com"}' +extra: + python: + source: tools/call_tool.py diff --git a/plugins/community/agentpay-mcp-payments/tools/check_balance.py b/plugins/community/agentpay-mcp-payments/tools/check_balance.py new file mode 100644 index 0000000000..502efa9b2d --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/check_balance.py @@ -0,0 +1,18 @@ +# pyright: reportMissingImports=false + +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from tools.client import AgentPayMCPClient + + +class CheckBalanceTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + _ = tool_parameters + client = AgentPayMCPClient.from_credentials(self.runtime.credentials) + # Always pass the gatewayKey in arguments for MCP compatibility + result = client.call_tool("check_balance", {"gatewayKey": client.gateway_key}) + yield self.create_json_message(result) diff --git a/plugins/community/agentpay-mcp-payments/tools/check_balance.yaml b/plugins/community/agentpay-mcp-payments/tools/check_balance.yaml new file mode 100644 index 0000000000..19c21cd563 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/check_balance.yaml @@ -0,0 +1,13 @@ +identity: + name: check_balance + author: AI Agent Economy Community + label: + en_US: Check Balance + description: + human: + en_US: Check wallet balance and provisioned tools. + llm: Check AgentPay wallet credits and provisioned tool access. +parameters: [] +extra: + python: + source: tools/check_balance.py diff --git a/plugins/community/agentpay-mcp-payments/tools/client.py b/plugins/community/agentpay-mcp-payments/tools/client.py new file mode 100644 index 0000000000..0e5fcf4b15 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/client.py @@ -0,0 +1,238 @@ +import json +import os +import subprocess +import time +import urllib.error +import urllib.request +from dataclasses import dataclass +from typing import Any + + +class AgentPayMCPError(RuntimeError): + """Base error for AgentPay MCP plugin client.""" + + +class AgentPayAuthError(AgentPayMCPError): + """Raised when credentials are invalid.""" + + +class AgentPayTimeoutError(AgentPayMCPError): + """Raised when request exceeds timeout.""" + + +@dataclass(slots=True) +class AgentPayMCPClient: + gateway_key: str + gateway_url: str + mcp_http_url: str | None + launch_mode: str + command: str + timeout_seconds: int = 30 + + @classmethod + def from_credentials(cls, credentials: dict[str, Any]) -> "AgentPayMCPClient": + gateway_key = str(credentials.get("agentpay_gateway_key") or "").strip() + if not gateway_key: + raise AgentPayAuthError("Missing AgentPay gateway key") + + gateway_url = str(credentials.get("agentpay_url") or "https://agentpay.metaltorque.dev").strip() + mcp_http_url = str(credentials.get("agentpay_mcp_http_url") or "").strip() or None + launch_mode = str(credentials.get("agentpay_launch_mode") or "auto").strip().lower() + command = str(credentials.get("agentpay_command") or "npx").strip() + + return cls( + gateway_key=gateway_key, + gateway_url=gateway_url, + mcp_http_url=mcp_http_url, + launch_mode=launch_mode, + command=command, + ) + + def call_tool(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: + if self.launch_mode == "http": + return self._http_call(tool_name, arguments) + + if self.launch_mode == "stdio": + return self._stdio_call(tool_name, arguments) + + # auto mode: prefer HTTP, fallback to stdio. + try: + return self._http_call(tool_name, arguments) + except Exception: + return self._stdio_call(tool_name, arguments) + + def _http_call(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: + print(f"[AgentPayMCPClient] HTTP call to {self.mcp_http_url} with tool '{tool_name}' and arguments {arguments}") + if not self.mcp_http_url: + raise AgentPayMCPError("MCP HTTP URL is not configured") + + payload = { + "jsonrpc": "2.0", + "id": "dify-agentpay-1", + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + }, + } + + request = urllib.request.Request( + self.mcp_http_url, + data=json.dumps(payload).encode("utf-8"), + headers={ + "Content-Type": "application/json", + "Authorization": f"Bearer {self.gateway_key}", + "X-AgentPay-Gateway-Key": self.gateway_key, + }, + method="POST", + ) + + try: + with urllib.request.urlopen(request, timeout=self.timeout_seconds) as response: + body = response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + if exc.code in (401, 403): + raise AgentPayAuthError("AgentPay authorization failed") from exc + raise AgentPayMCPError(f"AgentPay HTTP call failed: {exc}") from exc + except TimeoutError as exc: + raise AgentPayTimeoutError("AgentPay HTTP call timed out") from exc + except Exception as exc: # noqa: BLE001 + raise AgentPayMCPError(f"AgentPay HTTP call failed: {exc}") from exc + + try: + parsed = json.loads(body) + except json.JSONDecodeError as exc: + raise AgentPayMCPError("AgentPay HTTP response is not valid JSON") from exc + + return self._normalize_mcp_result(parsed) + + def _stdio_call(self, tool_name: str, arguments: dict[str, Any]) -> dict[str, Any]: + env = dict(os.environ) + env["AGENTPAY_GATEWAY_KEY"] = self.gateway_key + env.setdefault("AGENTPAY_URL", self.gateway_url) + + args = [self.command, "-y", "mcp-server-agentpay"] + process = subprocess.Popen( + args, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + + try: + initialize_request = { + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { + "name": "dify-agentpay-plugin", + "version": "0.0.1", + }, + }, + } + initialized_notification = { + "jsonrpc": "2.0", + "method": "notifications/initialized", + "params": {}, + } + tool_call_request = { + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": tool_name, + "arguments": arguments, + }, + } + + self._write_message(process, initialize_request) + _ = self._read_message(process) + self._write_message(process, initialized_notification) + self._write_message(process, tool_call_request) + response = self._read_message(process) + + return self._normalize_mcp_result(response) + except TimeoutError as exc: + raise AgentPayTimeoutError("AgentPay stdio call timed out") from exc + except Exception as exc: # noqa: BLE001 + raise AgentPayMCPError(f"AgentPay stdio call failed: {exc}") from exc + finally: + process.kill() + + def _write_message(self, process: subprocess.Popen[bytes], payload: dict[str, Any]) -> None: + if process.stdin is None: + raise AgentPayMCPError("stdio input is unavailable") + data = json.dumps(payload).encode("utf-8") + header = f"Content-Length: {len(data)}\r\n\r\n".encode("utf-8") + process.stdin.write(header + data) + process.stdin.flush() + + def _read_message(self, process: subprocess.Popen[bytes]) -> dict[str, Any]: + if process.stdout is None: + raise AgentPayMCPError("stdio output is unavailable") + + deadline = time.time() + self.timeout_seconds + + def read_line() -> bytes: + while time.time() < deadline: + line = process.stdout.readline() + if line: + return line + raise TimeoutError("Timed out reading MCP headers") + + content_length = 0 + while True: + line = read_line() + if line in (b"\r\n", b"\n", b""): + break + lower = line.decode("utf-8").lower() + if lower.startswith("content-length:"): + content_length = int(lower.split(":", 1)[1].strip()) + + if content_length <= 0: + raise AgentPayMCPError("Invalid MCP content length") + + remaining = content_length + chunks: list[bytes] = [] + while remaining > 0: + if time.time() >= deadline: + raise TimeoutError("Timed out reading MCP payload") + chunk = process.stdout.read(remaining) + if not chunk: + raise AgentPayMCPError("MCP server closed output early") + chunks.append(chunk) + remaining -= len(chunk) + + data = b"".join(chunks) + return json.loads(data.decode("utf-8")) + + def _normalize_mcp_result(self, response: dict[str, Any]) -> dict[str, Any]: + if "error" in response: + message = response["error"].get("message", "Unknown MCP error") + if "auth" in message.lower() or "key" in message.lower(): + raise AgentPayAuthError(message) + raise AgentPayMCPError(message) + + result = response.get("result") + if not isinstance(result, dict): + raise AgentPayMCPError("Invalid MCP result payload") + + content = result.get("content") + if isinstance(content, list): + for item in content: + if item.get("type") == "text" and "text" in item: + text = item["text"] + try: + parsed_text = json.loads(text) + if isinstance(parsed_text, dict): + return parsed_text + except json.JSONDecodeError: + return {"text": text} + if item.get("type") == "json" and isinstance(item.get("json"), dict): + return item["json"] + + return result diff --git a/plugins/community/agentpay-mcp-payments/tools/common.py b/plugins/community/agentpay-mcp-payments/tools/common.py new file mode 100644 index 0000000000..8f3f966166 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/common.py @@ -0,0 +1,20 @@ +import json +from typing import Any + + +def parse_json_object(value: str | None, *, field_name: str) -> dict[str, Any]: + if not value: + return {} + try: + data = json.loads(value) + except json.JSONDecodeError as exc: + raise ValueError(f"{field_name} must be valid JSON") from exc + if not isinstance(data, dict): + raise ValueError(f"{field_name} must be a JSON object") + return data + + +def to_int(value: Any, *, default: int) -> int: + if value is None or value == "": + return default + return int(value) diff --git a/plugins/community/agentpay-mcp-payments/tools/discover_tools.py b/plugins/community/agentpay-mcp-payments/tools/discover_tools.py new file mode 100644 index 0000000000..82822dbd23 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/discover_tools.py @@ -0,0 +1,21 @@ +# pyright: reportMissingImports=false + +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from tools.client import AgentPayMCPClient +from tools.common import to_int + + +class DiscoverToolsTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + client = AgentPayMCPClient.from_credentials(self.runtime.credentials) + payload = { + "query": tool_parameters.get("query", ""), + "limit": to_int(tool_parameters.get("limit"), default=10), + } + result = client.call_tool("discover_tools", payload) + yield self.create_json_message(result) diff --git a/plugins/community/agentpay-mcp-payments/tools/discover_tools.yaml b/plugins/community/agentpay-mcp-payments/tools/discover_tools.yaml new file mode 100644 index 0000000000..83a52e9821 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/discover_tools.yaml @@ -0,0 +1,32 @@ +identity: + name: discover_tools + author: AI Agent Economy Community + label: + en_US: Discover Tools + description: + human: + en_US: Search available paid tools by keyword. + llm: Search available paid tools by keyword and return pricing metadata. +parameters: + - name: query + type: string + required: false + form: llm + label: + en_US: Query + human_description: + en_US: Search keyword such as security, seo, or scraping. + llm_description: Keyword to search in tool marketplace. + - name: limit + type: number + required: false + form: llm + label: + en_US: Limit + human_description: + en_US: Max number of results to return. + llm_description: Maximum number of results to return. + default: 10 +extra: + python: + source: tools/discover_tools.py diff --git a/plugins/community/agentpay-mcp-payments/tools/fund_wallet_stripe.py b/plugins/community/agentpay-mcp-payments/tools/fund_wallet_stripe.py new file mode 100644 index 0000000000..46f37227e6 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/fund_wallet_stripe.py @@ -0,0 +1,19 @@ +# pyright: reportMissingImports=false + +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from tools.client import AgentPayMCPClient + + +class FundWalletStripeTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + client = AgentPayMCPClient.from_credentials(self.runtime.credentials) + payload = { + "package": str(tool_parameters.get("package") or "micro"), + } + result = client.call_tool("fund_wallet_stripe", payload) + yield self.create_json_message(result) diff --git a/plugins/community/agentpay-mcp-payments/tools/fund_wallet_stripe.yaml b/plugins/community/agentpay-mcp-payments/tools/fund_wallet_stripe.yaml new file mode 100644 index 0000000000..86bfd9dbc2 --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/fund_wallet_stripe.yaml @@ -0,0 +1,39 @@ +identity: + name: fund_wallet_stripe + author: AI Agent Economy Community + label: + en_US: Fund Wallet (Stripe) + description: + human: + en_US: Generate a Stripe checkout URL to fund wallet credits. + llm: Generate a Stripe checkout URL for adding credits to AgentPay wallet. +parameters: + - name: package + type: select + required: false + form: llm + label: + en_US: Package + human_description: + en_US: Credit package to purchase. + llm_description: Credit package name. + default: micro + options: + - value: micro + label: + en_US: micro + - value: small + label: + en_US: small + - value: medium + label: + en_US: medium + - value: large + label: + en_US: large + - value: whale + label: + en_US: whale +extra: + python: + source: tools/fund_wallet_stripe.py diff --git a/plugins/community/agentpay-mcp-payments/tools/get_usage.py b/plugins/community/agentpay-mcp-payments/tools/get_usage.py new file mode 100644 index 0000000000..a02663b61a --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/get_usage.py @@ -0,0 +1,20 @@ +# pyright: reportMissingImports=false + +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from tools.client import AgentPayMCPClient +from tools.common import to_int + + +class GetUsageTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + client = AgentPayMCPClient.from_credentials(self.runtime.credentials) + payload = { + "limit": to_int(tool_parameters.get("limit"), default=20), + } + result = client.call_tool("get_usage", payload) + yield self.create_json_message(result) diff --git a/plugins/community/agentpay-mcp-payments/tools/get_usage.yaml b/plugins/community/agentpay-mcp-payments/tools/get_usage.yaml new file mode 100644 index 0000000000..00fd56359a --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/get_usage.yaml @@ -0,0 +1,23 @@ +identity: + name: get_usage + author: AI Agent Economy Community + label: + en_US: Get Usage + description: + human: + en_US: View recent metered calls and costs. + llm: Get recent usage records and spending details. +parameters: + - name: limit + type: number + required: false + form: llm + label: + en_US: Limit + human_description: + en_US: Number of recent usage rows. + llm_description: Number of recent usage entries to fetch. + default: 20 +extra: + python: + source: tools/get_usage.py diff --git a/plugins/community/agentpay-mcp-payments/tools/list_tools.py b/plugins/community/agentpay-mcp-payments/tools/list_tools.py new file mode 100644 index 0000000000..af6cd1004c --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/list_tools.py @@ -0,0 +1,20 @@ +# pyright: reportMissingImports=false + +from collections.abc import Generator +from typing import Any + +from dify_plugin import Tool +from dify_plugin.entities.tool import ToolInvokeMessage + +from tools.client import AgentPayMCPClient +from tools.common import to_int + + +class ListToolsTool(Tool): + def _invoke(self, tool_parameters: dict[str, Any]) -> Generator[ToolInvokeMessage]: + client = AgentPayMCPClient.from_credentials(self.runtime.credentials) + payload = { + "limit": to_int(tool_parameters.get("limit"), default=25), + } + result = client.call_tool("list_tools", payload) + yield self.create_json_message(result) diff --git a/plugins/community/agentpay-mcp-payments/tools/list_tools.yaml b/plugins/community/agentpay-mcp-payments/tools/list_tools.yaml new file mode 100644 index 0000000000..39c617d55c --- /dev/null +++ b/plugins/community/agentpay-mcp-payments/tools/list_tools.yaml @@ -0,0 +1,23 @@ +identity: + name: list_tools + author: AI Agent Economy Community + label: + en_US: List Tools + description: + human: + en_US: List all available paid tools and their pricing. + llm: List all available paid tools and pricing plans. +parameters: + - name: limit + type: number + required: false + form: llm + label: + en_US: Limit + human_description: + en_US: Max number of tools to return. + llm_description: Maximum number of tools to return. + default: 25 +extra: + python: + source: tools/list_tools.py diff --git a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts index f44e14d608..11c58f85d3 100644 --- a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts +++ b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts @@ -348,6 +348,49 @@ describe('useMCPModalForm', () => { }) describe('handleUrlBlur', () => { + it('should auto-fill AgentPay defaults when url matches and fields are empty', async () => { + vi.mocked(await import('@/service/common').then(m => m.uploadRemoteFileInfo)).mockResolvedValueOnce({ + id: 'file123', + name: 'icon.png', + size: 1024, + mime_type: 'image/png', + url: 'https://example.com/files/file123/file-preview/icon.png', + } as unknown as { id: string, name: string, size: number, mime_type: string, url: string }) + + const { result } = renderHook(() => useMCPModalForm()) + + await act(async () => { + await result.current.actions.handleUrlBlur('https://api.agentpay.ai/mcp') + }) + + expect(result.current.state.name).toBe('AgentPay') + expect(result.current.state.serverIdentifier).toBe('agentpay') + }) + + it('should not override existing name and server identifier when url matches AgentPay', async () => { + vi.mocked(await import('@/service/common').then(m => m.uploadRemoteFileInfo)).mockResolvedValueOnce({ + id: 'file123', + name: 'icon.png', + size: 1024, + mime_type: 'image/png', + url: 'https://example.com/files/file123/file-preview/icon.png', + } as unknown as { id: string, name: string, size: number, mime_type: string, url: string }) + + const { result } = renderHook(() => useMCPModalForm()) + + act(() => { + result.current.actions.setName('Custom Name') + result.current.actions.setServerIdentifier('custom-id') + }) + + await act(async () => { + await result.current.actions.handleUrlBlur('https://api.agentpay.ai/mcp') + }) + + expect(result.current.state.name).toBe('Custom Name') + expect(result.current.state.serverIdentifier).toBe('custom-id') + }) + it('should not fetch icon in edit mode (when data is provided)', async () => { const mockData = { id: 'test', diff --git a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts index ec7c479b69..eb7cd84bfd 100644 --- a/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts +++ b/web/app/components/tools/mcp/hooks/use-mcp-modal-form.ts @@ -10,6 +10,8 @@ import { MCPAuthMethod } from '@/app/components/tools/types' import { uploadRemoteFileInfo } from '@/service/common' const DEFAULT_ICON = { type: 'emoji', icon: '🔗', background: '#6366F1' } +const AGENTPAY_DEFAULT_NAME = 'AgentPay' +const AGENTPAY_DEFAULT_SERVER_ID = 'agentpay' const extractFileId = (url: string) => { const match = /files\/(.+?)\/file-preview/.exec(url) @@ -32,6 +34,18 @@ const getInitialHeaders = (data?: ToolWithProvider): HeaderItem[] => { return Object.entries(data?.masked_headers || {}).map(([key, value]) => ({ id: uuid(), key, value })) } +const isAgentPayUrl = (urlValue: string) => { + try { + const parsed = new URL(urlValue) + const host = parsed.hostname.toLowerCase() + const path = parsed.pathname.toLowerCase() + return host.includes('agentpay') || path.includes('agentpay') + } + catch { + return false + } +} + export const isValidUrl = (string: string) => { try { const url = new URL(string) @@ -112,11 +126,29 @@ export const useMCPModalForm = (data?: ToolWithProvider) => { const [clientID, setClientID] = useState(() => data?.authentication?.client_id || '') const [credentials, setCredentials] = useState(() => data?.authentication?.client_secret || '') + const applyAgentPayDefaults = useCallback((urlValue: string) => { + if (!isAgentPayUrl(urlValue)) + return + + // Set smart defaults only when fields are still empty. + setName((prev) => { + if (prev.trim()) + return prev + return AGENTPAY_DEFAULT_NAME + }) + setServerIdentifier((prev) => { + if (prev.trim()) + return prev + return AGENTPAY_DEFAULT_SERVER_ID + }) + }, []) + const handleUrlBlur = useCallback(async (urlValue: string) => { if (data) return if (!isValidUrl(urlValue)) return + applyAgentPayDefaults(urlValue) const domain = getDomain(urlValue) const remoteIcon = `https://www.google.com/s2/favicons?domain=${domain}&sz=128` setIsFetchingIcon(true) @@ -145,7 +177,7 @@ export const useMCPModalForm = (data?: ToolWithProvider) => { finally { setIsFetchingIcon(false) } - }, [data]) + }, [applyAgentPayDefaults, data]) const resetIcon = useCallback(() => { setAppIcon(getIcon(data)) From e5d5eea4dad87fc53b7c2fe26d18f4d1427bc1df Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 02:58:31 +0000 Subject: [PATCH 2/3] [autofix.ci] apply automated fixes --- README.md | 1 - docs/agentpay-mcp-integration.md | 32 ++++++++++++------- .../community/agentpay-mcp-payments/README.md | 6 ++-- 3 files changed, 24 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 9c65cd90b7..1e474bd15b 100644 --- a/README.md +++ b/README.md @@ -154,7 +154,6 @@ If you want your agents to execute payment-capable workflows, follow the [AgentPay MCP Integration Guide](docs/agentpay-mcp-integration.md). The guide is beginner-friendly and uses Dify's existing MCP provider flow. - ### Metrics Monitoring with Grafana Import the dashboard to Grafana, using Dify's PostgreSQL database as data source, to monitor metrics in granularity of apps, tenants, messages, and more. diff --git a/docs/agentpay-mcp-integration.md b/docs/agentpay-mcp-integration.md index 78f485173a..60357a0641 100644 --- a/docs/agentpay-mcp-integration.md +++ b/docs/agentpay-mcp-integration.md @@ -2,28 +2,33 @@ This guide explains how to connect and use the AgentPay MCP Payments plugin with Dify, enabling agents and workflows to discover, fund, and invoke paid tools securely. ---- +______________________________________________________________________ ## 1. Prerequisites + - Dify instance (v1.13.0+ recommended) - AgentPay MCP server (HTTP or stdio) - AgentPay Gateway Key (starts with `apg_...`) - (Optional) Node.js for local stdio mode ---- +______________________________________________________________________ ## 2. Get Your AgentPay Gateway Key + Register for a gateway key: + ```bash curl -X POST https://agentpay.metaltorque.dev/gateway/register \ -H "Content-Type: application/json" \ -d '{"email": "you@example.com"}' ``` + Save the returned key (format: `apg_...`). ---- +______________________________________________________________________ ## 3. Start the AgentPay MCP Server + - **Recommended (HTTP):** ```bash npx -y supergateway --stdio "npx -y mcp-server-agentpay" --port 8000 @@ -34,32 +39,35 @@ Save the returned key (format: `apg_...`). npx -y mcp-server-agentpay ``` ---- +______________________________________________________________________ ## 4. Add MCP Server in Dify + 1. Go to **Tools → MCP → Add MCP Server (HTTP)** -2. Fill in: +1. Fill in: - **Server URL:** `http://localhost:8000/message` (or your HTTP endpoint) - **Name:** `AgentPay` - **Server Identifier:** `agentpay` - **Headers:** Add `X-AgentPay-Gateway-Key: apg_...` - **Dynamic Client Registration:** OFF - Leave Client ID/Secret blank -3. Save and authorize. -4. Click **Fetch Tools** to load available tools. +1. Save and authorize. +1. Click **Fetch Tools** to load available tools. ---- +______________________________________________________________________ ## 5. Attach Tools to Agents/Workflows + - Enable tools like `discover_tools`, `check_balance`, `call_tool` in your workflow or agent config. - Example prompts: - "Search for web scraping tools." - "Check my AgentPay balance." - "Call a paid tool with arguments: { ... }" ---- +______________________________________________________________________ ## 6. Troubleshooting + - **No tools appear:** - Check MCP server logs for errors - Ensure gateway key is correct and in headers @@ -76,15 +84,17 @@ Save the returned key (format: `apg_...`). -d '{"tool":"check_balance","args":{}}' ``` ---- +______________________________________________________________________ ## 7. Security & Best Practices + - Never commit gateway keys to source control - Rotate keys if exposed - Restrict tool usage in production - All payments are routed via AgentPay gateway; no private key custody ---- +______________________________________________________________________ ## 8. Support + - For advanced troubleshooting, see the operator runbook or contact plugin maintainers. diff --git a/plugins/community/agentpay-mcp-payments/README.md b/plugins/community/agentpay-mcp-payments/README.md index 975af0d853..45424ec6f8 100644 --- a/plugins/community/agentpay-mcp-payments/README.md +++ b/plugins/community/agentpay-mcp-payments/README.md @@ -79,8 +79,8 @@ Optional credential overrides: Minimal flow: 1. `Start` -2. `check_balance` tool node -3. `Answer` node +1. `check_balance` tool node +1. `Answer` node Answer template: @@ -95,7 +95,7 @@ Insert variable from picker (`CHECK_BALANCE -> text`) so node ID stays correct. Success requires both: 1. Tool node last run status = `SUCCESS` -2. Supergateway log shows `"method":"tools/call"` with `"name":"check_balance"` +1. Supergateway log shows `"method":"tools/call"` with `"name":"check_balance"` If you only see `initialize` and `tools/list`, execution did not reach tool invoke path. From 4fe529176c5bfc9f3778659dbea80b32ad42ab35 Mon Sep 17 00:00:00 2001 From: nourzakhama2003 Date: Sun, 15 Mar 2026 15:51:52 +0100 Subject: [PATCH 3/3] fix(agentpay): fail fast on auth errors and add coverage tests --- .../tests/test_agentpay_client.py | 40 ++++++++++++++++++- .../agentpay-mcp-payments/tools/client.py | 2 + .../__tests__/use-mcp-modal-form.spec.ts | 38 ++++++++++++++++++ 3 files changed, 79 insertions(+), 1 deletion(-) diff --git a/plugins/community/agentpay-mcp-payments/tests/test_agentpay_client.py b/plugins/community/agentpay-mcp-payments/tests/test_agentpay_client.py index 11ae90b50e..702cfe3d3d 100644 --- a/plugins/community/agentpay-mcp-payments/tests/test_agentpay_client.py +++ b/plugins/community/agentpay-mcp-payments/tests/test_agentpay_client.py @@ -1,7 +1,8 @@ import sys import os +import pytest sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) -from tools.client import AgentPayMCPClient +from tools.client import AgentPayAuthError, AgentPayMCPClient def test_requires_gateway_key() -> None: @@ -17,3 +18,40 @@ def test_default_credential_values() -> None: assert client.gateway_key == "apg_test" assert client.gateway_url == "https://agentpay.metaltorque.dev" assert client.launch_mode == "auto" + + +def test_auto_mode_raises_auth_error_without_stdio_fallback(monkeypatch: pytest.MonkeyPatch) -> None: + client = AgentPayMCPClient.from_credentials({"agentpay_gateway_key": "apg_test"}) + + stdio_called = False + + def mock_http_call(self: AgentPayMCPClient, tool_name: str, arguments: dict[str, object]) -> dict[str, object]: + raise AgentPayAuthError("invalid key") + + def mock_stdio_call(self: AgentPayMCPClient, tool_name: str, arguments: dict[str, object]) -> dict[str, object]: + nonlocal stdio_called + stdio_called = True + return {"ok": True} + + monkeypatch.setattr(AgentPayMCPClient, "_http_call", mock_http_call) + monkeypatch.setattr(AgentPayMCPClient, "_stdio_call", mock_stdio_call) + + with pytest.raises(AgentPayAuthError): + client.call_tool("check_balance", {}) + + assert stdio_called is False + + +def test_auto_mode_falls_back_to_stdio_for_non_auth_errors(monkeypatch: pytest.MonkeyPatch) -> None: + client = AgentPayMCPClient.from_credentials({"agentpay_gateway_key": "apg_test"}) + + def mock_http_call(self: AgentPayMCPClient, tool_name: str, arguments: dict[str, object]) -> dict[str, object]: + raise RuntimeError("network issue") + + def mock_stdio_call(self: AgentPayMCPClient, tool_name: str, arguments: dict[str, object]) -> dict[str, object]: + return {"ok": True} + + monkeypatch.setattr(AgentPayMCPClient, "_http_call", mock_http_call) + monkeypatch.setattr(AgentPayMCPClient, "_stdio_call", mock_stdio_call) + + assert client.call_tool("check_balance", {}) == {"ok": True} diff --git a/plugins/community/agentpay-mcp-payments/tools/client.py b/plugins/community/agentpay-mcp-payments/tools/client.py index 0e5fcf4b15..8c5b5923fb 100644 --- a/plugins/community/agentpay-mcp-payments/tools/client.py +++ b/plugins/community/agentpay-mcp-payments/tools/client.py @@ -58,6 +58,8 @@ class AgentPayMCPClient: # auto mode: prefer HTTP, fallback to stdio. try: return self._http_call(tool_name, arguments) + except AgentPayAuthError: + raise except Exception: return self._stdio_call(tool_name, arguments) diff --git a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts index 11c58f85d3..60def853db 100644 --- a/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts +++ b/web/app/components/tools/mcp/hooks/__tests__/use-mcp-modal-form.spec.ts @@ -348,6 +348,44 @@ describe('useMCPModalForm', () => { }) describe('handleUrlBlur', () => { + it('should safely continue when AgentPay URL detection throws', async () => { + const { uploadRemoteFileInfo } = await import('@/service/common') + vi.mocked(uploadRemoteFileInfo).mockResolvedValueOnce({ + id: 'file123', + name: 'icon.png', + size: 1024, + mime_type: 'image/png', + url: 'https://example.com/files/file123/file-preview/icon.png', + } as unknown as { id: string, name: string, size: number, mime_type: string, url: string }) + + const OriginalURL = globalThis.URL + let urlConstructorCalls = 0 + + class MockURL extends OriginalURL { + constructor(url: string | URL, base?: string | URL) { + urlConstructorCalls += 1 + if (urlConstructorCalls === 2) + throw new Error('forced URL parse error') + super(url, base) + } + } + + vi.stubGlobal('URL', MockURL) + + const { result } = renderHook(() => useMCPModalForm()) + + await act(async () => { + await result.current.actions.handleUrlBlur('https://example.com/mcp') + }) + + expect(result.current.state.name).toBe('') + expect(result.current.state.serverIdentifier).toBe('') + expect(result.current.state.appIcon.type).toBe('image') + expect(result.current.state.isFetchingIcon).toBe(false) + + vi.unstubAllGlobals() + }) + it('should auto-fill AgentPay defaults when url matches and fields are empty', async () => { vi.mocked(await import('@/service/common').then(m => m.uploadRemoteFileInfo)).mockResolvedValueOnce({ id: 'file123',