This commit is contained in:
Nour Zakhma 2026-03-24 02:53:47 +01:00 committed by GitHub
commit 0e6834b425
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 1148 additions and 1 deletions

4
.gitignore vendored
View File

@ -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

View File

@ -148,6 +148,12 @@ 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.

View File

@ -0,0 +1,100 @@
# 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)**
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
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
- 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.

View File

@ -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`
1. `check_balance` tool node
1. `Answer` node
Answer template:
```text
{{#<check_balance_node_id>.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`
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.
## 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

View File

@ -0,0 +1,5 @@
<svg width="128" height="128" viewBox="0 0 128 128" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="128" height="128" rx="24" fill="#0F172A"/>
<path d="M34 64C34 47.4315 47.4315 34 64 34H94V50H64C56.268 50 50 56.268 50 64C50 71.732 56.268 78 64 78H70V62H86V94H64C47.4315 94 34 80.5685 34 64Z" fill="#38BDF8"/>
<circle cx="92" cy="42" r="10" fill="#22C55E"/>
</svg>

After

Width:  |  Height:  |  Size: 384 B

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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.

View File

@ -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

View File

@ -0,0 +1,57 @@
import sys
import os
import pytest
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
from tools.client import AgentPayAuthError, 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"
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}

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -0,0 +1,240 @@
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 AgentPayAuthError:
raise
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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -348,6 +348,87 @@ 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',
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',

View File

@ -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))