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.
Files changed (95) hide show
  1. traia_iatp/__init__.py +105 -8
  2. traia_iatp/cli/main.py +85 -1
  3. traia_iatp/client/__init__.py +28 -3
  4. traia_iatp/client/crewai_a2a_tools.py +32 -12
  5. traia_iatp/client/d402_a2a_client.py +348 -0
  6. traia_iatp/contracts/__init__.py +11 -0
  7. traia_iatp/contracts/data/abis/contract-abis-localhost.json +4091 -0
  8. traia_iatp/contracts/data/abis/contract-abis-sepolia.json +4890 -0
  9. traia_iatp/contracts/data/addresses/contract-addresses.json +17 -0
  10. traia_iatp/contracts/data/addresses/contract-proxies.json +12 -0
  11. traia_iatp/contracts/iatp_contracts_config.py +263 -0
  12. traia_iatp/contracts/wallet_creator.py +369 -0
  13. traia_iatp/core/models.py +17 -3
  14. traia_iatp/d402/MIDDLEWARE_ARCHITECTURE.md +205 -0
  15. traia_iatp/d402/PRICE_BUILDER_USAGE.md +249 -0
  16. traia_iatp/d402/README.md +489 -0
  17. traia_iatp/d402/__init__.py +54 -0
  18. traia_iatp/d402/asgi_wrapper.py +469 -0
  19. traia_iatp/d402/chains.py +102 -0
  20. traia_iatp/d402/client.py +150 -0
  21. traia_iatp/d402/clients/__init__.py +7 -0
  22. traia_iatp/d402/clients/base.py +218 -0
  23. traia_iatp/d402/clients/httpx.py +266 -0
  24. traia_iatp/d402/common.py +114 -0
  25. traia_iatp/d402/encoding.py +28 -0
  26. traia_iatp/d402/examples/client_example.py +197 -0
  27. traia_iatp/d402/examples/server_example.py +171 -0
  28. traia_iatp/d402/facilitator.py +481 -0
  29. traia_iatp/d402/mcp_middleware.py +296 -0
  30. traia_iatp/d402/models.py +116 -0
  31. traia_iatp/d402/networks.py +98 -0
  32. traia_iatp/d402/path.py +43 -0
  33. traia_iatp/d402/payment_introspection.py +126 -0
  34. traia_iatp/d402/payment_signing.py +183 -0
  35. traia_iatp/d402/price_builder.py +164 -0
  36. traia_iatp/d402/servers/__init__.py +61 -0
  37. traia_iatp/d402/servers/base.py +139 -0
  38. traia_iatp/d402/servers/example_general_server.py +140 -0
  39. traia_iatp/d402/servers/fastapi.py +253 -0
  40. traia_iatp/d402/servers/mcp.py +304 -0
  41. traia_iatp/d402/servers/starlette.py +878 -0
  42. traia_iatp/d402/starlette_middleware.py +529 -0
  43. traia_iatp/d402/types.py +300 -0
  44. traia_iatp/mcp/D402_MCP_ADAPTER_FLOW.md +357 -0
  45. traia_iatp/mcp/__init__.py +3 -0
  46. traia_iatp/mcp/d402_mcp_tool_adapter.py +526 -0
  47. traia_iatp/mcp/mcp_agent_template.py +78 -13
  48. traia_iatp/mcp/templates/Dockerfile.j2 +27 -4
  49. traia_iatp/mcp/templates/README.md.j2 +104 -8
  50. traia_iatp/mcp/templates/cursor-rules.md.j2 +194 -0
  51. traia_iatp/mcp/templates/deployment_params.json.j2 +1 -2
  52. traia_iatp/mcp/templates/docker-compose.yml.j2 +13 -3
  53. traia_iatp/mcp/templates/env.example.j2 +60 -0
  54. traia_iatp/mcp/templates/mcp_health_check.py.j2 +2 -2
  55. traia_iatp/mcp/templates/pyproject.toml.j2 +11 -5
  56. traia_iatp/mcp/templates/pyrightconfig.json.j2 +22 -0
  57. traia_iatp/mcp/templates/run_local_docker.sh.j2 +320 -10
  58. traia_iatp/mcp/templates/server.py.j2 +174 -197
  59. traia_iatp/mcp/traia_mcp_adapter.py +182 -20
  60. traia_iatp/registry/__init__.py +47 -12
  61. traia_iatp/registry/atlas_search_indexes.json +108 -54
  62. traia_iatp/registry/iatp_search_api.py +169 -39
  63. traia_iatp/registry/mongodb_registry.py +241 -69
  64. traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +1 -1
  65. traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +8 -8
  66. traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +1 -1
  67. traia_iatp/registry/readmes/README.md +3 -3
  68. traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +6 -6
  69. traia_iatp/scripts/__init__.py +2 -0
  70. traia_iatp/scripts/create_wallet.py +244 -0
  71. traia_iatp/server/a2a_server.py +22 -7
  72. traia_iatp/server/iatp_server_template_generator.py +23 -0
  73. traia_iatp/server/templates/.dockerignore.j2 +48 -0
  74. traia_iatp/server/templates/Dockerfile.j2 +23 -1
  75. traia_iatp/server/templates/README.md +2 -2
  76. traia_iatp/server/templates/README.md.j2 +5 -5
  77. traia_iatp/server/templates/__main__.py.j2 +374 -66
  78. traia_iatp/server/templates/agent.py.j2 +12 -11
  79. traia_iatp/server/templates/agent_config.json.j2 +3 -3
  80. traia_iatp/server/templates/agent_executor.py.j2 +45 -27
  81. traia_iatp/server/templates/env.example.j2 +32 -4
  82. traia_iatp/server/templates/gitignore.j2 +7 -0
  83. traia_iatp/server/templates/pyproject.toml.j2 +13 -12
  84. traia_iatp/server/templates/run_local_docker.sh.j2 +143 -11
  85. traia_iatp/server/templates/server.py.j2 +197 -10
  86. traia_iatp/special_agencies/registry_search_agency.py +1 -1
  87. traia_iatp/utils/iatp_utils.py +6 -6
  88. traia_iatp-0.1.67.dist-info/METADATA +320 -0
  89. traia_iatp-0.1.67.dist-info/RECORD +117 -0
  90. traia_iatp-0.1.2.dist-info/METADATA +0 -414
  91. traia_iatp-0.1.2.dist-info/RECORD +0 -72
  92. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/WHEEL +0 -0
  93. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/entry_points.txt +0 -0
  94. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/licenses/LICENSE +0 -0
  95. {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
+