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
traia_iatp/d402/types.py
ADDED
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from typing import Any, Optional, Union, Dict, Literal, List
|
|
6
|
+
from typing_extensions import (
|
|
7
|
+
TypedDict,
|
|
8
|
+
) # use `typing_extensions.TypedDict` instead of `typing.TypedDict` on Python < 3.12
|
|
9
|
+
|
|
10
|
+
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
|
11
|
+
from pydantic.alias_generators import to_camel
|
|
12
|
+
|
|
13
|
+
from .networks import SupportedNetworks
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
# Add HTTP request structure types
|
|
17
|
+
class HTTPVerbs(str, Enum):
|
|
18
|
+
GET = "GET"
|
|
19
|
+
POST = "POST"
|
|
20
|
+
PUT = "PUT"
|
|
21
|
+
DELETE = "DELETE"
|
|
22
|
+
PATCH = "PATCH"
|
|
23
|
+
OPTIONS = "OPTIONS"
|
|
24
|
+
HEAD = "HEAD"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class HTTPInputSchema(BaseModel):
|
|
28
|
+
"""Schema for HTTP request input, excluding spec and method which are handled by the middleware"""
|
|
29
|
+
|
|
30
|
+
query_params: Optional[Dict[str, str]] = None
|
|
31
|
+
body_type: Optional[
|
|
32
|
+
Literal["json", "form-data", "multipart-form-data", "text", "binary"]
|
|
33
|
+
] = None
|
|
34
|
+
body_fields: Optional[Dict[str, Any]] = None
|
|
35
|
+
header_fields: Optional[Dict[str, Any]] = None
|
|
36
|
+
|
|
37
|
+
model_config = ConfigDict(
|
|
38
|
+
alias_generator=to_camel,
|
|
39
|
+
populate_by_name=True,
|
|
40
|
+
from_attributes=True,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class HTTPRequestStructure(HTTPInputSchema):
|
|
45
|
+
"""Complete HTTP request structure including protocol type and method"""
|
|
46
|
+
|
|
47
|
+
type: Literal["http"]
|
|
48
|
+
method: HTTPVerbs
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
# For now we only support HTTP, but could add MCP and OpenAPI later
|
|
52
|
+
RequestStructure = HTTPRequestStructure
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class TokenAmount(BaseModel):
|
|
56
|
+
"""Represents an amount of tokens in atomic units with asset information"""
|
|
57
|
+
|
|
58
|
+
amount: str
|
|
59
|
+
asset: TokenAsset
|
|
60
|
+
|
|
61
|
+
@field_validator("amount")
|
|
62
|
+
def validate_amount(cls, v):
|
|
63
|
+
try:
|
|
64
|
+
int(v)
|
|
65
|
+
except ValueError:
|
|
66
|
+
raise ValueError("amount must be an integer encoded as a string")
|
|
67
|
+
return v
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
class TokenAsset(BaseModel):
|
|
71
|
+
"""Represents token asset information including EIP-712 domain data and network"""
|
|
72
|
+
|
|
73
|
+
address: str
|
|
74
|
+
decimals: int
|
|
75
|
+
eip712: EIP712Domain
|
|
76
|
+
network: Optional[str] = None # Blockchain network (e.g., "sepolia", "base-sepolia")
|
|
77
|
+
|
|
78
|
+
@field_validator("decimals")
|
|
79
|
+
def validate_decimals(cls, v):
|
|
80
|
+
if v < 0 or v > 255:
|
|
81
|
+
raise ValueError("decimals must be between 0 and 255")
|
|
82
|
+
return v
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class EIP712Domain(BaseModel):
|
|
86
|
+
"""EIP-712 domain information for token signing"""
|
|
87
|
+
|
|
88
|
+
name: str
|
|
89
|
+
version: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# Price can be either Money (USD string) or TokenAmount
|
|
93
|
+
Money = Union[str, int] # e.g., "$0.01", 0.01, "0.001"
|
|
94
|
+
Price = Union[Money, TokenAmount]
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class PaymentRequirements(BaseModel):
|
|
98
|
+
scheme: str
|
|
99
|
+
network: SupportedNetworks
|
|
100
|
+
max_amount_required: str
|
|
101
|
+
resource: str
|
|
102
|
+
description: str
|
|
103
|
+
mime_type: str
|
|
104
|
+
output_schema: Optional[Any] = None
|
|
105
|
+
pay_to: str
|
|
106
|
+
max_timeout_seconds: int
|
|
107
|
+
asset: str
|
|
108
|
+
extra: Optional[dict[str, Any]] = None
|
|
109
|
+
|
|
110
|
+
model_config = ConfigDict(
|
|
111
|
+
alias_generator=to_camel,
|
|
112
|
+
populate_by_name=True,
|
|
113
|
+
from_attributes=True,
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
@field_validator("max_amount_required")
|
|
117
|
+
def validate_max_amount_required(cls, v):
|
|
118
|
+
try:
|
|
119
|
+
int(v)
|
|
120
|
+
except ValueError:
|
|
121
|
+
raise ValueError(
|
|
122
|
+
"max_amount_required must be an integer encoded as a string"
|
|
123
|
+
)
|
|
124
|
+
return v
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# Returned by a server as json alongside a 402 response code
|
|
128
|
+
class d402PaymentRequiredResponse(BaseModel):
|
|
129
|
+
d402_version: int
|
|
130
|
+
accepts: list[PaymentRequirements]
|
|
131
|
+
error: str
|
|
132
|
+
|
|
133
|
+
model_config = ConfigDict(
|
|
134
|
+
alias_generator=to_camel,
|
|
135
|
+
populate_by_name=True,
|
|
136
|
+
from_attributes=True,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
class PullFundsAuthorization(BaseModel):
|
|
141
|
+
"""
|
|
142
|
+
Authorization data for payment header (wire format).
|
|
143
|
+
|
|
144
|
+
This structure is sent in the payment header and includes fields for:
|
|
145
|
+
- EIP-712 signature: wallet, provider, token, amount, deadline, requestPath
|
|
146
|
+
- Transport metadata: valid_after, valid_before (for payment window)
|
|
147
|
+
|
|
148
|
+
Note: Only some fields are signed (see IATPWallet.sol PULL_FUNDS_FOR_SETTLEMENT_TYPEHASH)
|
|
149
|
+
"""
|
|
150
|
+
from_: str = Field(alias="from") # Consumer's IATPWallet address
|
|
151
|
+
to: str # Provider's IATPWallet address
|
|
152
|
+
value: str # Payment amount
|
|
153
|
+
valid_after: str = Field(alias="validAfter") # Not in signature (transport only)
|
|
154
|
+
valid_before: str = Field(alias="validBefore") # Maps to 'deadline' in signature
|
|
155
|
+
request_path: str = Field(alias="requestPath") # API path (signed)
|
|
156
|
+
|
|
157
|
+
model_config = ConfigDict(
|
|
158
|
+
alias_generator=to_camel,
|
|
159
|
+
populate_by_name=True,
|
|
160
|
+
from_attributes=True,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
@field_validator("value")
|
|
164
|
+
def validate_value(cls, v):
|
|
165
|
+
try:
|
|
166
|
+
int(v)
|
|
167
|
+
except ValueError:
|
|
168
|
+
raise ValueError("value must be an integer encoded as a string")
|
|
169
|
+
return v
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class ExactPaymentPayload(BaseModel):
|
|
173
|
+
"""Payment payload with PullFundsForSettlement signature."""
|
|
174
|
+
signature: str
|
|
175
|
+
authorization: PullFundsAuthorization
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
class VerifyResponse(BaseModel):
|
|
179
|
+
is_valid: bool = Field(alias="isValid")
|
|
180
|
+
invalid_reason: Optional[str] = Field(None, alias="invalidReason")
|
|
181
|
+
payer: Optional[str]
|
|
182
|
+
payment_uuid: Optional[str] = Field(None, alias="paymentUuid") # Unique payment identifier from facilitator
|
|
183
|
+
facilitator_fee_percent: Optional[int] = Field(250, alias="facilitatorFeePercent") # Fee percent from facilitator (default 2.5% = 250 basis points)
|
|
184
|
+
|
|
185
|
+
model_config = ConfigDict(
|
|
186
|
+
alias_generator=to_camel,
|
|
187
|
+
populate_by_name=True,
|
|
188
|
+
from_attributes=True,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class SettleResponse(BaseModel):
|
|
193
|
+
success: bool
|
|
194
|
+
error_reason: Optional[str] = None
|
|
195
|
+
transaction: Optional[str] = None
|
|
196
|
+
network: Optional[str] = None
|
|
197
|
+
payer: Optional[str] = None
|
|
198
|
+
|
|
199
|
+
model_config = ConfigDict(
|
|
200
|
+
alias_generator=to_camel,
|
|
201
|
+
populate_by_name=True,
|
|
202
|
+
from_attributes=True,
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# Union of payloads for each scheme
|
|
207
|
+
SchemePayloads = ExactPaymentPayload
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class PaymentPayload(BaseModel):
|
|
211
|
+
d402_version: int
|
|
212
|
+
scheme: str
|
|
213
|
+
network: str
|
|
214
|
+
payload: SchemePayloads
|
|
215
|
+
|
|
216
|
+
model_config = ConfigDict(
|
|
217
|
+
alias_generator=to_camel,
|
|
218
|
+
populate_by_name=True,
|
|
219
|
+
from_attributes=True,
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
class D402Headers(BaseModel):
|
|
224
|
+
x_payment: str
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
class UnsupportedSchemeException(Exception):
|
|
228
|
+
pass
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class PaywallConfig(TypedDict, total=False):
|
|
232
|
+
"""Configuration for paywall UI customization"""
|
|
233
|
+
|
|
234
|
+
cdp_client_key: str
|
|
235
|
+
app_name: str
|
|
236
|
+
app_logo: str
|
|
237
|
+
session_token_endpoint: str
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
class DiscoveredResource(BaseModel):
|
|
241
|
+
"""A discovery resource represents a discoverable resource in the D402 ecosystem."""
|
|
242
|
+
|
|
243
|
+
resource: str
|
|
244
|
+
type: str = Field(..., pattern="^http$") # Currently only supports 'http'
|
|
245
|
+
d402_version: int = Field(..., alias="d402Version")
|
|
246
|
+
accepts: List["PaymentRequirements"]
|
|
247
|
+
last_updated: datetime = Field(
|
|
248
|
+
...,
|
|
249
|
+
alias="lastUpdated",
|
|
250
|
+
description="ISO 8601 formatted datetime string with UTC timezone (e.g. 2025-08-09T01:07:04.005Z)",
|
|
251
|
+
)
|
|
252
|
+
metadata: Optional[dict] = None
|
|
253
|
+
|
|
254
|
+
model_config = ConfigDict(
|
|
255
|
+
alias_generator=to_camel,
|
|
256
|
+
populate_by_name=True,
|
|
257
|
+
from_attributes=True,
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
class ListDiscoveryResourcesRequest(BaseModel):
|
|
262
|
+
"""Request parameters for listing discovery resources."""
|
|
263
|
+
|
|
264
|
+
type: Optional[str] = None
|
|
265
|
+
limit: Optional[int] = None
|
|
266
|
+
offset: Optional[int] = None
|
|
267
|
+
|
|
268
|
+
model_config = ConfigDict(
|
|
269
|
+
alias_generator=to_camel,
|
|
270
|
+
populate_by_name=True,
|
|
271
|
+
from_attributes=True,
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
class DiscoveryResourcesPagination(BaseModel):
|
|
276
|
+
"""Pagination information for discovery resources responses."""
|
|
277
|
+
|
|
278
|
+
limit: int
|
|
279
|
+
offset: int
|
|
280
|
+
total: int
|
|
281
|
+
|
|
282
|
+
model_config = ConfigDict(
|
|
283
|
+
alias_generator=to_camel,
|
|
284
|
+
populate_by_name=True,
|
|
285
|
+
from_attributes=True,
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
class ListDiscoveryResourcesResponse(BaseModel):
|
|
290
|
+
"""Response from the discovery resources endpoint."""
|
|
291
|
+
|
|
292
|
+
d402_version: int = Field(..., alias="d402Version")
|
|
293
|
+
items: List[DiscoveredResource]
|
|
294
|
+
pagination: DiscoveryResourcesPagination
|
|
295
|
+
|
|
296
|
+
model_config = ConfigDict(
|
|
297
|
+
alias_generator=to_camel,
|
|
298
|
+
populate_by_name=True,
|
|
299
|
+
from_attributes=True,
|
|
300
|
+
)
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
# d402 Payment Flow in MCP Adapter
|
|
2
|
+
|
|
3
|
+
This document explains how the `TraiaMCPAdapter` integrates with d402 payment protocol to handle HTTP 402 Payment Required responses from MCP servers.
|
|
4
|
+
|
|
5
|
+
## Architecture Overview
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
9
|
+
│ CrewAI Agent/Task │
|
|
10
|
+
│ Calls MCP tool (e.g., get_prompt) │
|
|
11
|
+
└───────────────────────┬─────────────────────────────────────────┘
|
|
12
|
+
│
|
|
13
|
+
▼
|
|
14
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
15
|
+
│ TraiaMCPAdapter (extends MCPServerAdapter) │
|
|
16
|
+
│ - Monkey-patches httpx.AsyncClient.__init__ │
|
|
17
|
+
│ - Adds d402 payment hooks to all httpx clients │
|
|
18
|
+
└───────────────────────┬─────────────────────────────────────────┘
|
|
19
|
+
│
|
|
20
|
+
▼
|
|
21
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
22
|
+
│ MCPServerAdapter (from crewai_tools) │
|
|
23
|
+
│ - Creates httpx.AsyncClient instances │
|
|
24
|
+
│ - Makes MCP protocol requests (tools/call) │
|
|
25
|
+
│ - Returns BaseTool instances │
|
|
26
|
+
└───────────────────────┬─────────────────────────────────────────┘
|
|
27
|
+
│
|
|
28
|
+
▼
|
|
29
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
30
|
+
│ httpx.AsyncClient (with d402 hooks applied) │
|
|
31
|
+
│ - Event hooks: on_request, on_response │
|
|
32
|
+
│ - Automatically handles 402 responses │
|
|
33
|
+
└───────────────────────┬─────────────────────────────────────────┘
|
|
34
|
+
│
|
|
35
|
+
▼
|
|
36
|
+
┌─────────────────────────────────────────────────────────────────┐
|
|
37
|
+
│ MCP Server (FastMCP) │
|
|
38
|
+
│ - Receives tools/call request │
|
|
39
|
+
│ - Returns 402 Payment Required if no payment │
|
|
40
|
+
│ - Processes request if payment provided │
|
|
41
|
+
└─────────────────────────────────────────────────────────────────┘
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
## Detailed Flow
|
|
45
|
+
|
|
46
|
+
### 1. Initialization Phase
|
|
47
|
+
|
|
48
|
+
When `TraiaMCPAdapter` is created with d402 payment support:
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
adapter = create_mcp_adapter_with_x402(
|
|
52
|
+
url="http://localhost:8000/mcp",
|
|
53
|
+
account=client_account,
|
|
54
|
+
max_value=1_000_000
|
|
55
|
+
)
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
**What happens:**
|
|
59
|
+
1. `TraiaMCPAdapter.__init__()` is called
|
|
60
|
+
2. If `d402_account` is provided, `_apply_d402_patch()` is called
|
|
61
|
+
3. This monkey-patches `httpx.AsyncClient.__init__` globally
|
|
62
|
+
4. The patch ensures ALL future `httpx.AsyncClient` instances get d402 payment hooks
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
def _apply_d402_patch(self):
|
|
66
|
+
original_init = httpx.AsyncClient.__init__
|
|
67
|
+
|
|
68
|
+
def patched_init(client_self, *args, **kwargs):
|
|
69
|
+
# Call original init first
|
|
70
|
+
original_init(client_self, *args, **kwargs)
|
|
71
|
+
|
|
72
|
+
# Add d402 payment hooks to the client
|
|
73
|
+
hooks = d402_payment_hooks(d402_account, max_value=d402_max_value)
|
|
74
|
+
client_self.event_hooks = hooks # or merge with existing
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
### 2. Context Manager Entry
|
|
78
|
+
|
|
79
|
+
When entering the adapter context:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
with adapter as tools:
|
|
83
|
+
# Tools are now available
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
**What happens:**
|
|
87
|
+
1. `TraiaMCPAdapter.__enter__()` is called
|
|
88
|
+
2. This calls `super().__enter__()` which calls `MCPServerAdapter.__enter__()`
|
|
89
|
+
3. `MCPServerAdapter` creates an `httpx.AsyncClient` instance
|
|
90
|
+
4. **Because of our monkey-patch, this client automatically gets d402 hooks**
|
|
91
|
+
5. `MCPServerAdapter` makes an initial request to list tools
|
|
92
|
+
6. Returns list of `BaseTool` instances
|
|
93
|
+
|
|
94
|
+
### 3. Tool Execution Phase
|
|
95
|
+
|
|
96
|
+
When a CrewAI agent calls an MCP tool:
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
result = tool._run(query="test")
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**What happens:**
|
|
103
|
+
1. The `BaseTool` (created by `MCPServerAdapter`) executes
|
|
104
|
+
2. Internally, it uses the `httpx.AsyncClient` that was created in step 2
|
|
105
|
+
3. Makes an MCP protocol request: `POST /mcp` with JSON-RPC:
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"jsonrpc": "2.0",
|
|
109
|
+
"id": 1,
|
|
110
|
+
"method": "tools/call",
|
|
111
|
+
"params": {
|
|
112
|
+
"name": "get_prompt",
|
|
113
|
+
"arguments": {"query": "test"}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
### 4. First Request (No Payment)
|
|
119
|
+
|
|
120
|
+
```
|
|
121
|
+
Client → Server: POST /mcp (no X-Payment header)
|
|
122
|
+
Server → Client: HTTP 402 Payment Required
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Server Response:**
|
|
126
|
+
```json
|
|
127
|
+
{
|
|
128
|
+
"jsonrpc": "2.0",
|
|
129
|
+
"id": 1,
|
|
130
|
+
"error": {
|
|
131
|
+
"code": 402,
|
|
132
|
+
"message": "Payment required",
|
|
133
|
+
"data": {
|
|
134
|
+
"d402Version": 1,
|
|
135
|
+
"accepts": [
|
|
136
|
+
{
|
|
137
|
+
"scheme": "exact",
|
|
138
|
+
"network": "base-sepolia",
|
|
139
|
+
"maxAmountRequired": "1000",
|
|
140
|
+
"payTo": "0x...",
|
|
141
|
+
"asset": "0x036CbD53842c5426634e7929541eC2318f3dCF7e",
|
|
142
|
+
...
|
|
143
|
+
}
|
|
144
|
+
]
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### 5. d402 Hook Intercepts 402 Response
|
|
151
|
+
|
|
152
|
+
The `HttpxHooks.on_response()` method is automatically called:
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
async def on_response(self, response: Response) -> Response:
|
|
156
|
+
if response.status_code != 402:
|
|
157
|
+
return response # Not a 402, pass through
|
|
158
|
+
|
|
159
|
+
# Parse payment requirements
|
|
160
|
+
data = response.json()
|
|
161
|
+
payment_response = d402PaymentRequiredResponse(**data)
|
|
162
|
+
|
|
163
|
+
# Select payment requirements (matches token/network)
|
|
164
|
+
selected_requirements = self.client.select_payment_requirements(
|
|
165
|
+
payment_response.accepts
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
# Create signed payment header
|
|
169
|
+
payment_header = self.client.create_payment_header(
|
|
170
|
+
selected_requirements, payment_response.d402_version
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
# Retry request with payment
|
|
174
|
+
request = response.request
|
|
175
|
+
request.headers["X-Payment"] = payment_header
|
|
176
|
+
|
|
177
|
+
# Retry the request
|
|
178
|
+
async with AsyncClient() as client:
|
|
179
|
+
retry_response = await client.send(request)
|
|
180
|
+
return retry_response
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
**What happens:**
|
|
184
|
+
1. Hook detects HTTP 402 status
|
|
185
|
+
2. Parses payment requirements from response
|
|
186
|
+
3. Selects appropriate payment option (matches token/network)
|
|
187
|
+
4. Creates EIP-3009 signed payment authorization using client's account
|
|
188
|
+
5. Base64-encodes the payment header
|
|
189
|
+
6. Retries the original request with `X-Payment` header
|
|
190
|
+
|
|
191
|
+
### 6. Payment Header Creation
|
|
192
|
+
|
|
193
|
+
The `d402Client.create_payment_header()` method:
|
|
194
|
+
|
|
195
|
+
```python
|
|
196
|
+
def create_payment_header(self, payment_requirements, d402_version):
|
|
197
|
+
# Creates unsigned header structure
|
|
198
|
+
unsigned_header = {
|
|
199
|
+
"d402Version": d402_version,
|
|
200
|
+
"scheme": payment_requirements.scheme,
|
|
201
|
+
"network": payment_requirements.network,
|
|
202
|
+
"payload": {
|
|
203
|
+
"signature": None,
|
|
204
|
+
"authorization": {
|
|
205
|
+
"from": self.account.address, # CLIENT's address
|
|
206
|
+
"to": payment_requirements.pay_to, # SERVER's address
|
|
207
|
+
"value": payment_requirements.max_amount_required,
|
|
208
|
+
"validAfter": timestamp - 60,
|
|
209
|
+
"validBefore": timestamp + timeout,
|
|
210
|
+
"nonce": random_nonce(),
|
|
211
|
+
},
|
|
212
|
+
},
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
# Signs with EIP-3009 transferWithAuthorization
|
|
216
|
+
signed_header = sign_payment_header(
|
|
217
|
+
self.account, # CLIENT signs
|
|
218
|
+
payment_requirements,
|
|
219
|
+
unsigned_header
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
return base64_encode(signed_header)
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
**Key Points:**
|
|
226
|
+
- **CLIENT signs**: The payment is signed by the client's account (`d402_account`)
|
|
227
|
+
- **SERVER receives**: Payment is sent to server's address (`pay_to`)
|
|
228
|
+
- **EIP-3009**: Uses transferWithAuthorization (gasless payment)
|
|
229
|
+
- **Base64 encoded**: Payment header is base64-encoded for HTTP header
|
|
230
|
+
|
|
231
|
+
### 7. Retry Request (With Payment)
|
|
232
|
+
|
|
233
|
+
```
|
|
234
|
+
Client → Server: POST /mcp (with X-Payment header)
|
|
235
|
+
Server → Client: HTTP 200 OK (with result)
|
|
236
|
+
```
|
|
237
|
+
|
|
238
|
+
**Request Headers:**
|
|
239
|
+
```
|
|
240
|
+
X-Payment: <base64-encoded-signed-payment>
|
|
241
|
+
Content-Type: application/json
|
|
242
|
+
```
|
|
243
|
+
|
|
244
|
+
**Server Processing:**
|
|
245
|
+
1. MCP server's `D402MCPMiddleware` extracts `X-Payment` header
|
|
246
|
+
2. Decodes and validates the payment signature
|
|
247
|
+
3. Verifies payment matches endpoint requirements
|
|
248
|
+
4. Processes the tool call
|
|
249
|
+
5. Returns result
|
|
250
|
+
|
|
251
|
+
**Server Response:**
|
|
252
|
+
```json
|
|
253
|
+
{
|
|
254
|
+
"jsonrpc": "2.0",
|
|
255
|
+
"id": 1,
|
|
256
|
+
"result": {
|
|
257
|
+
"content": [
|
|
258
|
+
{
|
|
259
|
+
"type": "text",
|
|
260
|
+
"text": "Tool result here..."
|
|
261
|
+
}
|
|
262
|
+
]
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
```
|
|
266
|
+
|
|
267
|
+
## Key Components
|
|
268
|
+
|
|
269
|
+
### 1. Monkey-Patching Strategy
|
|
270
|
+
|
|
271
|
+
**Why monkey-patch?**
|
|
272
|
+
- `MCPServerAdapter` is from `crewai_tools` - we don't control its implementation
|
|
273
|
+
- It creates `httpx.AsyncClient` instances internally
|
|
274
|
+
- We need to inject d402 hooks into those clients
|
|
275
|
+
- Monkey-patching `__init__` ensures ALL httpx clients get hooks
|
|
276
|
+
|
|
277
|
+
**Limitations:**
|
|
278
|
+
- Global patch affects ALL httpx clients (not just MCP)
|
|
279
|
+
- Must restore original `__init__` in `__exit__`
|
|
280
|
+
- Thread-safety: Only one adapter should be active at a time
|
|
281
|
+
|
|
282
|
+
### 2. Event Hooks
|
|
283
|
+
|
|
284
|
+
httpx event hooks are called automatically:
|
|
285
|
+
- `on_request`: Before request is sent (currently no-op)
|
|
286
|
+
- `on_response`: After response is received (handles 402)
|
|
287
|
+
|
|
288
|
+
### 3. Payment Flow
|
|
289
|
+
|
|
290
|
+
```
|
|
291
|
+
1. Tool call → httpx request
|
|
292
|
+
2. Server returns 402
|
|
293
|
+
3. Hook intercepts 402
|
|
294
|
+
4. Creates signed payment
|
|
295
|
+
5. Retries request with X-Payment header
|
|
296
|
+
6. Server processes and returns result
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
## Example: Complete Flow
|
|
300
|
+
|
|
301
|
+
```python
|
|
302
|
+
from eth_account import Account
|
|
303
|
+
from traia_iatp.mcp.mcp_agent_template import run_with_mcp_tools, MCPServerInfo
|
|
304
|
+
from crewai import Agent, Task
|
|
305
|
+
|
|
306
|
+
# 1. Setup
|
|
307
|
+
account = Account.from_key("0x...") # CLIENT's account
|
|
308
|
+
mcp_server = MCPServerInfo(
|
|
309
|
+
id="canza-001",
|
|
310
|
+
name="canza-mcp",
|
|
311
|
+
url="http://localhost:8000/mcp",
|
|
312
|
+
...
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# 2. Create agent and task
|
|
316
|
+
agent = MCPAgentBuilder.create_agent(...)
|
|
317
|
+
task = Task(description="Call get_prompt", agent=agent)
|
|
318
|
+
|
|
319
|
+
# 3. Run with d402 payment
|
|
320
|
+
result = run_with_mcp_tools(
|
|
321
|
+
tasks=[task],
|
|
322
|
+
mcp_server=mcp_server,
|
|
323
|
+
d402_account=account, # CLIENT's account for signing
|
|
324
|
+
d402_max_value=1_000_000 # Safety limit
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
# What happens internally:
|
|
328
|
+
# 1. TraiaMCPAdapter patches httpx.AsyncClient.__init__
|
|
329
|
+
# 2. MCPServerAdapter creates httpx client (gets hooks automatically)
|
|
330
|
+
# 3. Agent calls tool → httpx makes request
|
|
331
|
+
# 4. Server returns 402 → hook intercepts
|
|
332
|
+
# 5. Hook creates payment → retries with X-Payment header
|
|
333
|
+
# 6. Server processes → returns result
|
|
334
|
+
# 7. Result returned to agent
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
## Important Notes
|
|
338
|
+
|
|
339
|
+
1. **Client Account**: The `d402_account` is the CLIENT's account that signs payments
|
|
340
|
+
2. **Server Address**: The server's payment address is configured server-side (SERVER_ADDRESS env var)
|
|
341
|
+
3. **Automatic**: The entire payment flow is automatic - agents don't need to handle 402s
|
|
342
|
+
4. **Transparent**: CrewAI agents see normal tool execution, payment happens behind the scenes
|
|
343
|
+
5. **Per-Request**: Each tool call that requires payment goes through this flow
|
|
344
|
+
|
|
345
|
+
## Troubleshooting
|
|
346
|
+
|
|
347
|
+
**Payment not working?**
|
|
348
|
+
- Check that monkey-patch is applied (look for debug logs)
|
|
349
|
+
- Verify httpx client has event_hooks set
|
|
350
|
+
- Check that 402 response is being intercepted
|
|
351
|
+
- Verify payment signature is valid
|
|
352
|
+
|
|
353
|
+
**Hooks not applied?**
|
|
354
|
+
- Ensure `d402_account` is provided
|
|
355
|
+
- Check that `D402_AVAILABLE` is True
|
|
356
|
+
- Verify httpx.AsyncClient is being created AFTER patch is applied
|
|
357
|
+
|
traia_iatp/mcp/__init__.py
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from .client import MCPClient
|
|
4
4
|
from .mcp_agent_template import MCPServerConfig, MCPAgentBuilder, run_with_mcp_tools, MCPServerInfo
|
|
5
5
|
from .traia_mcp_adapter import TraiaMCPAdapter, create_mcp_adapter
|
|
6
|
+
from .d402_mcp_tool_adapter import D402MCPToolAdapter, create_d402_mcp_adapter
|
|
6
7
|
|
|
7
8
|
__all__ = [
|
|
8
9
|
"MCPClient",
|
|
@@ -12,4 +13,6 @@ __all__ = [
|
|
|
12
13
|
"MCPServerInfo",
|
|
13
14
|
"TraiaMCPAdapter",
|
|
14
15
|
"create_mcp_adapter",
|
|
16
|
+
"D402MCPToolAdapter",
|
|
17
|
+
"create_d402_mcp_adapter",
|
|
15
18
|
]
|