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.
- traia_iatp/README.md +368 -0
- traia_iatp/__init__.py +54 -0
- traia_iatp/cli/__init__.py +5 -0
- traia_iatp/cli/main.py +483 -0
- traia_iatp/client/__init__.py +10 -0
- traia_iatp/client/a2a_client.py +274 -0
- traia_iatp/client/crewai_a2a_tools.py +335 -0
- traia_iatp/client/d402_a2a_client.py +293 -0
- traia_iatp/client/grpc_a2a_tools.py +349 -0
- traia_iatp/client/root_path_a2a_client.py +1 -0
- traia_iatp/contracts/__init__.py +12 -0
- traia_iatp/contracts/iatp_contracts_config.py +263 -0
- traia_iatp/contracts/wallet_creator.py +255 -0
- traia_iatp/core/__init__.py +43 -0
- traia_iatp/core/models.py +172 -0
- traia_iatp/d402/__init__.py +55 -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 +219 -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 +453 -0
- traia_iatp/d402/fastapi_middleware/__init__.py +6 -0
- traia_iatp/d402/fastapi_middleware/middleware.py +225 -0
- traia_iatp/d402/fastmcp_middleware.py +147 -0
- traia_iatp/d402/mcp_middleware.py +434 -0
- traia_iatp/d402/middleware.py +193 -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 +104 -0
- traia_iatp/d402/payment_signing.py +178 -0
- traia_iatp/d402/paywall.py +119 -0
- traia_iatp/d402/starlette_middleware.py +326 -0
- traia_iatp/d402/template.py +1 -0
- traia_iatp/d402/types.py +300 -0
- traia_iatp/mcp/__init__.py +18 -0
- traia_iatp/mcp/client.py +201 -0
- traia_iatp/mcp/d402_mcp_tool_adapter.py +361 -0
- traia_iatp/mcp/mcp_agent_template.py +481 -0
- traia_iatp/mcp/templates/Dockerfile.j2 +80 -0
- traia_iatp/mcp/templates/README.md.j2 +310 -0
- traia_iatp/mcp/templates/cursor-rules.md.j2 +520 -0
- traia_iatp/mcp/templates/deployment_params.json.j2 +20 -0
- traia_iatp/mcp/templates/docker-compose.yml.j2 +32 -0
- traia_iatp/mcp/templates/dockerignore.j2 +47 -0
- traia_iatp/mcp/templates/env.example.j2 +57 -0
- traia_iatp/mcp/templates/gitignore.j2 +77 -0
- traia_iatp/mcp/templates/mcp_health_check.py.j2 +150 -0
- traia_iatp/mcp/templates/pyproject.toml.j2 +32 -0
- traia_iatp/mcp/templates/pyrightconfig.json.j2 +22 -0
- traia_iatp/mcp/templates/run_local_docker.sh.j2 +390 -0
- traia_iatp/mcp/templates/server.py.j2 +175 -0
- traia_iatp/mcp/traia_mcp_adapter.py +543 -0
- traia_iatp/preview_diagrams.html +181 -0
- traia_iatp/registry/__init__.py +26 -0
- traia_iatp/registry/atlas_search_indexes.json +280 -0
- traia_iatp/registry/embeddings.py +298 -0
- traia_iatp/registry/iatp_search_api.py +846 -0
- traia_iatp/registry/mongodb_registry.py +771 -0
- traia_iatp/registry/readmes/ATLAS_SEARCH_INDEXES.md +252 -0
- traia_iatp/registry/readmes/ATLAS_SEARCH_SETUP.md +134 -0
- traia_iatp/registry/readmes/AUTHENTICATION_UPDATE.md +124 -0
- traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +172 -0
- traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +257 -0
- traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +208 -0
- traia_iatp/registry/readmes/README.md +251 -0
- traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +191 -0
- traia_iatp/scripts/__init__.py +2 -0
- traia_iatp/scripts/create_wallet.py +244 -0
- traia_iatp/server/__init__.py +15 -0
- traia_iatp/server/a2a_server.py +219 -0
- traia_iatp/server/example_template_usage.py +72 -0
- traia_iatp/server/iatp_server_agent_generator.py +237 -0
- traia_iatp/server/iatp_server_template_generator.py +235 -0
- traia_iatp/server/templates/.dockerignore.j2 +48 -0
- traia_iatp/server/templates/Dockerfile.j2 +49 -0
- traia_iatp/server/templates/README.md +137 -0
- traia_iatp/server/templates/README.md.j2 +425 -0
- traia_iatp/server/templates/__init__.py +1 -0
- traia_iatp/server/templates/__main__.py.j2 +565 -0
- traia_iatp/server/templates/agent.py.j2 +94 -0
- traia_iatp/server/templates/agent_config.json.j2 +22 -0
- traia_iatp/server/templates/agent_executor.py.j2 +279 -0
- traia_iatp/server/templates/docker-compose.yml.j2 +23 -0
- traia_iatp/server/templates/env.example.j2 +84 -0
- traia_iatp/server/templates/gitignore.j2 +78 -0
- traia_iatp/server/templates/grpc_server.py.j2 +218 -0
- traia_iatp/server/templates/pyproject.toml.j2 +78 -0
- traia_iatp/server/templates/run_local_docker.sh.j2 +103 -0
- traia_iatp/server/templates/server.py.j2 +243 -0
- traia_iatp/special_agencies/__init__.py +4 -0
- traia_iatp/special_agencies/registry_search_agency.py +392 -0
- traia_iatp/utils/__init__.py +10 -0
- traia_iatp/utils/docker_utils.py +251 -0
- traia_iatp/utils/general.py +64 -0
- traia_iatp/utils/iatp_utils.py +126 -0
- traia_iatp-0.1.29.dist-info/METADATA +423 -0
- traia_iatp-0.1.29.dist-info/RECORD +107 -0
- traia_iatp-0.1.29.dist-info/WHEEL +5 -0
- traia_iatp-0.1.29.dist-info/entry_points.txt +2 -0
- traia_iatp-0.1.29.dist-info/licenses/LICENSE +21 -0
- 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
|
+
|