traia-iatp 0.1.2__py3-none-any.whl → 0.1.67__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.
- traia_iatp/__init__.py +105 -8
- traia_iatp/cli/main.py +85 -1
- traia_iatp/client/__init__.py +28 -3
- traia_iatp/client/crewai_a2a_tools.py +32 -12
- traia_iatp/client/d402_a2a_client.py +348 -0
- traia_iatp/contracts/__init__.py +11 -0
- traia_iatp/contracts/data/abis/contract-abis-localhost.json +4091 -0
- traia_iatp/contracts/data/abis/contract-abis-sepolia.json +4890 -0
- traia_iatp/contracts/data/addresses/contract-addresses.json +17 -0
- traia_iatp/contracts/data/addresses/contract-proxies.json +12 -0
- traia_iatp/contracts/iatp_contracts_config.py +263 -0
- traia_iatp/contracts/wallet_creator.py +369 -0
- traia_iatp/core/models.py +17 -3
- traia_iatp/d402/MIDDLEWARE_ARCHITECTURE.md +205 -0
- traia_iatp/d402/PRICE_BUILDER_USAGE.md +249 -0
- traia_iatp/d402/README.md +489 -0
- traia_iatp/d402/__init__.py +54 -0
- traia_iatp/d402/asgi_wrapper.py +469 -0
- traia_iatp/d402/chains.py +102 -0
- traia_iatp/d402/client.py +150 -0
- traia_iatp/d402/clients/__init__.py +7 -0
- traia_iatp/d402/clients/base.py +218 -0
- traia_iatp/d402/clients/httpx.py +266 -0
- traia_iatp/d402/common.py +114 -0
- traia_iatp/d402/encoding.py +28 -0
- traia_iatp/d402/examples/client_example.py +197 -0
- traia_iatp/d402/examples/server_example.py +171 -0
- traia_iatp/d402/facilitator.py +481 -0
- traia_iatp/d402/mcp_middleware.py +296 -0
- traia_iatp/d402/models.py +116 -0
- traia_iatp/d402/networks.py +98 -0
- traia_iatp/d402/path.py +43 -0
- traia_iatp/d402/payment_introspection.py +126 -0
- traia_iatp/d402/payment_signing.py +183 -0
- traia_iatp/d402/price_builder.py +164 -0
- traia_iatp/d402/servers/__init__.py +61 -0
- traia_iatp/d402/servers/base.py +139 -0
- traia_iatp/d402/servers/example_general_server.py +140 -0
- traia_iatp/d402/servers/fastapi.py +253 -0
- traia_iatp/d402/servers/mcp.py +304 -0
- traia_iatp/d402/servers/starlette.py +878 -0
- traia_iatp/d402/starlette_middleware.py +529 -0
- traia_iatp/d402/types.py +300 -0
- traia_iatp/mcp/D402_MCP_ADAPTER_FLOW.md +357 -0
- traia_iatp/mcp/__init__.py +3 -0
- traia_iatp/mcp/d402_mcp_tool_adapter.py +526 -0
- traia_iatp/mcp/mcp_agent_template.py +78 -13
- traia_iatp/mcp/templates/Dockerfile.j2 +27 -4
- traia_iatp/mcp/templates/README.md.j2 +104 -8
- traia_iatp/mcp/templates/cursor-rules.md.j2 +194 -0
- traia_iatp/mcp/templates/deployment_params.json.j2 +1 -2
- traia_iatp/mcp/templates/docker-compose.yml.j2 +13 -3
- traia_iatp/mcp/templates/env.example.j2 +60 -0
- traia_iatp/mcp/templates/mcp_health_check.py.j2 +2 -2
- traia_iatp/mcp/templates/pyproject.toml.j2 +11 -5
- traia_iatp/mcp/templates/pyrightconfig.json.j2 +22 -0
- traia_iatp/mcp/templates/run_local_docker.sh.j2 +320 -10
- traia_iatp/mcp/templates/server.py.j2 +174 -197
- traia_iatp/mcp/traia_mcp_adapter.py +182 -20
- traia_iatp/registry/__init__.py +47 -12
- traia_iatp/registry/atlas_search_indexes.json +108 -54
- traia_iatp/registry/iatp_search_api.py +169 -39
- traia_iatp/registry/mongodb_registry.py +241 -69
- traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +1 -1
- traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +8 -8
- traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +1 -1
- traia_iatp/registry/readmes/README.md +3 -3
- traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +6 -6
- traia_iatp/scripts/__init__.py +2 -0
- traia_iatp/scripts/create_wallet.py +244 -0
- traia_iatp/server/a2a_server.py +22 -7
- traia_iatp/server/iatp_server_template_generator.py +23 -0
- traia_iatp/server/templates/.dockerignore.j2 +48 -0
- traia_iatp/server/templates/Dockerfile.j2 +23 -1
- traia_iatp/server/templates/README.md +2 -2
- traia_iatp/server/templates/README.md.j2 +5 -5
- traia_iatp/server/templates/__main__.py.j2 +374 -66
- traia_iatp/server/templates/agent.py.j2 +12 -11
- traia_iatp/server/templates/agent_config.json.j2 +3 -3
- traia_iatp/server/templates/agent_executor.py.j2 +45 -27
- traia_iatp/server/templates/env.example.j2 +32 -4
- traia_iatp/server/templates/gitignore.j2 +7 -0
- traia_iatp/server/templates/pyproject.toml.j2 +13 -12
- traia_iatp/server/templates/run_local_docker.sh.j2 +143 -11
- traia_iatp/server/templates/server.py.j2 +197 -10
- traia_iatp/special_agencies/registry_search_agency.py +1 -1
- traia_iatp/utils/iatp_utils.py +6 -6
- traia_iatp-0.1.67.dist-info/METADATA +320 -0
- traia_iatp-0.1.67.dist-info/RECORD +117 -0
- traia_iatp-0.1.2.dist-info/METADATA +0 -414
- traia_iatp-0.1.2.dist-info/RECORD +0 -72
- {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/WHEEL +0 -0
- {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/entry_points.txt +0 -0
- {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/licenses/LICENSE +0 -0
- {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,529 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Starlette middleware adaptors for d402 payment protocol.
|
|
3
|
+
|
|
4
|
+
These middleware classes work with Starlette apps (like FastMCP's streamable_http_app())
|
|
5
|
+
to provide HTTP 402 payment support and authentication.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import json
|
|
10
|
+
import os
|
|
11
|
+
from typing import Dict, Any, Optional
|
|
12
|
+
|
|
13
|
+
from starlette.middleware.base import BaseHTTPMiddleware
|
|
14
|
+
from starlette.requests import Request
|
|
15
|
+
from starlette.responses import JSONResponse
|
|
16
|
+
|
|
17
|
+
from .types import PaymentRequirements, d402PaymentRequiredResponse, PaymentPayload
|
|
18
|
+
from .common import d402_VERSION
|
|
19
|
+
from .facilitator import IATPSettlementFacilitator
|
|
20
|
+
from .encoding import safe_base64_decode
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def extract_server_url_from_request(request: Request) -> str:
|
|
26
|
+
"""
|
|
27
|
+
Extract the server's own URL from the HTTP request headers.
|
|
28
|
+
|
|
29
|
+
This is used for tracking in the facilitator which server a payment came from.
|
|
30
|
+
Works for both local and remote (Cloud Run) deployments.
|
|
31
|
+
|
|
32
|
+
Args:
|
|
33
|
+
request: Starlette Request object
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Full server URL (e.g., 'https://my-mcp.cloudrun.app' or 'http://localhost:8000')
|
|
37
|
+
"""
|
|
38
|
+
# Check X-Forwarded headers first (set by proxies/load balancers like Cloud Run)
|
|
39
|
+
forwarded_proto = request.headers.get("X-Forwarded-Proto", "http")
|
|
40
|
+
forwarded_host = request.headers.get("X-Forwarded-Host")
|
|
41
|
+
|
|
42
|
+
if forwarded_host:
|
|
43
|
+
# Cloud Run / proxy scenario
|
|
44
|
+
server_url = f"{forwarded_proto}://{forwarded_host}"
|
|
45
|
+
logger.debug(f"Server URL from X-Forwarded headers: {server_url}")
|
|
46
|
+
return server_url
|
|
47
|
+
|
|
48
|
+
# Fallback to Host header (for local/direct access)
|
|
49
|
+
host = request.headers.get("Host", "localhost:8000")
|
|
50
|
+
|
|
51
|
+
# Determine protocol (https if forwarded, otherwise check if local)
|
|
52
|
+
if "localhost" in host or "127.0.0.1" in host or "host.docker.internal" in host:
|
|
53
|
+
proto = "http"
|
|
54
|
+
else:
|
|
55
|
+
# Remote host without X-Forwarded-Proto, assume https
|
|
56
|
+
proto = "https"
|
|
57
|
+
|
|
58
|
+
server_url = f"{proto}://{host}"
|
|
59
|
+
logger.debug(f"Server URL from Host header: {server_url}")
|
|
60
|
+
return server_url
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class D402PaymentMiddleware(BaseHTTPMiddleware):
|
|
64
|
+
"""
|
|
65
|
+
Starlette middleware that intercepts MCP tool calls for HTTP 402 payment.
|
|
66
|
+
|
|
67
|
+
This middleware:
|
|
68
|
+
1. Extracts API key if present → stores in request.state
|
|
69
|
+
2. Checks if tool requires payment
|
|
70
|
+
3. Returns HTTP 402 if neither auth nor payment
|
|
71
|
+
4. Sets request.state.api_key_to_use with the resolved key
|
|
72
|
+
5. Forwards to FastMCP
|
|
73
|
+
|
|
74
|
+
Usage:
|
|
75
|
+
app = mcp.streamable_http_app()
|
|
76
|
+
app.add_middleware(
|
|
77
|
+
D402PaymentMiddleware,
|
|
78
|
+
tool_payment_configs=TOOL_PAYMENT_CONFIGS,
|
|
79
|
+
server_address=SERVER_ADDRESS,
|
|
80
|
+
requires_auth=True,
|
|
81
|
+
internal_api_key="server_api_key" # Server's internal key
|
|
82
|
+
)
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
def __init__(
|
|
86
|
+
self,
|
|
87
|
+
app,
|
|
88
|
+
tool_payment_configs: Dict[str, Dict[str, Any]],
|
|
89
|
+
server_address: str,
|
|
90
|
+
requires_auth: bool = False,
|
|
91
|
+
internal_api_key: Optional[str] = None,
|
|
92
|
+
testing_mode: bool = False,
|
|
93
|
+
facilitator_url: Optional[str] = None,
|
|
94
|
+
facilitator_api_key: Optional[str] = None,
|
|
95
|
+
server_name: Optional[str] = None
|
|
96
|
+
):
|
|
97
|
+
super().__init__(app)
|
|
98
|
+
self.tool_payment_configs = tool_payment_configs
|
|
99
|
+
self.server_address = server_address
|
|
100
|
+
self.requires_auth = requires_auth
|
|
101
|
+
self.internal_api_key = internal_api_key # Server's internal API key
|
|
102
|
+
self.testing_mode = testing_mode or os.getenv("D402_TESTING_MODE", "false").lower() == "true"
|
|
103
|
+
|
|
104
|
+
# Initialize facilitator for payment verification and settlement
|
|
105
|
+
self.facilitator = None
|
|
106
|
+
if not self.testing_mode:
|
|
107
|
+
try:
|
|
108
|
+
operator_key = os.getenv("MCP_OPERATOR_PRIVATE_KEY") or os.getenv("OPERATOR_PRIVATE_KEY")
|
|
109
|
+
# Get server name/ID from initialization or environment
|
|
110
|
+
mcp_server_name = server_name or os.getenv("MCP_SERVER_NAME", os.getenv("MCP_SERVER_ID"))
|
|
111
|
+
|
|
112
|
+
# Note: server_url will be extracted from each request at runtime
|
|
113
|
+
# This is needed because Cloud Run URLs are not known until deployment
|
|
114
|
+
# and must be introspected from X-Forwarded-Host and X-Forwarded-Proto headers
|
|
115
|
+
|
|
116
|
+
self.facilitator = IATPSettlementFacilitator(
|
|
117
|
+
facilitator_url=facilitator_url or os.getenv("D402_FACILITATOR_URL", "https://facilitator.d402.net"),
|
|
118
|
+
facilitator_api_key=facilitator_api_key or os.getenv("D402_FACILITATOR_API_KEY"),
|
|
119
|
+
provider_operator_key=operator_key,
|
|
120
|
+
server_name=mcp_server_name,
|
|
121
|
+
server_url=None # Will be set per-request from headers
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Store server name for later use
|
|
125
|
+
self.server_name = mcp_server_name
|
|
126
|
+
if operator_key:
|
|
127
|
+
logger.info(f" Facilitator initialized with operator key (settlement enabled)")
|
|
128
|
+
else:
|
|
129
|
+
logger.warning(f" No operator key - settlement disabled")
|
|
130
|
+
except Exception as e:
|
|
131
|
+
logger.warning(f" Could not initialize facilitator: {e}")
|
|
132
|
+
self.testing_mode = True
|
|
133
|
+
|
|
134
|
+
logger.info(f"D402PaymentMiddleware initialized:")
|
|
135
|
+
logger.info(f" Payment-enabled tools: {len(tool_payment_configs)}")
|
|
136
|
+
logger.info(f" Server address: {server_address}")
|
|
137
|
+
logger.info(f" Testing mode: {self.testing_mode}")
|
|
138
|
+
logger.info(f" Facilitator: {'Enabled' if self.facilitator else 'Disabled (testing)'}")
|
|
139
|
+
|
|
140
|
+
async def dispatch(self, request: Request, call_next):
|
|
141
|
+
"""
|
|
142
|
+
Intercept requests for auth and payment checking.
|
|
143
|
+
|
|
144
|
+
Handles both:
|
|
145
|
+
1. If server requires auth: Extract API key and store in request.state
|
|
146
|
+
2. If tool requires payment: Check payment or auth, return HTTP 402 if missing
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
# Step 0: Skip payment processing on URLs that will redirect (trailing slash)
|
|
150
|
+
# This prevents duplicate payments when /mcp/ redirects to /mcp
|
|
151
|
+
if request.url.path.endswith('/') and request.url.path != '/':
|
|
152
|
+
# This request will likely redirect, skip payment processing
|
|
153
|
+
logger.debug(f"Skipping payment processing for trailing slash URL: {request.url.path}")
|
|
154
|
+
return await call_next(request)
|
|
155
|
+
|
|
156
|
+
# Step 1: Store middleware reference for decorator access (for settlement)
|
|
157
|
+
request.state.d402_middleware = self
|
|
158
|
+
|
|
159
|
+
# Step 2: Extract and store API key if present (for all requests)
|
|
160
|
+
if self.requires_auth:
|
|
161
|
+
auth = request.headers.get("Authorization", "")
|
|
162
|
+
if auth.lower().startswith("bearer "):
|
|
163
|
+
token = auth[7:].strip()
|
|
164
|
+
request.state.api_key = token
|
|
165
|
+
request.state.authenticated = True
|
|
166
|
+
logger.debug(f"D402: API key stored: {token[:10]}...")
|
|
167
|
+
# Check for X-API-Key header (case-insensitive)
|
|
168
|
+
# Try common variations: X-API-Key, X-API-KEY, x-api-key
|
|
169
|
+
elif request.headers.get("x-api-key"): # Starlette headers are case-insensitive when using lowercase
|
|
170
|
+
token = request.headers.get("x-api-key")
|
|
171
|
+
request.state.api_key = token
|
|
172
|
+
request.state.authenticated = True
|
|
173
|
+
logger.debug(f"D402: X-API-Key stored: {token[:10]}...")
|
|
174
|
+
else:
|
|
175
|
+
request.state.api_key = None
|
|
176
|
+
request.state.authenticated = False
|
|
177
|
+
|
|
178
|
+
# Step 2: Check payment for tool calls
|
|
179
|
+
# Only intercept POST to /mcp
|
|
180
|
+
if request.method != "POST" or not request.url.path.startswith("/mcp"):
|
|
181
|
+
return await call_next(request)
|
|
182
|
+
|
|
183
|
+
try:
|
|
184
|
+
# Read body to check tool name
|
|
185
|
+
body = await request.body()
|
|
186
|
+
data = json.loads(body)
|
|
187
|
+
|
|
188
|
+
# Check if it's a tool call
|
|
189
|
+
if data.get("method") == "tools/call":
|
|
190
|
+
tool_name = data.get("params", {}).get("name")
|
|
191
|
+
|
|
192
|
+
# Calculate tool-specific path for signature binding
|
|
193
|
+
# For MCP servers: /mcp/tools/{tool_name}
|
|
194
|
+
# For other servers: use the actual HTTP path
|
|
195
|
+
if request.url.path.rstrip('/').endswith('/mcp'):
|
|
196
|
+
base_path = request.url.path.rstrip('/')
|
|
197
|
+
tool_path = f"{base_path}/tools/{tool_name}"
|
|
198
|
+
else:
|
|
199
|
+
tool_path = request.url.path
|
|
200
|
+
|
|
201
|
+
# Check if tool requires payment
|
|
202
|
+
if tool_name in self.tool_payment_configs:
|
|
203
|
+
# Mode 1: If server requires auth AND client has API key → FREE
|
|
204
|
+
if self.requires_auth and request.state.authenticated:
|
|
205
|
+
logger.info(f"✅ {tool_name}: Client authenticated with API key (Mode 1: Free)")
|
|
206
|
+
# Set api_key_to_use = client's key
|
|
207
|
+
request.state.api_key_to_use = request.state.api_key
|
|
208
|
+
# Continue to FastMCP
|
|
209
|
+
return await self._continue_with_body(request, body, call_next)
|
|
210
|
+
|
|
211
|
+
# Mode 2: Check payment → Client must pay, server uses internal API key
|
|
212
|
+
payment_header = request.headers.get("X-Payment")
|
|
213
|
+
if not payment_header:
|
|
214
|
+
logger.info(f"💰 {tool_name}: Payment required (Mode 2) - HTTP 402")
|
|
215
|
+
config = self.tool_payment_configs[tool_name]
|
|
216
|
+
return self._create_402_response(config, "Payment required", request_path=tool_path)
|
|
217
|
+
else:
|
|
218
|
+
# Payment header present - VALIDATE IT!
|
|
219
|
+
logger.info(f"💰 {tool_name}: Payment header RECEIVED - validating...")
|
|
220
|
+
logger.info(f"📦 Payment header length: {len(payment_header)} bytes")
|
|
221
|
+
|
|
222
|
+
# TODO: Add full payment validation:
|
|
223
|
+
# 1. Decode and parse payment header
|
|
224
|
+
# 2. Verify EIP-3009 signature
|
|
225
|
+
# 3. Check amount >= required
|
|
226
|
+
# 4. Verify pay_to == SERVER_ADDRESS
|
|
227
|
+
# 5. Check timestamp validity
|
|
228
|
+
# 6. Call facilitator.verify() if not testing mode
|
|
229
|
+
|
|
230
|
+
# For now: Basic validation in testing mode
|
|
231
|
+
try:
|
|
232
|
+
from .encoding import safe_base64_decode
|
|
233
|
+
payment_data = safe_base64_decode(payment_header)
|
|
234
|
+
if not payment_data:
|
|
235
|
+
logger.error(f"❌ {tool_name}: Invalid payment encoding")
|
|
236
|
+
# Return 402 with error
|
|
237
|
+
config = self.tool_payment_configs[tool_name]
|
|
238
|
+
return self._create_402_response(config, "Invalid payment encoding", request_path=tool_path)
|
|
239
|
+
|
|
240
|
+
payment_dict = json.loads(payment_data)
|
|
241
|
+
|
|
242
|
+
# Basic validation: check structure
|
|
243
|
+
if not payment_dict.get("payload") or not payment_dict["payload"].get("authorization"):
|
|
244
|
+
logger.error(f"❌ {tool_name}: Invalid payment structure")
|
|
245
|
+
config = self.tool_payment_configs[tool_name]
|
|
246
|
+
return self._create_402_response(config, "Invalid payment structure", request_path=tool_path)
|
|
247
|
+
|
|
248
|
+
auth = payment_dict["payload"]["authorization"]
|
|
249
|
+
|
|
250
|
+
# Verify payment destination
|
|
251
|
+
if auth.get("to", "").lower() != self.server_address.lower():
|
|
252
|
+
logger.error(f"❌ {tool_name}: Payment to wrong address")
|
|
253
|
+
config = self.tool_payment_configs[tool_name]
|
|
254
|
+
return self._create_402_response(config, "Payment to wrong address", request_path=tool_path)
|
|
255
|
+
|
|
256
|
+
# Verify payment amount
|
|
257
|
+
config = self.tool_payment_configs[tool_name]
|
|
258
|
+
payment_amount = int(auth.get("value", 0))
|
|
259
|
+
required_amount = int(config["price_wei"])
|
|
260
|
+
|
|
261
|
+
if payment_amount < required_amount:
|
|
262
|
+
logger.error(f"❌ {tool_name}: Insufficient payment: {payment_amount} < {required_amount}")
|
|
263
|
+
return self._create_402_response(config, f"Insufficient payment: {payment_amount} < {required_amount}", request_path=tool_path)
|
|
264
|
+
|
|
265
|
+
# Call facilitator.verify() if available (production mode)
|
|
266
|
+
if self.facilitator and not self.testing_mode:
|
|
267
|
+
try:
|
|
268
|
+
# Create PaymentPayload for facilitator
|
|
269
|
+
payment_payload = PaymentPayload.model_validate(payment_dict)
|
|
270
|
+
|
|
271
|
+
# Create PaymentRequirements with full token info
|
|
272
|
+
# Include client information in extra field for facilitator tracking
|
|
273
|
+
extra_data = {
|
|
274
|
+
"client_url": request.headers.get("referer") or request.headers.get("origin"),
|
|
275
|
+
"client_ip": request.client.host if request.client else None,
|
|
276
|
+
"user_agent": request.headers.get("user-agent")
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
payment_reqs = PaymentRequirements(
|
|
280
|
+
scheme="exact",
|
|
281
|
+
network=config["network"],
|
|
282
|
+
pay_to=config["server_address"],
|
|
283
|
+
max_amount_required=config["price_wei"],
|
|
284
|
+
max_timeout_seconds=86400, # 24 hours for settlement window
|
|
285
|
+
description=config["description"],
|
|
286
|
+
resource=tool_path,
|
|
287
|
+
mime_type="application/json",
|
|
288
|
+
asset=config["token_address"],
|
|
289
|
+
extra=extra_data
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
# Extract server's own URL from request headers for tracking
|
|
293
|
+
server_url = extract_server_url_from_request(request)
|
|
294
|
+
logger.info(f"🌐 Server URL (introspected): {server_url}")
|
|
295
|
+
|
|
296
|
+
# Temporarily update facilitator's server_url for this request
|
|
297
|
+
original_server_url = self.facilitator.server_url
|
|
298
|
+
self.facilitator.server_url = server_url
|
|
299
|
+
|
|
300
|
+
# Verify with facilitator
|
|
301
|
+
logger.info(f"🔐 Verifying payment with facilitator...")
|
|
302
|
+
#==============================================================
|
|
303
|
+
verify_result = await self.facilitator.verify(payment_payload, payment_reqs)
|
|
304
|
+
#==============================================================
|
|
305
|
+
|
|
306
|
+
# Restore original server_url (for thread safety if needed)
|
|
307
|
+
self.facilitator.server_url = original_server_url
|
|
308
|
+
|
|
309
|
+
logger.info(f"🔐 Facilitator verify result: {verify_result}")
|
|
310
|
+
if not verify_result.is_valid:
|
|
311
|
+
logger.error(f"❌ {tool_name}: Facilitator rejected payment: {verify_result.invalid_reason}")
|
|
312
|
+
return self._create_402_response(config, f"Payment verification failed: {verify_result.invalid_reason}", request_path=tool_path)
|
|
313
|
+
|
|
314
|
+
# Store payment_uuid and facilitatorFeePercent for settlement
|
|
315
|
+
request.state.payment_uuid = verify_result.payment_uuid
|
|
316
|
+
request.state.facilitator_fee_percent = verify_result.facilitator_fee_percent or 250
|
|
317
|
+
logger.info(f"✅ {tool_name}: Facilitator verified payment (UUID: {verify_result.payment_uuid[:20] if verify_result.payment_uuid else 'N/A'}...)")
|
|
318
|
+
logger.info(f" Facilitator fee: {request.state.facilitator_fee_percent} basis points")
|
|
319
|
+
|
|
320
|
+
except Exception as e:
|
|
321
|
+
logger.error(f"❌ {tool_name}: Facilitator error: {e}")
|
|
322
|
+
return self._create_402_response(config, f"Facilitator verification failed: {str(e)}", request_path=tool_path)
|
|
323
|
+
else:
|
|
324
|
+
logger.info(f"⚠️ {tool_name}: Testing mode - skipping facilitator verification")
|
|
325
|
+
|
|
326
|
+
# Payment validated! Set api_key_to_use
|
|
327
|
+
logger.info(f"✅ {tool_name}: Payment VERIFIED successfully (Mode 2: Paid)")
|
|
328
|
+
logger.info(f" Payment amount: {payment_amount} wei (required: {required_amount} wei)")
|
|
329
|
+
logger.info(f" From (wallet): {auth.get('from', 'unknown')}")
|
|
330
|
+
logger.info(f" To (provider): {auth.get('to', 'unknown')}")
|
|
331
|
+
logger.info(f" Request path: {auth.get('requestPath', auth.get('request_path', 'unknown'))}")
|
|
332
|
+
request.state.api_key_to_use = self.internal_api_key
|
|
333
|
+
request.state.payment_validated = True
|
|
334
|
+
request.state.payment_dict = payment_dict
|
|
335
|
+
request.state.tool_name = tool_name # Store for settlement
|
|
336
|
+
|
|
337
|
+
# Store payment info for settlement
|
|
338
|
+
request.state.payment_payload = PaymentPayload.model_validate(payment_dict)
|
|
339
|
+
logger.info(f"💾 {tool_name}: Payment payload stored for settlement")
|
|
340
|
+
|
|
341
|
+
except Exception as e:
|
|
342
|
+
logger.error(f"❌ {tool_name}: Payment validation error: {e}")
|
|
343
|
+
config = self.tool_payment_configs[tool_name]
|
|
344
|
+
return self._create_402_response(config, f"Payment validation failed: {str(e)}", request_path=tool_path)
|
|
345
|
+
|
|
346
|
+
# Continue with reconstructed request
|
|
347
|
+
response = await self._continue_with_body(request, body, call_next)
|
|
348
|
+
|
|
349
|
+
# If payment was validated and response is successful, settle the payment
|
|
350
|
+
if hasattr(request.state, 'payment_validated') and request.state.payment_validated:
|
|
351
|
+
if 200 <= response.status_code < 300:
|
|
352
|
+
payment_uuid = getattr(request.state, 'payment_uuid', None)
|
|
353
|
+
|
|
354
|
+
# Read response body ONCE (handle different response types)
|
|
355
|
+
response_body = b""
|
|
356
|
+
try:
|
|
357
|
+
# StreamingResponse has body_iterator, Response has body attribute
|
|
358
|
+
if hasattr(response, 'body_iterator'):
|
|
359
|
+
async for chunk in response.body_iterator:
|
|
360
|
+
response_body += chunk
|
|
361
|
+
elif hasattr(response, 'body'):
|
|
362
|
+
# Regular Response - body is bytes
|
|
363
|
+
response_body = response.body
|
|
364
|
+
else:
|
|
365
|
+
logger.warning("Response has no body_iterator or body attribute")
|
|
366
|
+
response_body = b""
|
|
367
|
+
except Exception as e:
|
|
368
|
+
logger.error(f"Error reading response body: {e}")
|
|
369
|
+
response_body = b""
|
|
370
|
+
|
|
371
|
+
# Check if response contains errors - don't settle if upstream API failed
|
|
372
|
+
should_settle = True
|
|
373
|
+
try:
|
|
374
|
+
response_str = response_body.decode() if response_body else ""
|
|
375
|
+
# Check for MCP error indicators
|
|
376
|
+
if '"isError":true' in response_str or '"isError": true' in response_str:
|
|
377
|
+
logger.warning(f"⚠️ Tool returned isError=true - NOT settling payment")
|
|
378
|
+
should_settle = False
|
|
379
|
+
elif response_str.count('"error"') > 1: # Multiple error fields suggest failure
|
|
380
|
+
logger.warning(f"⚠️ Tool response contains errors - NOT settling payment")
|
|
381
|
+
should_settle = False
|
|
382
|
+
|
|
383
|
+
# Recreate response for return with buffered body
|
|
384
|
+
from starlette.responses import Response
|
|
385
|
+
response = Response(
|
|
386
|
+
content=response_body,
|
|
387
|
+
status_code=response.status_code,
|
|
388
|
+
headers=dict(response.headers),
|
|
389
|
+
media_type=response.media_type
|
|
390
|
+
)
|
|
391
|
+
except Exception as e:
|
|
392
|
+
logger.error(f"Error checking response for errors: {e}")
|
|
393
|
+
# If we can't check, don't settle to be safe
|
|
394
|
+
should_settle = False
|
|
395
|
+
|
|
396
|
+
if not should_settle:
|
|
397
|
+
logger.info(f"💳 Skipping settlement due to tool error")
|
|
398
|
+
return response
|
|
399
|
+
|
|
400
|
+
logger.info(f"💳 Successful response with validated payment - triggering settlement")
|
|
401
|
+
logger.info(f" Payment UUID: {payment_uuid}")
|
|
402
|
+
|
|
403
|
+
# Trigger settlement asynchronously (fire-and-forget)
|
|
404
|
+
if payment_uuid and self.facilitator:
|
|
405
|
+
import asyncio
|
|
406
|
+
from .mcp_middleware import settle_payment, EndpointPaymentInfo
|
|
407
|
+
|
|
408
|
+
# Parse for settlement (runs async after response sent)
|
|
409
|
+
async def do_settlement():
|
|
410
|
+
try:
|
|
411
|
+
logger.info(f"🚀 Background settlement started (async - client already has response)")
|
|
412
|
+
|
|
413
|
+
# Parse response body
|
|
414
|
+
try:
|
|
415
|
+
output_data = json.loads(response_body.decode())
|
|
416
|
+
except:
|
|
417
|
+
output_data = {"response": response_body.decode() if response_body else "completed"}
|
|
418
|
+
|
|
419
|
+
# Get tool config
|
|
420
|
+
tool_name = getattr(request.state, 'tool_name', 'unknown')
|
|
421
|
+
config = self.tool_payment_configs.get(tool_name, {})
|
|
422
|
+
|
|
423
|
+
# Create endpoint info
|
|
424
|
+
endpoint_info = EndpointPaymentInfo(
|
|
425
|
+
settlement_token_address=config.get("token_address"),
|
|
426
|
+
settlement_token_network=config.get("network"),
|
|
427
|
+
payment_price_float=float(config.get("price_wei", 0)) / 1e6,
|
|
428
|
+
payment_price_wei=config.get("price_wei"),
|
|
429
|
+
server_address=config.get("server_address")
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
# Create context wrapper
|
|
433
|
+
class SettlementContext:
|
|
434
|
+
class State:
|
|
435
|
+
def __init__(self):
|
|
436
|
+
self.payment_payload = getattr(request.state, 'payment_payload', None)
|
|
437
|
+
self.payment_uuid = payment_uuid
|
|
438
|
+
self.facilitator_fee_percent = getattr(request.state, 'facilitator_fee_percent', 250)
|
|
439
|
+
|
|
440
|
+
def __init__(self):
|
|
441
|
+
self.state = SettlementContext.State()
|
|
442
|
+
|
|
443
|
+
settlement_ctx = SettlementContext()
|
|
444
|
+
|
|
445
|
+
# Use actual response data for output hash
|
|
446
|
+
logger.info(f"📊 Using actual response data for settlement")
|
|
447
|
+
logger.info(f" Output data size: {len(str(output_data))} chars")
|
|
448
|
+
|
|
449
|
+
settlement_success = await settle_payment(
|
|
450
|
+
context=settlement_ctx,
|
|
451
|
+
endpoint_info=endpoint_info,
|
|
452
|
+
output_data=output_data,
|
|
453
|
+
middleware=self
|
|
454
|
+
)
|
|
455
|
+
|
|
456
|
+
if settlement_success:
|
|
457
|
+
logger.info(f"✅ Background settlement completed")
|
|
458
|
+
else:
|
|
459
|
+
logger.warning(f"⚠️ Background settlement failed")
|
|
460
|
+
|
|
461
|
+
except Exception as e:
|
|
462
|
+
logger.error(f"❌ Settlement error: {e}")
|
|
463
|
+
import traceback
|
|
464
|
+
logger.error(traceback.format_exc())
|
|
465
|
+
|
|
466
|
+
asyncio.create_task(do_settlement())
|
|
467
|
+
logger.info(f"📅 Settlement task scheduled - client gets response immediately")
|
|
468
|
+
|
|
469
|
+
return response
|
|
470
|
+
|
|
471
|
+
except Exception as e:
|
|
472
|
+
logger.error(f"Error in D402PaymentMiddleware: {e}")
|
|
473
|
+
import traceback
|
|
474
|
+
logger.error(traceback.format_exc())
|
|
475
|
+
# Continue on error
|
|
476
|
+
return await call_next(request)
|
|
477
|
+
|
|
478
|
+
async def _continue_with_body(self, request: Request, body: bytes, call_next):
|
|
479
|
+
"""Continue request processing with body we already read."""
|
|
480
|
+
# Create new request with reconstructed receive
|
|
481
|
+
async def receive():
|
|
482
|
+
return {"type": "http.request", "body": body, "more_body": False}
|
|
483
|
+
|
|
484
|
+
from starlette.requests import Request as NewRequest
|
|
485
|
+
new_request = NewRequest(request.scope, receive)
|
|
486
|
+
return await call_next(new_request)
|
|
487
|
+
|
|
488
|
+
def _create_402_response(self, config: Dict[str, Any], error_message: str, request_path: str = "/mcp") -> JSONResponse:
|
|
489
|
+
"""Helper to create HTTP 402 response with request path for signature binding."""
|
|
490
|
+
# Include EIP712 domain in extra for client to sign payment
|
|
491
|
+
# This should be IATPWallet domain (consumer's wallet contract)
|
|
492
|
+
extra_data = config.get("eip712_domain", {
|
|
493
|
+
"name": "IATPWallet", # Consumer's wallet contract
|
|
494
|
+
"version": "1"
|
|
495
|
+
})
|
|
496
|
+
|
|
497
|
+
logger.info(f" 🔧 Creating 402 response with resource: {request_path}")
|
|
498
|
+
|
|
499
|
+
payment_req = PaymentRequirements(
|
|
500
|
+
scheme="exact",
|
|
501
|
+
network=config["network"],
|
|
502
|
+
pay_to=config["server_address"],
|
|
503
|
+
max_amount_required=config["price_wei"],
|
|
504
|
+
max_timeout_seconds=86400, # 24 hours for settlement window
|
|
505
|
+
description=config["description"],
|
|
506
|
+
resource=request_path, # Include actual API path for signature binding
|
|
507
|
+
mime_type="application/json",
|
|
508
|
+
asset=config["token_address"],
|
|
509
|
+
extra=extra_data # EIP712 domain for signature
|
|
510
|
+
)
|
|
511
|
+
|
|
512
|
+
response_data = d402PaymentRequiredResponse(
|
|
513
|
+
d402_version=d402_VERSION,
|
|
514
|
+
accepts=[payment_req],
|
|
515
|
+
error=error_message
|
|
516
|
+
)
|
|
517
|
+
|
|
518
|
+
return JSONResponse(
|
|
519
|
+
status_code=402,
|
|
520
|
+
content=response_data.model_dump(by_alias=True),
|
|
521
|
+
headers={
|
|
522
|
+
"Access-Control-Allow-Origin": "*",
|
|
523
|
+
"Access-Control-Expose-Headers": "X-Payment-Response"
|
|
524
|
+
}
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
__all__ = ["D402PaymentMiddleware"]
|
|
529
|
+
|