traia-iatp 0.1.29__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.

Potentially problematic release.


This version of traia-iatp might be problematic. Click here for more details.

Files changed (107) hide show
  1. traia_iatp/README.md +368 -0
  2. traia_iatp/__init__.py +54 -0
  3. traia_iatp/cli/__init__.py +5 -0
  4. traia_iatp/cli/main.py +483 -0
  5. traia_iatp/client/__init__.py +10 -0
  6. traia_iatp/client/a2a_client.py +274 -0
  7. traia_iatp/client/crewai_a2a_tools.py +335 -0
  8. traia_iatp/client/d402_a2a_client.py +293 -0
  9. traia_iatp/client/grpc_a2a_tools.py +349 -0
  10. traia_iatp/client/root_path_a2a_client.py +1 -0
  11. traia_iatp/contracts/__init__.py +12 -0
  12. traia_iatp/contracts/iatp_contracts_config.py +263 -0
  13. traia_iatp/contracts/wallet_creator.py +255 -0
  14. traia_iatp/core/__init__.py +43 -0
  15. traia_iatp/core/models.py +172 -0
  16. traia_iatp/d402/__init__.py +55 -0
  17. traia_iatp/d402/chains.py +102 -0
  18. traia_iatp/d402/client.py +150 -0
  19. traia_iatp/d402/clients/__init__.py +7 -0
  20. traia_iatp/d402/clients/base.py +218 -0
  21. traia_iatp/d402/clients/httpx.py +219 -0
  22. traia_iatp/d402/common.py +114 -0
  23. traia_iatp/d402/encoding.py +28 -0
  24. traia_iatp/d402/examples/client_example.py +197 -0
  25. traia_iatp/d402/examples/server_example.py +171 -0
  26. traia_iatp/d402/facilitator.py +453 -0
  27. traia_iatp/d402/fastapi_middleware/__init__.py +6 -0
  28. traia_iatp/d402/fastapi_middleware/middleware.py +225 -0
  29. traia_iatp/d402/fastmcp_middleware.py +147 -0
  30. traia_iatp/d402/mcp_middleware.py +434 -0
  31. traia_iatp/d402/middleware.py +193 -0
  32. traia_iatp/d402/models.py +116 -0
  33. traia_iatp/d402/networks.py +98 -0
  34. traia_iatp/d402/path.py +43 -0
  35. traia_iatp/d402/payment_introspection.py +104 -0
  36. traia_iatp/d402/payment_signing.py +178 -0
  37. traia_iatp/d402/paywall.py +119 -0
  38. traia_iatp/d402/starlette_middleware.py +326 -0
  39. traia_iatp/d402/template.py +1 -0
  40. traia_iatp/d402/types.py +300 -0
  41. traia_iatp/mcp/__init__.py +18 -0
  42. traia_iatp/mcp/client.py +201 -0
  43. traia_iatp/mcp/d402_mcp_tool_adapter.py +361 -0
  44. traia_iatp/mcp/mcp_agent_template.py +481 -0
  45. traia_iatp/mcp/templates/Dockerfile.j2 +80 -0
  46. traia_iatp/mcp/templates/README.md.j2 +310 -0
  47. traia_iatp/mcp/templates/cursor-rules.md.j2 +520 -0
  48. traia_iatp/mcp/templates/deployment_params.json.j2 +20 -0
  49. traia_iatp/mcp/templates/docker-compose.yml.j2 +32 -0
  50. traia_iatp/mcp/templates/dockerignore.j2 +47 -0
  51. traia_iatp/mcp/templates/env.example.j2 +57 -0
  52. traia_iatp/mcp/templates/gitignore.j2 +77 -0
  53. traia_iatp/mcp/templates/mcp_health_check.py.j2 +150 -0
  54. traia_iatp/mcp/templates/pyproject.toml.j2 +32 -0
  55. traia_iatp/mcp/templates/pyrightconfig.json.j2 +22 -0
  56. traia_iatp/mcp/templates/run_local_docker.sh.j2 +390 -0
  57. traia_iatp/mcp/templates/server.py.j2 +175 -0
  58. traia_iatp/mcp/traia_mcp_adapter.py +543 -0
  59. traia_iatp/preview_diagrams.html +181 -0
  60. traia_iatp/registry/__init__.py +26 -0
  61. traia_iatp/registry/atlas_search_indexes.json +280 -0
  62. traia_iatp/registry/embeddings.py +298 -0
  63. traia_iatp/registry/iatp_search_api.py +846 -0
  64. traia_iatp/registry/mongodb_registry.py +771 -0
  65. traia_iatp/registry/readmes/ATLAS_SEARCH_INDEXES.md +252 -0
  66. traia_iatp/registry/readmes/ATLAS_SEARCH_SETUP.md +134 -0
  67. traia_iatp/registry/readmes/AUTHENTICATION_UPDATE.md +124 -0
  68. traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +172 -0
  69. traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +257 -0
  70. traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +208 -0
  71. traia_iatp/registry/readmes/README.md +251 -0
  72. traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +191 -0
  73. traia_iatp/scripts/__init__.py +2 -0
  74. traia_iatp/scripts/create_wallet.py +244 -0
  75. traia_iatp/server/__init__.py +15 -0
  76. traia_iatp/server/a2a_server.py +219 -0
  77. traia_iatp/server/example_template_usage.py +72 -0
  78. traia_iatp/server/iatp_server_agent_generator.py +237 -0
  79. traia_iatp/server/iatp_server_template_generator.py +235 -0
  80. traia_iatp/server/templates/.dockerignore.j2 +48 -0
  81. traia_iatp/server/templates/Dockerfile.j2 +49 -0
  82. traia_iatp/server/templates/README.md +137 -0
  83. traia_iatp/server/templates/README.md.j2 +425 -0
  84. traia_iatp/server/templates/__init__.py +1 -0
  85. traia_iatp/server/templates/__main__.py.j2 +565 -0
  86. traia_iatp/server/templates/agent.py.j2 +94 -0
  87. traia_iatp/server/templates/agent_config.json.j2 +22 -0
  88. traia_iatp/server/templates/agent_executor.py.j2 +279 -0
  89. traia_iatp/server/templates/docker-compose.yml.j2 +23 -0
  90. traia_iatp/server/templates/env.example.j2 +84 -0
  91. traia_iatp/server/templates/gitignore.j2 +78 -0
  92. traia_iatp/server/templates/grpc_server.py.j2 +218 -0
  93. traia_iatp/server/templates/pyproject.toml.j2 +78 -0
  94. traia_iatp/server/templates/run_local_docker.sh.j2 +103 -0
  95. traia_iatp/server/templates/server.py.j2 +243 -0
  96. traia_iatp/special_agencies/__init__.py +4 -0
  97. traia_iatp/special_agencies/registry_search_agency.py +392 -0
  98. traia_iatp/utils/__init__.py +10 -0
  99. traia_iatp/utils/docker_utils.py +251 -0
  100. traia_iatp/utils/general.py +64 -0
  101. traia_iatp/utils/iatp_utils.py +126 -0
  102. traia_iatp-0.1.29.dist-info/METADATA +423 -0
  103. traia_iatp-0.1.29.dist-info/RECORD +107 -0
  104. traia_iatp-0.1.29.dist-info/WHEEL +5 -0
  105. traia_iatp-0.1.29.dist-info/entry_points.txt +2 -0
  106. traia_iatp-0.1.29.dist-info/licenses/LICENSE +21 -0
  107. traia_iatp-0.1.29.dist-info/top_level.txt +1 -0
@@ -0,0 +1,178 @@
1
+ import time
2
+ import secrets
3
+ from typing import Dict, Any
4
+ from typing_extensions import (
5
+ TypedDict,
6
+ ) # use `typing_extensions.TypedDict` instead of `typing.TypedDict` on Python < 3.12
7
+ from eth_account import Account
8
+ from .encoding import safe_base64_encode, safe_base64_decode
9
+ from .types import (
10
+ PaymentRequirements,
11
+ )
12
+ from .chains import get_chain_id
13
+ import json
14
+
15
+
16
+ def create_nonce() -> bytes:
17
+ """Create a random 32-byte nonce for authorization signatures."""
18
+ return secrets.token_bytes(32)
19
+
20
+
21
+ def prepare_payment_header(
22
+ sender_address: str, d402_version: int, payment_requirements: PaymentRequirements
23
+ ) -> Dict[str, Any]:
24
+ """Prepare an unsigned payment header with sender address, d402 version, and payment requirements."""
25
+ nonce = create_nonce()
26
+ valid_after = str(int(time.time()) - 60) # 60 seconds before
27
+ valid_before = str(int(time.time()) + payment_requirements.max_timeout_seconds)
28
+
29
+ return {
30
+ "d402Version": d402_version,
31
+ "scheme": payment_requirements.scheme,
32
+ "network": payment_requirements.network,
33
+ "payload": {
34
+ "signature": None,
35
+ "authorization": {
36
+ "from": sender_address,
37
+ "to": payment_requirements.pay_to,
38
+ "value": payment_requirements.max_amount_required,
39
+ "validAfter": valid_after,
40
+ "validBefore": valid_before,
41
+ "nonce": nonce,
42
+ },
43
+ },
44
+ }
45
+
46
+
47
+ class PaymentHeader(TypedDict):
48
+ d402Version: int
49
+ scheme: str
50
+ network: str
51
+ payload: dict[str, Any]
52
+
53
+
54
+ def sign_payment_header(
55
+ operator_account: Account,
56
+ payment_requirements: PaymentRequirements,
57
+ header: PaymentHeader,
58
+ wallet_address: str = None,
59
+ request_path: str = None
60
+ ) -> str:
61
+ """
62
+ Sign a payment header using EIP-712 PullFundsForSettlement signature.
63
+
64
+ This signature format matches IATPWallet.sol validateConsumerSignature.
65
+
66
+ Contract Type Hash (IATPWallet.sol line 34-36):
67
+ PullFundsForSettlement(
68
+ address wallet, // Consumer's IATPWallet contract address
69
+ address provider, // Provider's IATPWallet contract address
70
+ address token, // Token address (USDC, etc.)
71
+ uint256 amount, // Payment amount
72
+ uint256 deadline, // Signature expiration
73
+ string requestPath // API path (e.g., "/mcp/tools/call")
74
+ )
75
+
76
+ Note: chainId is in the EIP-712 domain, NOT in the message (per EIP-712 standard)
77
+
78
+ Args:
79
+ operator_account: Operator account with private key for signing (EOA)
80
+ payment_requirements: Payment requirements from server
81
+ header: Payment header structure
82
+ wallet_address: Consumer's IATPWallet contract address (if None, uses operator_account.address)
83
+ request_path: API request path (if None, uses payment_requirements.resource)
84
+ """
85
+ try:
86
+ auth = header["payload"]["authorization"]
87
+
88
+ # Get wallet address (IATPWallet contract, not EOA)
89
+ consumer_wallet = wallet_address or auth["from"]
90
+
91
+ # Get request path from payment_requirements if not provided
92
+ if request_path is None:
93
+ request_path = payment_requirements.resource or "/mcp"
94
+ logger.info(f"🔍 payment_requirements.resource: {payment_requirements.resource}")
95
+ logger.info(f"🔍 Using request_path: {request_path}")
96
+
97
+ # Ensure we have a valid request path (contract requires non-empty string)
98
+ if not request_path or request_path.strip() == "":
99
+ logger.warning(f"⚠️ request_path was empty, defaulting to /mcp")
100
+ request_path = "/mcp"
101
+
102
+ # Get domain info from payment_requirements.extra (IATPWallet domain)
103
+ extra = payment_requirements.extra or {}
104
+ wallet_name = extra.get("name", "IATPWallet")
105
+ wallet_version = extra.get("version", "1")
106
+
107
+ # Build EIP-712 typed data for PullFundsForSettlement
108
+ # Note: chainId is in the domain, not the message (EIP-712 standard)
109
+ typed_data = {
110
+ "types": {
111
+ "PullFundsForSettlement": [
112
+ {"name": "wallet", "type": "address"},
113
+ {"name": "provider", "type": "address"},
114
+ {"name": "token", "type": "address"},
115
+ {"name": "amount", "type": "uint256"},
116
+ {"name": "deadline", "type": "uint256"},
117
+ {"name": "requestPath", "type": "string"},
118
+ ]
119
+ },
120
+ "primaryType": "PullFundsForSettlement",
121
+ "domain": {
122
+ "name": wallet_name,
123
+ "version": wallet_version,
124
+ "chainId": int(get_chain_id(payment_requirements.network)), # chainId in domain only
125
+ "verifyingContract": consumer_wallet, # Consumer's IATPWallet contract
126
+ },
127
+ "message": {
128
+ "wallet": consumer_wallet, # Consumer's IATPWallet contract address
129
+ "provider": auth["to"], # Provider's IATPWallet contract address
130
+ "token": payment_requirements.asset, # Token address (e.g., USDC)
131
+ "amount": int(auth["value"]),
132
+ "deadline": int(auth["validBefore"]),
133
+ "requestPath": request_path, # Actual API path, not nonce
134
+ },
135
+ }
136
+
137
+ signed_message = operator_account.sign_typed_data(
138
+ domain_data=typed_data["domain"],
139
+ message_types=typed_data["types"],
140
+ message_data=typed_data["message"],
141
+ )
142
+ signature = signed_message.signature.hex()
143
+ if not signature.startswith("0x"):
144
+ signature = f"0x{signature}"
145
+
146
+ header["payload"]["signature"] = signature
147
+
148
+ # Store wallet address and request path in header for verification
149
+ header["payload"]["authorization"]["from"] = consumer_wallet
150
+ header["payload"]["authorization"]["requestPath"] = request_path
151
+
152
+ encoded = encode_payment(header)
153
+ return encoded
154
+ except Exception:
155
+ raise
156
+
157
+
158
+ def encode_payment(payment_payload: Dict[str, Any]) -> str:
159
+ """Encode a payment payload into a base64 string, handling HexBytes and other non-serializable types."""
160
+ from hexbytes import HexBytes
161
+
162
+ def default(obj):
163
+ if isinstance(obj, HexBytes):
164
+ return obj.hex()
165
+ if hasattr(obj, "to_dict"):
166
+ return obj.to_dict()
167
+ if hasattr(obj, "hex"):
168
+ return obj.hex()
169
+ raise TypeError(
170
+ f"Object of type {obj.__class__.__name__} is not JSON serializable"
171
+ )
172
+
173
+ return safe_base64_encode(json.dumps(payment_payload, default=default))
174
+
175
+
176
+ def decode_payment(encoded_payment: str) -> Dict[str, Any]:
177
+ """Decode a base64 encoded payment string back into a PaymentPayload object."""
178
+ return json.loads(safe_base64_decode(encoded_payment))
@@ -0,0 +1,119 @@
1
+ import json
2
+ from typing import Dict, Any, List, Optional
3
+
4
+ from .types import PaymentRequirements, PaywallConfig
5
+ from .common import d402_VERSION
6
+ from .template import PAYWALL_TEMPLATE
7
+
8
+
9
+ def is_browser_request(headers: Dict[str, Any]) -> bool:
10
+ """
11
+ Determine if request is from a browser vs API client.
12
+
13
+ Args:
14
+ headers: Dictionary of request headers (case-insensitive keys)
15
+
16
+ Returns:
17
+ True if request appears to be from a browser, False otherwise
18
+ """
19
+ headers_lower = {k.lower(): v for k, v in headers.items()}
20
+ accept_header = headers_lower.get("accept", "")
21
+ user_agent = headers_lower.get("user-agent", "")
22
+
23
+ if "text/html" in accept_header and "Mozilla" in user_agent:
24
+ return True
25
+
26
+ return False
27
+
28
+
29
+ def create_d402_config(
30
+ error: str,
31
+ payment_requirements: List[PaymentRequirements],
32
+ paywall_config: Optional[PaywallConfig] = None,
33
+ ) -> Dict[str, Any]:
34
+ """Create d402 configuration object from payment requirements."""
35
+
36
+ requirements = payment_requirements[0] if payment_requirements else None
37
+ display_amount = 0
38
+ current_url = ""
39
+ testnet = True
40
+
41
+ if requirements:
42
+ # Convert atomic amount back to USD (assuming USDC with 6 decimals)
43
+ try:
44
+ display_amount = (
45
+ float(requirements.max_amount_required) / 1000000
46
+ ) # USDC has 6 decimals
47
+ except (ValueError, TypeError):
48
+ display_amount = 0
49
+
50
+ current_url = requirements.resource or ""
51
+ testnet = requirements.network == "base-sepolia"
52
+
53
+ # Get paywall config values or defaults
54
+ config = paywall_config or {}
55
+
56
+ # Create the window.d402 configuration object
57
+ return {
58
+ "amount": display_amount,
59
+ "paymentRequirements": [
60
+ req.model_dump(by_alias=True) for req in payment_requirements
61
+ ],
62
+ "testnet": testnet,
63
+ "currentUrl": current_url,
64
+ "error": error,
65
+ "d402_version": d402_VERSION,
66
+ "cdpClientKey": config.get("cdp_client_key", ""),
67
+ "appName": config.get("app_name", ""),
68
+ "appLogo": config.get("app_logo", ""),
69
+ "sessionTokenEndpoint": config.get("session_token_endpoint", ""),
70
+ }
71
+
72
+
73
+ def inject_payment_data(
74
+ html_content: str,
75
+ error: str,
76
+ payment_requirements: List[PaymentRequirements],
77
+ paywall_config: Optional[PaywallConfig] = None,
78
+ ) -> str:
79
+ """Inject payment requirements into HTML as JavaScript variables."""
80
+
81
+ # Create d402 configuration object
82
+ d402_config = create_d402_config(error, payment_requirements, paywall_config)
83
+
84
+ # Create the configuration script (matching TypeScript pattern)
85
+ log_on_testnet = (
86
+ "console.log('Payment requirements initialized:', window.d402);"
87
+ if d402_config["testnet"]
88
+ else ""
89
+ )
90
+
91
+ config_script = f"""
92
+ <script>
93
+ window.d402 = {json.dumps(d402_config)};
94
+ {log_on_testnet}
95
+ </script>"""
96
+
97
+ # Inject the configuration script into the head (same as TypeScript)
98
+ return html_content.replace("</head>", f"{config_script}\n</head>")
99
+
100
+
101
+ def get_paywall_html(
102
+ error: str,
103
+ payment_requirements: List[PaymentRequirements],
104
+ paywall_config: Optional[PaywallConfig] = None,
105
+ ) -> str:
106
+ """
107
+ Load paywall HTML and inject payment data.
108
+
109
+ Args:
110
+ error: Error message to display
111
+ payment_requirements: List of payment requirements
112
+ paywall_config: Optional paywall UI configuration
113
+
114
+ Returns:
115
+ Complete HTML with injected payment data
116
+ """
117
+ return inject_payment_data(
118
+ PAYWALL_TEMPLATE, error, payment_requirements, paywall_config
119
+ )
@@ -0,0 +1,326 @@
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
+ class D402PaymentMiddleware(BaseHTTPMiddleware):
26
+ """
27
+ Starlette middleware that intercepts MCP tool calls for HTTP 402 payment.
28
+
29
+ This middleware:
30
+ 1. Extracts API key if present → stores in request.state
31
+ 2. Checks if tool requires payment
32
+ 3. Returns HTTP 402 if neither auth nor payment
33
+ 4. Sets request.state.api_key_to_use with the resolved key
34
+ 5. Forwards to FastMCP
35
+
36
+ Usage:
37
+ app = mcp.streamable_http_app()
38
+ app.add_middleware(
39
+ D402PaymentMiddleware,
40
+ tool_payment_configs=TOOL_PAYMENT_CONFIGS,
41
+ server_address=SERVER_ADDRESS,
42
+ requires_auth=True,
43
+ internal_api_key="server_api_key" # Server's internal key
44
+ )
45
+ """
46
+
47
+ def __init__(
48
+ self,
49
+ app,
50
+ tool_payment_configs: Dict[str, Dict[str, Any]],
51
+ server_address: str,
52
+ requires_auth: bool = False,
53
+ internal_api_key: Optional[str] = None,
54
+ testing_mode: bool = False,
55
+ facilitator_url: Optional[str] = None,
56
+ facilitator_api_key: Optional[str] = None
57
+ ):
58
+ super().__init__(app)
59
+ self.tool_payment_configs = tool_payment_configs
60
+ self.server_address = server_address
61
+ self.requires_auth = requires_auth
62
+ self.internal_api_key = internal_api_key # Server's internal API key
63
+ self.testing_mode = testing_mode or os.getenv("D402_TESTING_MODE", "false").lower() == "true"
64
+
65
+ # Initialize facilitator for payment verification and settlement
66
+ self.facilitator = None
67
+ if not self.testing_mode:
68
+ try:
69
+ operator_key = os.getenv("MCP_OPERATOR_PRIVATE_KEY") or os.getenv("OPERATOR_PRIVATE_KEY")
70
+ self.facilitator = IATPSettlementFacilitator(
71
+ relayer_url=facilitator_url or os.getenv("D402_FACILITATOR_URL", "https://facilitator.d402.net"),
72
+ relayer_api_key=facilitator_api_key or os.getenv("D402_FACILITATOR_API_KEY"),
73
+ provider_operator_key=operator_key,
74
+ facilitator_url=facilitator_url or os.getenv("D402_FACILITATOR_URL", "https://facilitator.d402.net"),
75
+ facilitator_api_key=facilitator_api_key or os.getenv("D402_FACILITATOR_API_KEY")
76
+ )
77
+ if operator_key:
78
+ logger.info(f" Facilitator initialized with operator key (settlement enabled)")
79
+ else:
80
+ logger.warning(f" No operator key - settlement disabled")
81
+ except Exception as e:
82
+ logger.warning(f" Could not initialize facilitator: {e}")
83
+ self.testing_mode = True
84
+
85
+ logger.info(f"D402PaymentMiddleware initialized:")
86
+ logger.info(f" Payment-enabled tools: {len(tool_payment_configs)}")
87
+ logger.info(f" Server address: {server_address}")
88
+ logger.info(f" Testing mode: {self.testing_mode}")
89
+ logger.info(f" Facilitator: {'Enabled' if self.facilitator else 'Disabled (testing)'}")
90
+
91
+ async def dispatch(self, request: Request, call_next):
92
+ """
93
+ Intercept requests for auth and payment checking.
94
+
95
+ Handles both:
96
+ 1. If server requires auth: Extract API key and store in request.state
97
+ 2. If tool requires payment: Check payment or auth, return HTTP 402 if missing
98
+ """
99
+
100
+ # Step 1: Store middleware reference for decorator access (for settlement)
101
+ request.state.d402_middleware = self
102
+
103
+ # Step 2: Extract and store API key if present (for all requests)
104
+ if self.requires_auth:
105
+ auth = request.headers.get("Authorization", "")
106
+ if auth.lower().startswith("bearer "):
107
+ token = auth[7:].strip()
108
+ request.state.api_key = token
109
+ request.state.authenticated = True
110
+ logger.debug(f"D402: API key stored: {token[:10]}...")
111
+ elif request.headers.get("X-API-KEY"):
112
+ token = request.headers.get("X-API-KEY")
113
+ request.state.api_key = token
114
+ request.state.authenticated = True
115
+ logger.debug(f"D402: X-API-KEY stored: {token[:10]}...")
116
+ else:
117
+ request.state.api_key = None
118
+ request.state.authenticated = False
119
+
120
+ # Step 2: Check payment for tool calls
121
+ # Only intercept POST to /mcp
122
+ if request.method != "POST" or not request.url.path.startswith("/mcp"):
123
+ return await call_next(request)
124
+
125
+ try:
126
+ # Read body to check tool name
127
+ body = await request.body()
128
+ data = json.loads(body)
129
+
130
+ # Check if it's a tool call
131
+ if data.get("method") == "tools/call":
132
+ tool_name = data.get("params", {}).get("name")
133
+
134
+ # Calculate tool-specific path for signature binding
135
+ # For MCP servers: /mcp/tools/{tool_name}
136
+ # For other servers: use the actual HTTP path
137
+ if request.url.path.rstrip('/').endswith('/mcp'):
138
+ base_path = request.url.path.rstrip('/')
139
+ tool_path = f"{base_path}/tools/{tool_name}"
140
+ else:
141
+ tool_path = request.url.path
142
+
143
+ # Check if tool requires payment
144
+ if tool_name in self.tool_payment_configs:
145
+ # Mode 1: If server requires auth AND client has API key → FREE
146
+ if self.requires_auth and request.state.authenticated:
147
+ logger.info(f"✅ {tool_name}: Client authenticated with API key (Mode 1: Free)")
148
+ # Set api_key_to_use = client's key
149
+ request.state.api_key_to_use = request.state.api_key
150
+ # Continue to FastMCP
151
+ return await self._continue_with_body(request, body, call_next)
152
+
153
+ # Mode 2: Check payment → Client must pay, server uses internal API key
154
+ payment_header = request.headers.get("X-Payment")
155
+ if not payment_header:
156
+ logger.info(f"💰 {tool_name}: Payment required (Mode 2) - HTTP 402")
157
+ config = self.tool_payment_configs[tool_name]
158
+ return self._create_402_response(config, "Payment required", request_path=tool_path)
159
+ else:
160
+ # Payment header present - VALIDATE IT!
161
+ logger.info(f"💰 {tool_name}: Payment header RECEIVED - validating...")
162
+ logger.info(f"📦 Payment header length: {len(payment_header)} bytes")
163
+
164
+ # TODO: Add full payment validation:
165
+ # 1. Decode and parse payment header
166
+ # 2. Verify EIP-3009 signature
167
+ # 3. Check amount >= required
168
+ # 4. Verify pay_to == SERVER_ADDRESS
169
+ # 5. Check timestamp validity
170
+ # 6. Call facilitator.verify() if not testing mode
171
+
172
+ # For now: Basic validation in testing mode
173
+ try:
174
+ from .encoding import safe_base64_decode
175
+ payment_data = safe_base64_decode(payment_header)
176
+ if not payment_data:
177
+ logger.error(f"❌ {tool_name}: Invalid payment encoding")
178
+ # Return 402 with error
179
+ config = self.tool_payment_configs[tool_name]
180
+ return self._create_402_response(config, "Invalid payment encoding", request_path=tool_path)
181
+
182
+ payment_dict = json.loads(payment_data)
183
+
184
+ # Basic validation: check structure
185
+ if not payment_dict.get("payload") or not payment_dict["payload"].get("authorization"):
186
+ logger.error(f"❌ {tool_name}: Invalid payment structure")
187
+ config = self.tool_payment_configs[tool_name]
188
+ return self._create_402_response(config, "Invalid payment structure", request_path=tool_path)
189
+
190
+ auth = payment_dict["payload"]["authorization"]
191
+
192
+ # Verify payment destination
193
+ if auth.get("to", "").lower() != self.server_address.lower():
194
+ logger.error(f"❌ {tool_name}: Payment to wrong address")
195
+ config = self.tool_payment_configs[tool_name]
196
+ return self._create_402_response(config, "Payment to wrong address", request_path=tool_path)
197
+
198
+ # Verify payment amount
199
+ config = self.tool_payment_configs[tool_name]
200
+ payment_amount = int(auth.get("value", 0))
201
+ required_amount = int(config["price_wei"])
202
+
203
+ if payment_amount < required_amount:
204
+ logger.error(f"❌ {tool_name}: Insufficient payment: {payment_amount} < {required_amount}")
205
+ return self._create_402_response(config, f"Insufficient payment: {payment_amount} < {required_amount}", request_path=tool_path)
206
+
207
+ # Call facilitator.verify() if available (production mode)
208
+ if self.facilitator and not self.testing_mode:
209
+ try:
210
+ # Create PaymentPayload for facilitator
211
+ payment_payload = PaymentPayload.model_validate(payment_dict)
212
+
213
+ # Create PaymentRequirements with full token info
214
+ payment_reqs = PaymentRequirements(
215
+ scheme="exact",
216
+ network=config["network"],
217
+ pay_to=config["server_address"],
218
+ max_amount_required=config["price_wei"],
219
+ max_timeout_seconds=300,
220
+ description=config["description"],
221
+ resource="",
222
+ mime_type="application/json",
223
+ asset=config["token_address"],
224
+ extra=None
225
+ )
226
+
227
+ # Verify with facilitator
228
+ logger.info(f"🔐 Verifying payment with facilitator...")
229
+ #==============================================================
230
+ verify_result = await self.facilitator.verify(payment_payload, payment_reqs)
231
+ #==============================================================
232
+ logger.info(f"🔐 Facilitator verify result: {verify_result}")
233
+ if not verify_result.is_valid:
234
+ logger.error(f"❌ {tool_name}: Facilitator rejected payment: {verify_result.invalid_reason}")
235
+ return self._create_402_response(config, f"Payment verification failed: {verify_result.invalid_reason}", request_path=tool_path)
236
+
237
+ # Store payment_uuid and facilitatorFeePercent for settlement
238
+ request.state.payment_uuid = verify_result.payment_uuid
239
+ request.state.facilitator_fee_percent = verify_result.facilitator_fee_percent or 250
240
+ logger.info(f"✅ {tool_name}: Facilitator verified payment (UUID: {verify_result.payment_uuid[:20] if verify_result.payment_uuid else 'N/A'}...)")
241
+ logger.info(f" Facilitator fee: {request.state.facilitator_fee_percent} basis points")
242
+
243
+ except Exception as e:
244
+ logger.error(f"❌ {tool_name}: Facilitator error: {e}")
245
+ return self._create_402_response(config, f"Facilitator verification failed: {str(e)}", request_path=tool_path)
246
+ else:
247
+ logger.info(f"⚠️ {tool_name}: Testing mode - skipping facilitator verification")
248
+
249
+ # Payment validated! Set api_key_to_use
250
+ logger.info(f"✅ {tool_name}: Payment VERIFIED successfully (Mode 2: Paid)")
251
+ logger.info(f" Payment amount: {payment_amount} wei (required: {required_amount} wei)")
252
+ logger.info(f" From (wallet): {auth.get('from', 'unknown')}")
253
+ logger.info(f" To (provider): {auth.get('to', 'unknown')}")
254
+ logger.info(f" Request path: {auth.get('requestPath', auth.get('request_path', 'unknown'))}")
255
+ request.state.api_key_to_use = self.internal_api_key
256
+ request.state.payment_validated = True
257
+ request.state.payment_dict = payment_dict
258
+
259
+ # Store payment info for settlement
260
+ request.state.payment_payload = PaymentPayload.model_validate(payment_dict)
261
+ logger.info(f"💾 {tool_name}: Payment payload stored for settlement")
262
+
263
+ except Exception as e:
264
+ logger.error(f"❌ {tool_name}: Payment validation error: {e}")
265
+ config = self.tool_payment_configs[tool_name]
266
+ return self._create_402_response(config, f"Payment validation failed: {str(e)}", request_path=tool_path)
267
+
268
+ # Continue with reconstructed request
269
+ return await self._continue_with_body(request, body, call_next)
270
+
271
+ except Exception as e:
272
+ logger.error(f"Error in D402PaymentMiddleware: {e}")
273
+ import traceback
274
+ logger.error(traceback.format_exc())
275
+ # Continue on error
276
+ return await call_next(request)
277
+
278
+ async def _continue_with_body(self, request: Request, body: bytes, call_next):
279
+ """Continue request processing with body we already read."""
280
+ # Create new request with reconstructed receive
281
+ async def receive():
282
+ return {"type": "http.request", "body": body, "more_body": False}
283
+
284
+ from starlette.requests import Request as NewRequest
285
+ new_request = NewRequest(request.scope, receive)
286
+ return await call_next(new_request)
287
+
288
+ def _create_402_response(self, config: Dict[str, Any], error_message: str, request_path: str = "/mcp") -> JSONResponse:
289
+ """Helper to create HTTP 402 response with request path for signature binding."""
290
+ # Include EIP712 domain in extra for client to sign payment
291
+ # This should be IATPWallet domain (consumer's wallet contract)
292
+ extra_data = config.get("eip712_domain", {
293
+ "name": "IATPWallet", # Consumer's wallet contract
294
+ "version": "1"
295
+ })
296
+
297
+ logger.info(f" 🔧 Creating 402 response with resource: {request_path}")
298
+
299
+ payment_req = PaymentRequirements(
300
+ scheme="exact",
301
+ network=config["network"],
302
+ pay_to=config["server_address"],
303
+ max_amount_required=config["price_wei"],
304
+ max_timeout_seconds=300,
305
+ description=config["description"],
306
+ resource=request_path, # Include actual API path for signature binding
307
+ mime_type="application/json",
308
+ asset=config["token_address"],
309
+ extra=extra_data # EIP712 domain for signature
310
+ )
311
+
312
+ response_data = d402PaymentRequiredResponse(
313
+ d402_version=d402_VERSION,
314
+ accepts=[payment_req],
315
+ error=error_message
316
+ )
317
+
318
+ return JSONResponse(
319
+ status_code=402,
320
+ content=response_data.model_dump(by_alias=True),
321
+ headers={"Access-Control-Expose-Headers": "X-Payment-Response"}
322
+ )
323
+
324
+
325
+ __all__ = ["D402PaymentMiddleware"]
326
+