mcp-server-agentpay 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
agentpay/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """AgentPay — Agent-to-Agent micropayments via x402 + MCP."""
2
+
3
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,208 @@
1
+ """Autonomous x402 payment HTTP client for AI agents."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any
7
+
8
+ import httpx
9
+
10
+ from agentpay.config import Settings, get_settings
11
+ from agentpay.payment.verifier import create_demo_payment_signature
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+
16
+ class PaymentPolicyError(Exception):
17
+ """Raised when a payment exceeds agent spending policy."""
18
+
19
+
20
+ class X402PaymentRequired(Exception):
21
+ """Raised when 402 is received but auto-pay is disabled."""
22
+
23
+ def __init__(self, challenge: dict[str, Any], headers: dict[str, str]):
24
+ self.challenge = challenge
25
+ self.headers = headers
26
+ super().__init__("Payment required")
27
+
28
+
29
+ class X402AgentClient:
30
+ """
31
+ HTTP client that intercepts 402 Payment Required responses,
32
+ signs payment authorization, and automatically retries.
33
+ """
34
+
35
+ def __init__(self, settings: Settings | None = None):
36
+ self.settings = settings or get_settings()
37
+ self._payer_address: str | None = None
38
+
39
+ @property
40
+ def payer_address(self) -> str:
41
+ if self._payer_address:
42
+ return self._payer_address
43
+
44
+ if self.settings.agent_private_key:
45
+ try:
46
+ from eth_account import Account
47
+
48
+ account = Account.from_key(self.settings.agent_private_key)
49
+ self._payer_address = account.address
50
+ return self._payer_address
51
+ except Exception as e:
52
+ logger.warning("Invalid agent private key: %s", e)
53
+
54
+ self._payer_address = "0xAgentDemoWallet000000000000000000000001"
55
+ return self._payer_address
56
+
57
+ def _check_spending_policy(self, amount_str: str) -> None:
58
+ amount = float(str(amount_str).lstrip("$"))
59
+ if amount > self.settings.max_spend_per_call:
60
+ raise PaymentPolicyError(
61
+ f"Payment {amount} USDC exceeds max_spend_per_call "
62
+ f"({self.settings.max_spend_per_call} USDC)"
63
+ )
64
+
65
+ def _sign_demo_payment(self, challenge: dict[str, Any]) -> str:
66
+ amount = challenge.get("amount") or self.settings.payment_amount
67
+ recipient = challenge.get("recipient") or self.settings.wallet_recipient_address
68
+ self._check_spending_policy(str(amount))
69
+ sig = create_demo_payment_signature(
70
+ payer=self.payer_address,
71
+ amount=str(amount).lstrip("$"),
72
+ recipient=recipient,
73
+ )
74
+ logger.info("Demo payment signed: %s USDC -> %s", amount, recipient[:10])
75
+ return sig
76
+
77
+ async def request(
78
+ self,
79
+ method: str,
80
+ url: str,
81
+ *,
82
+ params: dict[str, Any] | None = None,
83
+ auto_pay: bool = True,
84
+ **kwargs: Any,
85
+ ) -> httpx.Response:
86
+ """Make HTTP request with automatic 402 handling."""
87
+ if not self.settings.is_demo_mode:
88
+ return await self._request_official_x402(
89
+ method, url, params=params, auto_pay=auto_pay, **kwargs
90
+ )
91
+
92
+ async with httpx.AsyncClient(timeout=90.0) as client:
93
+ response = await client.request(method, url, params=params, **kwargs)
94
+
95
+ if response.status_code != 402:
96
+ return response
97
+
98
+ if not auto_pay:
99
+ raise X402PaymentRequired(response.json(), dict(response.headers))
100
+
101
+ challenge = response.json()
102
+ amount = (
103
+ challenge.get("amount")
104
+ or response.headers.get("X-Payment-Amount")
105
+ or self.settings.payment_amount
106
+ )
107
+ challenge["amount"] = amount
108
+ challenge["recipient"] = (
109
+ challenge.get("recipient")
110
+ or response.headers.get("X-Payment-Recipient")
111
+ or self.settings.wallet_recipient_address
112
+ )
113
+
114
+ signature = self._sign_demo_payment(challenge)
115
+ headers = kwargs.pop("headers", {}) or {}
116
+ headers["X-Payment-Signature"] = signature
117
+ headers["X-Payment-Payer"] = self.payer_address
118
+
119
+ logger.info("Retrying paid request to %s", url)
120
+ return await client.request(method, url, params=params, headers=headers, **kwargs)
121
+
122
+ async def _request_official_x402(
123
+ self,
124
+ method: str,
125
+ url: str,
126
+ *,
127
+ params: dict[str, Any] | None = None,
128
+ auto_pay: bool = True,
129
+ **kwargs: Any,
130
+ ) -> httpx.Response:
131
+ """Production path: official x402 SDK signs + facilitator settles."""
132
+ if not self.settings.agent_private_key:
133
+ raise ValueError("AGENT_PRIVATE_KEY required for production x402 payments")
134
+
135
+ from eth_account import Account
136
+ from x402 import x402Client
137
+ from x402.http.clients.httpx import x402HttpxClient
138
+ from x402.mechanisms.evm.exact import ExactEvmScheme
139
+ from x402.mechanisms.evm.signers import EthAccountSigner
140
+
141
+ self._check_spending_policy(self.settings.payment_amount)
142
+
143
+ account = Account.from_key(self.settings.agent_private_key)
144
+ self._payer_address = account.address
145
+ signer = EthAccountSigner(account)
146
+
147
+ payment_client = x402Client()
148
+ payment_client.register(self.settings.payment_network, ExactEvmScheme(signer))
149
+ # Also register wildcard for network matching quirks
150
+ payment_client.register("eip155:*", ExactEvmScheme(signer))
151
+
152
+ if not auto_pay:
153
+ async with httpx.AsyncClient(timeout=90.0) as client:
154
+ response = await client.request(method, url, params=params, **kwargs)
155
+ if response.status_code == 402:
156
+ raise X402PaymentRequired(
157
+ response.json() if response.content else {},
158
+ dict(response.headers),
159
+ )
160
+ return response
161
+
162
+ async with x402HttpxClient(payment_client, timeout=90.0) as client:
163
+ response = await client.request(method, url, params=params, **kwargs)
164
+ return response
165
+
166
+ async def get_sourcing(
167
+ self,
168
+ keyword: str,
169
+ category_id: str | None = None,
170
+ max_price: float | None = None,
171
+ ) -> dict[str, Any]:
172
+ settings = self.settings
173
+ url = f"{settings.gateway_base_url}/api/v1/paid/sourcing"
174
+ params: dict[str, Any] = {"keyword": keyword}
175
+ if category_id:
176
+ params["category_id"] = category_id
177
+ if max_price is not None:
178
+ params["max_price"] = max_price
179
+
180
+ response = await self.request("GET", url, params=params)
181
+ response.raise_for_status()
182
+ return response.json()
183
+
184
+ async def get_xhs_note(
185
+ self,
186
+ note: str,
187
+ note_type: str | None = None,
188
+ ) -> dict[str, Any]:
189
+ url = f"{self.settings.gateway_base_url}/api/v1/paid/xhs/note"
190
+ params: dict[str, Any] = {"note": note}
191
+ if note_type:
192
+ params["note_type"] = note_type
193
+ response = await self.request("GET", url, params=params)
194
+ response.raise_for_status()
195
+ return response.json()
196
+
197
+ async def get_xhs_user_notes(
198
+ self,
199
+ user_id: str,
200
+ cursor: str = "",
201
+ ) -> dict[str, Any]:
202
+ url = f"{self.settings.gateway_base_url}/api/v1/paid/xhs/user_notes"
203
+ params: dict[str, Any] = {"user_id": user_id}
204
+ if cursor:
205
+ params["cursor"] = cursor
206
+ response = await self.request("GET", url, params=params)
207
+ response.raise_for_status()
208
+ return response.json()
agentpay/config.py ADDED
@@ -0,0 +1,68 @@
1
+ """Central configuration loaded from environment variables."""
2
+
3
+ from functools import lru_cache
4
+
5
+ from pydantic_settings import BaseSettings, SettingsConfigDict
6
+
7
+ # Well-known USDC contract addresses
8
+ USDC_BASE_MAINNET = "0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913"
9
+ USDC_BASE_SEPOLIA = "0x036CbD53842c5426634e7929541eC2318f3dCF7e"
10
+
11
+
12
+ class Settings(BaseSettings):
13
+ model_config = SettingsConfigDict(
14
+ env_file=".env",
15
+ env_file_encoding="utf-8",
16
+ extra="ignore",
17
+ )
18
+
19
+ # Gateway
20
+ wallet_recipient_address: str = "0x5f29e616e34bf86dD72B622Ef392b2FDcF1Aa50E"
21
+ payment_network: str = "eip155:8453" # Base Mainnet; use eip155:84532 for Sepolia test
22
+ payment_amount: str = "0.05"
23
+ payment_token_address: str = USDC_BASE_MAINNET
24
+ gateway_host: str = "127.0.0.1"
25
+ gateway_port: int = 8402
26
+ gateway_base_url: str = "http://127.0.0.1:8402"
27
+
28
+ # demo = local fake pay | production = official x402 facilitator settle
29
+ payment_mode: str = "demo"
30
+ base_rpc_url: str = "https://mainnet.base.org"
31
+
32
+ # Agent client
33
+ agent_private_key: str = ""
34
+ max_spend_per_call: float = 0.10
35
+
36
+ # Optional override; default picks testnet vs mainnet facilitator
37
+ x402_facilitator_url: str = ""
38
+
39
+ # Xiaohongshu data providers (gateway-side secrets only)
40
+ dataflow_base_url: str = "https://dataflowserver.org"
41
+ dataflow_api_token: str = ""
42
+ tikhub_api_key: str = ""
43
+
44
+ # Operator debug only — agents should always pay the gateway
45
+ xhs_direct_mode: bool = False
46
+
47
+ @property
48
+ def payment_token_symbol(self) -> str:
49
+ return "USDC"
50
+
51
+ @property
52
+ def is_demo_mode(self) -> bool:
53
+ return self.payment_mode.lower() == "demo"
54
+
55
+ @property
56
+ def is_testnet(self) -> bool:
57
+ return "84532" in self.payment_network
58
+
59
+ @property
60
+ def has_xhs_credentials(self) -> bool:
61
+ return bool(
62
+ (self.dataflow_api_token or "").strip() or (self.tikhub_api_key or "").strip()
63
+ )
64
+
65
+
66
+ @lru_cache
67
+ def get_settings() -> Settings:
68
+ return Settings()
File without changes
@@ -0,0 +1,207 @@
1
+ """AgentPay x402 Micropayment Data Gateway — FastAPI application."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from contextlib import asynccontextmanager
7
+
8
+ import uvicorn
9
+ from fastapi import FastAPI, Query
10
+ from fastapi.middleware.cors import CORSMiddleware
11
+
12
+ from agentpay.config import get_settings
13
+ from agentpay.gateway.data import query_wholesale_pricing
14
+ from agentpay.gateway.middleware import X402PaymentMiddleware
15
+ from agentpay.mcp_server.server import mcp as mcp_server
16
+ from agentpay.payment.x402_setup import build_paid_routes, build_resource_server, settlement_info
17
+ from agentpay.xhs.fetcher import fetch_note_detail, fetch_user_notes
18
+
19
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(name)s: %(message)s")
20
+ logger = logging.getLogger(__name__)
21
+
22
+
23
+ def create_app() -> FastAPI:
24
+ settings = get_settings()
25
+
26
+ # Remote MCP (Streamable HTTP) for Smithery / Glama connectors
27
+ mcp_http = mcp_server.streamable_http_app(
28
+ streamable_http_path="/",
29
+ stateless_http=True,
30
+ host="0.0.0.0",
31
+ )
32
+
33
+ @asynccontextmanager
34
+ async def lifespan(_app: FastAPI):
35
+ async with mcp_http.router.lifespan_context(mcp_http):
36
+ logger.info("MCP Streamable HTTP mounted at /mcp")
37
+ yield
38
+
39
+ app = FastAPI(
40
+ title="AgentPay x402 Gateway",
41
+ description=(
42
+ "Monetized API gateway with HTTP 402 micropayment enforcement. "
43
+ "Agents pay USDC on Base per request for Xiaohongshu note data and other premium APIs. "
44
+ "Also exposes MCP tools at /mcp (Streamable HTTP)."
45
+ ),
46
+ version="0.1.0",
47
+ lifespan=lifespan,
48
+ )
49
+ app.mount("/mcp", mcp_http)
50
+
51
+ # Railway/Fly terminate TLS; trust X-Forwarded-* so 402 resource URLs are https://
52
+ from uvicorn.middleware.proxy_headers import ProxyHeadersMiddleware
53
+
54
+ app.add_middleware(ProxyHeadersMiddleware, trusted_hosts="*")
55
+
56
+ app.add_middleware(
57
+ CORSMiddleware,
58
+ allow_origins=["*"],
59
+ allow_methods=["*"],
60
+ allow_headers=["*"],
61
+ expose_headers=[
62
+ "X-Payment-Required",
63
+ "X-Payment-Network",
64
+ "X-Payment-Token",
65
+ "X-Payment-Amount",
66
+ "X-Payment-Recipient",
67
+ "X-Payment-Verified",
68
+ "X-Payment-Payer",
69
+ "PAYMENT-REQUIRED",
70
+ "PAYMENT-RESPONSE",
71
+ ],
72
+ )
73
+
74
+ if settings.is_demo_mode:
75
+ # Local/dev: accept demo signatures (no on-chain settlement)
76
+ app.add_middleware(X402PaymentMiddleware, settings=settings)
77
+ logger.info("Payment middleware: demo (no chain settlement)")
78
+ else:
79
+ # Production/testnet: official x402 verify + settle via facilitator
80
+ from x402.http.middleware.fastapi import PaymentMiddlewareASGI
81
+
82
+ server = build_resource_server(settings)
83
+ routes = build_paid_routes(settings)
84
+ app.add_middleware(PaymentMiddlewareASGI, routes=routes, server=server)
85
+ logger.info(
86
+ "Payment middleware: x402 facilitator=%s network=%s pay_to=%s",
87
+ settlement_info(settings)["facilitator"],
88
+ settings.payment_network,
89
+ settings.wallet_recipient_address,
90
+ )
91
+
92
+ @app.get("/health")
93
+ async def health():
94
+ info = settlement_info(settings)
95
+ return {
96
+ "status": "ok",
97
+ "service": "agentpay-gateway",
98
+ "payment_mode": settings.payment_mode,
99
+ "xhs_credentials": settings.has_xhs_credentials,
100
+ "mcp": "/mcp",
101
+ "settlement": info,
102
+ }
103
+
104
+ @app.get("/api/v1/pricing")
105
+ async def pricing_info():
106
+ """Public endpoint: returns payment requirements without serving data."""
107
+ return {
108
+ "services": [
109
+ {
110
+ "name": "xhs_note_detail",
111
+ "paid_endpoint": "/api/v1/paid/xhs/note",
112
+ "amount": settings.payment_amount,
113
+ },
114
+ {
115
+ "name": "xhs_user_notes",
116
+ "paid_endpoint": "/api/v1/paid/xhs/user_notes",
117
+ "amount": settings.payment_amount,
118
+ },
119
+ {
120
+ "name": "china_wholesale_pricing",
121
+ "paid_endpoint": "/api/v1/paid/sourcing",
122
+ "amount": settings.payment_amount,
123
+ },
124
+ ],
125
+ "payment_required": True,
126
+ "network": settings.payment_network,
127
+ "token": settings.payment_token_symbol,
128
+ "recipient": settings.wallet_recipient_address,
129
+ "settlement": settlement_info(settings),
130
+ }
131
+
132
+ @app.get("/api/v1/paid/sourcing")
133
+ async def get_sourcing_data(
134
+ keyword: str = Query(..., description="Product search query"),
135
+ category_id: str | None = Query(None, description="Optional category filter"),
136
+ max_price: float | None = Query(None, description="Max unit price USD"),
137
+ ):
138
+ """Monetized endpoint: wholesale pricing data (requires x402 payment)."""
139
+ return query_wholesale_pricing(keyword, category_id, max_price)
140
+
141
+ @app.get("/api/v1/paid/xhs/note")
142
+ async def get_xhs_note(
143
+ note: str = Query(..., description="Xiaohongshu note URL or 24-char note_id"),
144
+ note_type: str | None = Query(
145
+ None, description="Optional hint: image | video | 图文 | 视频"
146
+ ),
147
+ ):
148
+ """Monetized: fetch Xiaohongshu note detail."""
149
+ if not settings.has_xhs_credentials:
150
+ return {
151
+ "status": "error",
152
+ "error": "Note data provider not configured on gateway",
153
+ }
154
+ return await fetch_note_detail(
155
+ note_id_or_link=note,
156
+ note_type_hint=note_type,
157
+ settings=settings,
158
+ )
159
+
160
+ @app.get("/api/v1/paid/xhs/user_notes")
161
+ async def get_xhs_user_notes(
162
+ user_id: str = Query(..., description="Xiaohongshu user ID"),
163
+ cursor: str = Query("", description="Pagination cursor from previous response"),
164
+ ):
165
+ """Monetized: fetch a user's posted notes list."""
166
+ if not settings.has_xhs_credentials:
167
+ return {
168
+ "status": "error",
169
+ "error": "Note data provider not configured on gateway",
170
+ }
171
+ return await fetch_user_notes(user_id=user_id, cursor=cursor, settings=settings)
172
+
173
+ return app
174
+
175
+
176
+ app = create_app()
177
+
178
+
179
+ def main() -> None:
180
+ # Clear settings cache so .env edits apply on restart
181
+ get_settings.cache_clear()
182
+ settings = get_settings()
183
+ info = settlement_info(settings)
184
+ # Railway/Cloud inject PORT
185
+ import os
186
+
187
+ port = int(os.getenv("PORT") or settings.gateway_port)
188
+ host = os.getenv("HOST") or settings.gateway_host or "0.0.0.0"
189
+ logger.info(
190
+ "Starting AgentPay Gateway on %s:%s mode=%s network=%s pay_to=%s facilitator=%s",
191
+ host,
192
+ port,
193
+ settings.payment_mode,
194
+ settings.payment_network,
195
+ settings.wallet_recipient_address,
196
+ info["facilitator"],
197
+ )
198
+ uvicorn.run(
199
+ "agentpay.gateway.app:app",
200
+ host=host,
201
+ port=port,
202
+ reload=False,
203
+ )
204
+
205
+
206
+ if __name__ == "__main__":
207
+ main()
@@ -0,0 +1,65 @@
1
+ """Mock wholesale pricing data provider."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ from typing import Any
7
+
8
+
9
+ def _stable_price(keyword: str, index: int) -> float:
10
+ seed = hashlib.md5(f"{keyword}:{index}".encode()).hexdigest()
11
+ return round(0.5 + (int(seed[:8], 16) % 500) / 100, 2)
12
+
13
+
14
+ def _factory_locations() -> list[str]:
15
+ return [
16
+ "Yiwu, Zhejiang",
17
+ "Shenzhen, Guangdong",
18
+ "Guangzhou, Guangdong",
19
+ "Dongguan, Guangdong",
20
+ "Ningbo, Zhejiang",
21
+ "Hangzhou, Zhejiang",
22
+ ]
23
+
24
+
25
+ def query_wholesale_pricing(
26
+ keyword: str,
27
+ category_id: str | None = None,
28
+ max_price: float | None = None,
29
+ ) -> dict[str, Any]:
30
+ """
31
+ Return structured manufacturing/wholesale data.
32
+ In production, this would call 1688/Yiwu APIs behind the paywall.
33
+ """
34
+ locations = _factory_locations()
35
+ items: list[dict[str, Any]] = []
36
+
37
+ for i in range(5):
38
+ unit_price = _stable_price(keyword, i)
39
+ if max_price is not None and unit_price > max_price:
40
+ continue
41
+
42
+ item_id = f"CN-{hashlib.md5(f'{keyword}{i}'.encode()).hexdigest()[:6].upper()}"
43
+ items.append(
44
+ {
45
+ "item_id": item_id,
46
+ "name": f"Factory Direct: {keyword.title()} — Model {i + 1}",
47
+ "factory_location": locations[i % len(locations)],
48
+ "supplier_verified": i % 2 == 0,
49
+ "moq": [100, 200, 500, 1000][i % 4],
50
+ "unit_price_usd": unit_price,
51
+ "lead_time_days": [5, 7, 10, 14][i % 4],
52
+ "platform": ["1688", "Yiwu Market", "Alibaba"][i % 3],
53
+ }
54
+ )
55
+
56
+ return {
57
+ "status": "success",
58
+ "data": {
59
+ "query": keyword,
60
+ "category_id": category_id,
61
+ "currency": "USD",
62
+ "total_results": len(items),
63
+ "items": items,
64
+ },
65
+ }
@@ -0,0 +1,53 @@
1
+ """FastAPI x402 payment enforcement middleware."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ from fastapi import Request
8
+ from fastapi.responses import JSONResponse
9
+ from starlette.middleware.base import BaseHTTPMiddleware
10
+ from starlette.responses import Response
11
+
12
+ from agentpay.config import Settings, get_settings
13
+ from agentpay.payment.verifier import build_402_body, build_402_headers, verify_payment
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+ PROTECTED_PREFIX = "/api/v1/paid/"
18
+
19
+
20
+ class X402PaymentMiddleware(BaseHTTPMiddleware):
21
+ """Intercept unpaid requests to monetized routes and return HTTP 402."""
22
+
23
+ def __init__(self, app, settings: Settings | None = None):
24
+ super().__init__(app)
25
+ self.settings = settings or get_settings()
26
+
27
+ async def dispatch(self, request: Request, call_next) -> Response:
28
+ if not request.url.path.startswith(PROTECTED_PREFIX):
29
+ return await call_next(request)
30
+
31
+ signature = request.headers.get("X-Payment-Signature")
32
+ payer = request.headers.get("X-Payment-Payer")
33
+
34
+ if not verify_payment(signature, payer, self.settings):
35
+ logger.info(
36
+ "402 challenge for %s %s (payer=%s)",
37
+ request.method,
38
+ request.url.path,
39
+ payer or "none",
40
+ )
41
+ return JSONResponse(
42
+ status_code=402,
43
+ headers=build_402_headers(self.settings),
44
+ content=build_402_body(self.settings),
45
+ )
46
+
47
+ response = await call_next(request)
48
+
49
+ # Attach settlement confirmation header
50
+ response.headers["X-Payment-Verified"] = "true"
51
+ if payer:
52
+ response.headers["X-Payment-Payer"] = payer
53
+ return response
File without changes