traia-iatp 0.1.2__py3-none-any.whl → 0.1.67__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. traia_iatp/__init__.py +105 -8
  2. traia_iatp/cli/main.py +85 -1
  3. traia_iatp/client/__init__.py +28 -3
  4. traia_iatp/client/crewai_a2a_tools.py +32 -12
  5. traia_iatp/client/d402_a2a_client.py +348 -0
  6. traia_iatp/contracts/__init__.py +11 -0
  7. traia_iatp/contracts/data/abis/contract-abis-localhost.json +4091 -0
  8. traia_iatp/contracts/data/abis/contract-abis-sepolia.json +4890 -0
  9. traia_iatp/contracts/data/addresses/contract-addresses.json +17 -0
  10. traia_iatp/contracts/data/addresses/contract-proxies.json +12 -0
  11. traia_iatp/contracts/iatp_contracts_config.py +263 -0
  12. traia_iatp/contracts/wallet_creator.py +369 -0
  13. traia_iatp/core/models.py +17 -3
  14. traia_iatp/d402/MIDDLEWARE_ARCHITECTURE.md +205 -0
  15. traia_iatp/d402/PRICE_BUILDER_USAGE.md +249 -0
  16. traia_iatp/d402/README.md +489 -0
  17. traia_iatp/d402/__init__.py +54 -0
  18. traia_iatp/d402/asgi_wrapper.py +469 -0
  19. traia_iatp/d402/chains.py +102 -0
  20. traia_iatp/d402/client.py +150 -0
  21. traia_iatp/d402/clients/__init__.py +7 -0
  22. traia_iatp/d402/clients/base.py +218 -0
  23. traia_iatp/d402/clients/httpx.py +266 -0
  24. traia_iatp/d402/common.py +114 -0
  25. traia_iatp/d402/encoding.py +28 -0
  26. traia_iatp/d402/examples/client_example.py +197 -0
  27. traia_iatp/d402/examples/server_example.py +171 -0
  28. traia_iatp/d402/facilitator.py +481 -0
  29. traia_iatp/d402/mcp_middleware.py +296 -0
  30. traia_iatp/d402/models.py +116 -0
  31. traia_iatp/d402/networks.py +98 -0
  32. traia_iatp/d402/path.py +43 -0
  33. traia_iatp/d402/payment_introspection.py +126 -0
  34. traia_iatp/d402/payment_signing.py +183 -0
  35. traia_iatp/d402/price_builder.py +164 -0
  36. traia_iatp/d402/servers/__init__.py +61 -0
  37. traia_iatp/d402/servers/base.py +139 -0
  38. traia_iatp/d402/servers/example_general_server.py +140 -0
  39. traia_iatp/d402/servers/fastapi.py +253 -0
  40. traia_iatp/d402/servers/mcp.py +304 -0
  41. traia_iatp/d402/servers/starlette.py +878 -0
  42. traia_iatp/d402/starlette_middleware.py +529 -0
  43. traia_iatp/d402/types.py +300 -0
  44. traia_iatp/mcp/D402_MCP_ADAPTER_FLOW.md +357 -0
  45. traia_iatp/mcp/__init__.py +3 -0
  46. traia_iatp/mcp/d402_mcp_tool_adapter.py +526 -0
  47. traia_iatp/mcp/mcp_agent_template.py +78 -13
  48. traia_iatp/mcp/templates/Dockerfile.j2 +27 -4
  49. traia_iatp/mcp/templates/README.md.j2 +104 -8
  50. traia_iatp/mcp/templates/cursor-rules.md.j2 +194 -0
  51. traia_iatp/mcp/templates/deployment_params.json.j2 +1 -2
  52. traia_iatp/mcp/templates/docker-compose.yml.j2 +13 -3
  53. traia_iatp/mcp/templates/env.example.j2 +60 -0
  54. traia_iatp/mcp/templates/mcp_health_check.py.j2 +2 -2
  55. traia_iatp/mcp/templates/pyproject.toml.j2 +11 -5
  56. traia_iatp/mcp/templates/pyrightconfig.json.j2 +22 -0
  57. traia_iatp/mcp/templates/run_local_docker.sh.j2 +320 -10
  58. traia_iatp/mcp/templates/server.py.j2 +174 -197
  59. traia_iatp/mcp/traia_mcp_adapter.py +182 -20
  60. traia_iatp/registry/__init__.py +47 -12
  61. traia_iatp/registry/atlas_search_indexes.json +108 -54
  62. traia_iatp/registry/iatp_search_api.py +169 -39
  63. traia_iatp/registry/mongodb_registry.py +241 -69
  64. traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +1 -1
  65. traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +8 -8
  66. traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +1 -1
  67. traia_iatp/registry/readmes/README.md +3 -3
  68. traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +6 -6
  69. traia_iatp/scripts/__init__.py +2 -0
  70. traia_iatp/scripts/create_wallet.py +244 -0
  71. traia_iatp/server/a2a_server.py +22 -7
  72. traia_iatp/server/iatp_server_template_generator.py +23 -0
  73. traia_iatp/server/templates/.dockerignore.j2 +48 -0
  74. traia_iatp/server/templates/Dockerfile.j2 +23 -1
  75. traia_iatp/server/templates/README.md +2 -2
  76. traia_iatp/server/templates/README.md.j2 +5 -5
  77. traia_iatp/server/templates/__main__.py.j2 +374 -66
  78. traia_iatp/server/templates/agent.py.j2 +12 -11
  79. traia_iatp/server/templates/agent_config.json.j2 +3 -3
  80. traia_iatp/server/templates/agent_executor.py.j2 +45 -27
  81. traia_iatp/server/templates/env.example.j2 +32 -4
  82. traia_iatp/server/templates/gitignore.j2 +7 -0
  83. traia_iatp/server/templates/pyproject.toml.j2 +13 -12
  84. traia_iatp/server/templates/run_local_docker.sh.j2 +143 -11
  85. traia_iatp/server/templates/server.py.j2 +197 -10
  86. traia_iatp/special_agencies/registry_search_agency.py +1 -1
  87. traia_iatp/utils/iatp_utils.py +6 -6
  88. traia_iatp-0.1.67.dist-info/METADATA +320 -0
  89. traia_iatp-0.1.67.dist-info/RECORD +117 -0
  90. traia_iatp-0.1.2.dist-info/METADATA +0 -414
  91. traia_iatp-0.1.2.dist-info/RECORD +0 -72
  92. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/WHEEL +0 -0
  93. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/entry_points.txt +0 -0
  94. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/licenses/LICENSE +0 -0
  95. {traia_iatp-0.1.2.dist-info → traia_iatp-0.1.67.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,526 @@
1
+ #!/usr/bin/env python
2
+ """
3
+ D402 MCP Tool Adapter
4
+
5
+ A simple adapter for using d402-enabled MCP servers with CrewAI.
6
+ This adapter avoids the complexity of persistent SSE connections and background tasks.
7
+
8
+ Instead, it:
9
+ 1. Lists available tools from the MCP server
10
+ 2. Creates CrewAI BaseTool wrappers for each MCP tool
11
+ 3. Each tool uses httpx with d402 payment hooks for requests
12
+ 4. No persistent connections - simple request/response pattern
13
+
14
+ This is more reliable than MCPServerAdapter for d402 payment scenarios.
15
+ """
16
+
17
+ import json
18
+ import logging
19
+ from typing import Any, Dict, List, Optional, Type
20
+ from pydantic import BaseModel, Field, create_model
21
+ from crewai.tools import BaseTool
22
+ import httpx
23
+
24
+ from traia_iatp.d402.clients.httpx import d402HttpxClient
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ def json_schema_to_pydantic_model(json_schema: Dict[str, Any], model_name: str = "ToolInput") -> type[BaseModel]:
30
+ """
31
+ Convert JSON schema to Pydantic BaseModel for CrewAI args_schema.
32
+
33
+ CrewAI BaseTool expects args_schema to be a Pydantic BaseModel, not a Dict.
34
+ Without this conversion, CrewAI cannot properly extract and validate tool arguments,
35
+ causing arguments to be lost (empty dict sent to MCP server).
36
+
37
+ Args:
38
+ json_schema: JSON schema dictionary (OpenAPI format)
39
+ model_name: Name for the generated Pydantic model
40
+
41
+ Returns:
42
+ Pydantic BaseModel class
43
+ """
44
+ if not json_schema or "properties" not in json_schema:
45
+ # Return empty model if no schema
46
+ return create_model(model_name, __base__=BaseModel)
47
+
48
+ properties = json_schema.get("properties", {})
49
+ required = json_schema.get("required", [])
50
+
51
+ # Map JSON schema types to Python types
52
+ type_mapping = {
53
+ "string": str,
54
+ "integer": int,
55
+ "number": float,
56
+ "boolean": bool,
57
+ "array": list,
58
+ "object": dict,
59
+ }
60
+
61
+ # Build field definitions
62
+ field_definitions = {}
63
+ for prop_name, prop_schema in properties.items():
64
+ prop_type = prop_schema.get("type", "string")
65
+ python_type = type_mapping.get(prop_type, str)
66
+
67
+ # Get description for Field
68
+ description = prop_schema.get("description", "")
69
+
70
+ # Check if required
71
+ is_required = prop_name in required
72
+
73
+ # Create Field with description
74
+ if is_required:
75
+ field_definitions[prop_name] = (python_type, Field(description=description))
76
+ else:
77
+ # Optional field with default
78
+ default_value = prop_schema.get("default", None)
79
+ field_definitions[prop_name] = (Optional[python_type], Field(default=default_value, description=description))
80
+
81
+ # Create Pydantic model
82
+ if field_definitions:
83
+ return create_model(model_name, **field_definitions)
84
+ else:
85
+ return create_model(model_name, __base__=BaseModel)
86
+
87
+
88
+ class D402MCPTool(BaseTool):
89
+ """
90
+ CrewAI tool wrapper for a single MCP tool with d402 payment support.
91
+
92
+ Each instance represents one MCP tool and handles d402 payments automatically.
93
+ """
94
+
95
+ name: str = "mcp_tool"
96
+ description: str = "MCP tool with d402 payment"
97
+ mcp_server_url: str = "" # Full MCP server URL (will be cleaned of trailing slash)
98
+ mcp_tool_name: str = ""
99
+ mcp_session_id: str = ""
100
+ d402_operator_account: Optional[Any] = None # Operator account (EOA) for signing
101
+ d402_wallet_address: Optional[str] = None # IATPWallet contract address
102
+ input_schema: Dict[str, Any] = Field(default_factory=dict)
103
+
104
+ class Config:
105
+ arbitrary_types_allowed = True
106
+
107
+ def __init__(self, **data):
108
+ """Initialize with custom data."""
109
+ super().__init__(**data)
110
+ # Ensure args_schema is preserved if provided
111
+ # BaseTool.__init__ might not preserve it properly, so set it explicitly
112
+ if "args_schema" in data and data["args_schema"] is not None:
113
+ self.args_schema = data["args_schema"]
114
+
115
+ def _set_args_schema(self):
116
+ """
117
+ Override to prevent BaseTool from overriding our args_schema.
118
+
119
+ If args_schema is already set (from input_schema conversion), keep it.
120
+ Otherwise, let BaseTool infer it from _run signature.
121
+ """
122
+ # Only set args_schema if it's not already set (i.e., still the placeholder)
123
+ from crewai.tools.base_tool import BaseTool as BaseToolClass
124
+ if self.args_schema == BaseToolClass._ArgsSchemaPlaceholder:
125
+ # Call parent to infer from _run signature
126
+ super()._set_args_schema()
127
+ # Otherwise, keep our custom args_schema from input_schema conversion
128
+
129
+ def _run(self, **kwargs) -> Dict[str, Any]:
130
+ """
131
+ Synchronous wrapper that runs async _arun.
132
+
133
+ This method receives arguments from CrewAI. If kwargs is empty, it means
134
+ CrewAI didn't extract arguments properly from the LLM's tool call.
135
+
136
+ IMPORTANT: This method signature must match what CrewAI expects.
137
+ CrewAI will call this with keyword arguments based on args_schema.
138
+ """
139
+ # Debug: Log what arguments we received
140
+ if not kwargs:
141
+ logger.error(f"āŒ D402MCPTool._run called with EMPTY kwargs for {self.name}")
142
+ logger.error(f" This means CrewAI/LLM didn't provide arguments!")
143
+ logger.error(f" args_schema: {self.args_schema}")
144
+ if hasattr(self.args_schema, 'model_fields'):
145
+ required_fields = [name for name, field in self.args_schema.model_fields.items()
146
+ if field.is_required()]
147
+ logger.error(f" Required fields: {required_fields}")
148
+ logger.error(f" All fields: {list(self.args_schema.model_fields.keys())}")
149
+ logger.error(f" Tool description: {self.description[:200]}...")
150
+ # Don't fail here - let the MCP server validate and return proper error
151
+ else:
152
+ logger.info(f"āœ… D402MCPTool._run called with kwargs: {kwargs}")
153
+
154
+ import asyncio
155
+ try:
156
+ # Run the async method in a new event loop
157
+ return asyncio.run(self._arun(**kwargs))
158
+ except RuntimeError:
159
+ # If we're already in an event loop, use run_until_complete
160
+ loop = asyncio.get_event_loop()
161
+ return loop.run_until_complete(self._arun(**kwargs))
162
+
163
+ async def _arun(self, **kwargs) -> Dict[str, Any]:
164
+ """
165
+ Execute the MCP tool with d402 payment support.
166
+
167
+ This method:
168
+ 1. Creates d402HttpxClient with built-in payment handling
169
+ 2. Makes MCP tools/call request
170
+ 3. d402 hooks automatically handle any 402 responses
171
+ 4. Returns the result
172
+ """
173
+
174
+ # Remove trailing slash to avoid redirects
175
+ mcp_url = self.mcp_server_url.rstrip('/')
176
+
177
+ # Create d402HttpxClient with built-in payment hooks
178
+ # This ensures the hooks have a reference to the client for retries
179
+ async with d402HttpxClient(
180
+ operator_account=self.d402_operator_account,
181
+ wallet_address=self.d402_wallet_address,
182
+ timeout=60.0,
183
+ http2=False # Disable HTTP/2 for compatibility
184
+ ) as client:
185
+
186
+ # Create MCP tools/call request
187
+ # kwargs contains the actual arguments from CrewAI (e.g., {"q": "test"})
188
+ mcp_request = {
189
+ "jsonrpc": "2.0",
190
+ "id": 1,
191
+ "method": "tools/call",
192
+ "params": {
193
+ "name": self.mcp_tool_name,
194
+ "arguments": kwargs # Pass arguments directly from CrewAI
195
+ }
196
+ }
197
+
198
+ try:
199
+ # Make request - d402 hooks will handle any 402 responses automatically
200
+ response = await client.post(
201
+ mcp_url, # Use full URL without trailing slash
202
+ json=mcp_request,
203
+ headers={
204
+ "Content-Type": "application/json",
205
+ "Accept": "application/json, text/event-stream",
206
+ "mcp-session-id": self.mcp_session_id
207
+ },
208
+ follow_redirects=False # Don't follow redirects - causes issues
209
+ )
210
+
211
+ # Read response content
212
+ content = await response.aread()
213
+ content_str = content.decode() if content else ""
214
+
215
+ # Parse SSE format if present
216
+ if "data:" in content_str:
217
+ sse_lines = content_str.strip().split('\n')
218
+ for line in sse_lines:
219
+ if line.startswith("data:"):
220
+ content_str = line[5:].strip()
221
+ break
222
+
223
+ # Parse JSON response
224
+ response_data = json.loads(content_str)
225
+
226
+ # Extract result
227
+ if "result" in response_data:
228
+ result = response_data["result"]
229
+ # Return structured content if available, otherwise full result
230
+ if isinstance(result, dict) and "structuredContent" in result:
231
+ return result["structuredContent"].get("result", result)
232
+ return result
233
+ elif "error" in response_data:
234
+ return {"error": response_data["error"]}
235
+ else:
236
+ return response_data
237
+
238
+ except Exception as e:
239
+ logger.error(f"Error calling MCP tool {self.mcp_tool_name}: {e}")
240
+ return {"error": str(e)}
241
+
242
+
243
+ class D402MCPToolAdapter:
244
+ """
245
+ Adapter for using d402-enabled MCP servers with CrewAI.
246
+
247
+ This adapter is simpler and more reliable than MCPServerAdapter for d402:
248
+ - No persistent SSE connections
249
+ - No background tasks
250
+ - Direct request/response with d402 hooks
251
+
252
+ Usage:
253
+ ```python
254
+ from eth_account import Account
255
+ from traia_iatp.mcp import D402MCPToolAdapter
256
+
257
+ account = Account.from_key("0x...")
258
+ adapter = D402MCPToolAdapter(
259
+ url="http://localhost:8000/mcp",
260
+ account=account
261
+ )
262
+
263
+ with adapter as tools:
264
+ agent = Agent(
265
+ role="Analyst",
266
+ goal="Analyze data",
267
+ tools=tools
268
+ )
269
+ ```
270
+ """
271
+
272
+ def __init__(
273
+ self,
274
+ url: str,
275
+ account: Any, # Operator account (EOA) for signing
276
+ wallet_address: str = None, # IATPWallet contract address
277
+ max_value: Optional[int] = None
278
+ ):
279
+ """
280
+ Initialize the d402 MCP adapter.
281
+
282
+ Args:
283
+ url: MCP server URL (e.g., "http://localhost:8000/mcp")
284
+ account: Operator account (EOA) with private key for signing payments
285
+ wallet_address: IATPWallet contract address (if None, uses account.address for testing)
286
+ max_value: Optional maximum payment value in base units
287
+ """
288
+ self.url = url
289
+ self.account = account # Operator EOA
290
+ self.wallet_address = wallet_address or account.address # IATPWallet or EOA for testing
291
+ self.max_value = max_value
292
+ self.tools: List[BaseTool] = []
293
+ self.session_id: Optional[str] = None
294
+
295
+ async def _initialize_session(self) -> str:
296
+ """Initialize MCP session and return session ID."""
297
+ # Remove trailing slash to avoid redirects
298
+ mcp_url = self.url.rstrip('/')
299
+
300
+ async with httpx.AsyncClient(timeout=30.0, http2=False) as client:
301
+ init_request = {
302
+ "jsonrpc": "2.0",
303
+ "id": "init",
304
+ "method": "initialize",
305
+ "params": {
306
+ "protocolVersion": "2024-11-05",
307
+ "capabilities": {},
308
+ "clientInfo": {"name": "d402-crewai-client", "version": "1.0"}
309
+ }
310
+ }
311
+
312
+ response = await client.post(
313
+ mcp_url, # Use full URL without trailing slash
314
+ json=init_request,
315
+ headers={
316
+ "Content-Type": "application/json",
317
+ "Accept": "application/json, text/event-stream"
318
+ },
319
+ follow_redirects=False # Don't follow redirects - causes issues
320
+ )
321
+
322
+ session_id = response.headers.get("mcp-session-id")
323
+ if not session_id:
324
+ raise RuntimeError("Failed to establish MCP session")
325
+
326
+ return session_id
327
+
328
+ async def _list_tools(self) -> List[Dict[str, Any]]:
329
+ """List available tools from the MCP server."""
330
+ # Remove trailing slash to avoid redirects
331
+ mcp_url = self.url.rstrip('/')
332
+
333
+ async with httpx.AsyncClient(timeout=30.0, http2=False) as client:
334
+ mcp_request = {
335
+ "jsonrpc": "2.0",
336
+ "id": 1,
337
+ "method": "tools/list",
338
+ "params": {}
339
+ }
340
+
341
+ response = await client.post(
342
+ mcp_url, # Use full URL without trailing slash
343
+ json=mcp_request,
344
+ headers={
345
+ "Content-Type": "application/json",
346
+ "Accept": "application/json, text/event-stream",
347
+ "mcp-session-id": self.session_id
348
+ },
349
+ follow_redirects=False # Don't follow redirects - causes issues
350
+ )
351
+
352
+ # Read response content (SSE format)
353
+ content = await response.aread()
354
+ content_str = content.decode() if content else ""
355
+
356
+ # Parse SSE format if present
357
+ if "data:" in content_str:
358
+ sse_lines = content_str.strip().split('\n')
359
+ for line in sse_lines:
360
+ if line.startswith("data:"):
361
+ content_str = line[5:].strip()
362
+ break
363
+
364
+ response_data = json.loads(content_str)
365
+
366
+ if "result" in response_data and "tools" in response_data["result"]:
367
+ return response_data["result"]["tools"]
368
+ return []
369
+
370
+ def __enter__(self) -> List[BaseTool]:
371
+ """
372
+ Enter context manager - set up session and create tool wrappers.
373
+
374
+ Returns:
375
+ List of CrewAI BaseTool objects for each MCP tool
376
+ """
377
+ import asyncio
378
+
379
+ logger.info("="*80)
380
+ logger.info("šŸš€ D402MCPToolAdapter.__enter__() starting...")
381
+ logger.info("="*80)
382
+
383
+ # Handle nested event loop scenario (e.g., when CrewAI already has an event loop running)
384
+ logger.info("šŸ” Step 1: Checking for existing event loop...")
385
+ try:
386
+ # Try to get existing event loop
387
+ loop = asyncio.get_running_loop()
388
+ logger.info(f" āœ… Found existing event loop: {loop}")
389
+ # We have a running loop - use nest_asyncio to allow nested asyncio.run()
390
+ logger.info(" Attempting to apply nest_asyncio...")
391
+ try:
392
+ import nest_asyncio
393
+ logger.info(" nest_asyncio imported successfully")
394
+ nest_asyncio.apply()
395
+ logger.info(" šŸ“” Applied nest_asyncio for nested event loop support")
396
+ except ImportError as e:
397
+ logger.warning(f" āš ļø nest_asyncio not available: {e}")
398
+ logger.warning(" This WILL cause hanging with nested event loops")
399
+ except RuntimeError as e:
400
+ # No event loop running - asyncio.run() will work fine
401
+ logger.info(f" āœ… No existing event loop detected (RuntimeError: {e})")
402
+ logger.info(" asyncio.run() will work without nest_asyncio")
403
+
404
+ # Initialize session
405
+ logger.info("")
406
+ logger.info("šŸ” Step 2: Initializing MCP session...")
407
+ logger.info(f" URL: {self.url}")
408
+ logger.info(" About to call: asyncio.run(self._initialize_session())")
409
+ try:
410
+ self.session_id = asyncio.run(self._initialize_session())
411
+ logger.info(f" āœ… Session established: {self.session_id}")
412
+ except Exception as e:
413
+ logger.error(f" āŒ Session initialization failed: {e}")
414
+ raise
415
+
416
+ # List available tools
417
+ logger.info("")
418
+ logger.info("šŸ” Step 3: Listing available tools...")
419
+ logger.info(" About to call: asyncio.run(self._list_tools())")
420
+ try:
421
+ mcp_tools = asyncio.run(self._list_tools())
422
+ logger.info(f" āœ… Found {len(mcp_tools)} tools")
423
+ except Exception as e:
424
+ logger.error(f" āŒ Tool listing failed: {e}")
425
+ raise
426
+
427
+ logger.info("")
428
+ logger.info("šŸ” Step 4: Creating CrewAI tool wrappers...")
429
+ logger.info(f" Creating wrappers for {len(mcp_tools)} tools...")
430
+
431
+ # Create CrewAI tool wrappers for each MCP tool
432
+ self.tools = []
433
+ for mcp_tool in mcp_tools:
434
+ tool_name = mcp_tool.get("name", "unknown")
435
+ tool_description = mcp_tool.get("description", f"MCP tool: {tool_name}")
436
+ input_schema = mcp_tool.get("inputSchema", {})
437
+
438
+ # Convert JSON schema to Pydantic model for CrewAI args_schema
439
+ # This is critical: CrewAI BaseTool expects args_schema to be a Pydantic BaseModel,
440
+ # not a Dict. Without this conversion, CrewAI cannot properly extract arguments,
441
+ # causing arguments to be lost (empty dict sent to MCP server).
442
+ model_name = f"{tool_name}Input"
443
+ args_schema = json_schema_to_pydantic_model(input_schema, model_name)
444
+
445
+ # Create tool instance with all necessary data
446
+ tool_instance = D402MCPTool(
447
+ name=tool_name,
448
+ description=tool_description,
449
+ mcp_server_url=self.url,
450
+ mcp_tool_name=tool_name,
451
+ mcp_session_id=self.session_id,
452
+ d402_operator_account=self.account, # Operator EOA for signing
453
+ d402_wallet_address=self.wallet_address, # IATPWallet contract (or EOA for testing)
454
+ input_schema=input_schema, # Keep for reference, but args_schema is what CrewAI uses
455
+ args_schema=args_schema # Set the Pydantic model for CrewAI argument extraction
456
+ )
457
+ # Note: args_schema is preserved by __init__ override, no need to set again
458
+ # BaseTool will automatically enhance the description with argument info
459
+
460
+ self.tools.append(tool_instance)
461
+
462
+ logger.info(f"āœ… Created {len(self.tools)} CrewAI tool wrappers")
463
+ return self.tools
464
+
465
+ def __exit__(self, exc_type, exc_val, exc_tb):
466
+ """Exit context manager - cleanup."""
467
+ logger.info("šŸ”Œ Closing D402MCPToolAdapter")
468
+ return False
469
+
470
+
471
+ def create_d402_mcp_adapter(
472
+ url: str,
473
+ account: Any, # Operator account (EOA) for signing
474
+ wallet_address: str = None, # IATPWallet contract address
475
+ max_value: Optional[int] = None
476
+ ) -> D402MCPToolAdapter:
477
+ """
478
+ Create a d402 MCP adapter for CrewAI.
479
+
480
+ Args:
481
+ url: MCP server URL
482
+ account: Operator account (EOA) for signing payments
483
+ wallet_address: IATPWallet contract address (if None, uses account.address for testing)
484
+ max_value: Optional maximum payment value in base units
485
+
486
+ Returns:
487
+ D402MCPToolAdapter instance
488
+
489
+ Example:
490
+ ```python
491
+ from eth_account import Account
492
+ from traia_iatp.mcp import create_d402_mcp_adapter
493
+
494
+ # For testing (uses mock wallet address)
495
+ operator_account = Account.from_key("0x...")
496
+ mock_wallet = "0x70997970C51812dc3A010C7d01b50e0d17dc79C8" # Different from operator
497
+ adapter = create_d402_mcp_adapter(
498
+ url="http://localhost:8000/mcp",
499
+ account=operator_account,
500
+ wallet_address=mock_wallet,
501
+ max_value=1_000_000 # $1.00 in USDC
502
+ )
503
+
504
+ # For production (with deployed IATPWallet contract)
505
+ operator_account = Account.from_key("0x...") # Operator EOA
506
+ wallet_address = "0x..." # Deployed IATPWallet contract
507
+ adapter = create_d402_mcp_adapter(
508
+ url="http://localhost:8000/mcp",
509
+ account=operator_account,
510
+ wallet_address=wallet_address,
511
+ max_value=1_000_000
512
+ )
513
+
514
+ with adapter as tools:
515
+ agent = Agent(
516
+ role="Analyst",
517
+ goal="Analyze data",
518
+ tools=tools
519
+ )
520
+ ```
521
+ """
522
+ return D402MCPToolAdapter(url, account, wallet_address, max_value)
523
+
524
+
525
+ __all__ = ["D402MCPTool", "D402MCPToolAdapter", "create_d402_mcp_adapter"]
526
+
@@ -82,8 +82,9 @@ from pathlib import Path
82
82
  from crewai import Agent, Task, Crew, Process, LLM
83
83
  from crewai_tools import MCPServerAdapter
84
84
 
85
- # Import our custom adapter for API key support
86
- from .traia_mcp_adapter import create_mcp_adapter, create_mcp_adapter_with_auth
85
+ # Import our custom adapters for API key and d402 payment support
86
+ from .traia_mcp_adapter import create_mcp_adapter, create_mcp_adapter_with_auth, create_mcp_adapter_with_x402
87
+ from .d402_mcp_tool_adapter import create_d402_mcp_adapter
87
88
 
88
89
 
89
90
  logger = logging.getLogger(__name__)
@@ -152,7 +153,9 @@ class MCPAgentBuilder:
152
153
  verbose: bool = True,
153
154
  allow_delegation: bool = False,
154
155
  llm: LLM = None,
155
- tools_subset: List[str] = None
156
+ tools_subset: List[str] = None,
157
+ memory: bool = False,
158
+ max_iter: int = 25
156
159
  ) -> Agent:
157
160
  """
158
161
  Create a CrewAI agent for use with MCP tools.
@@ -165,6 +168,8 @@ class MCPAgentBuilder:
165
168
  allow_delegation: Whether to allow the agent to delegate tasks
166
169
  llm: The LLM instance to use (defaults to gpt-4.1 with temperature 0.7)
167
170
  tools_subset: Optional list of specific tool names to include (if None, all tools are included)
171
+ memory: Whether to enable memory for learning and context retention
172
+ max_iter: Maximum number of iterations for tool execution
168
173
 
169
174
  Returns:
170
175
  CrewAI Agent configured for MCP tools
@@ -181,7 +186,9 @@ class MCPAgentBuilder:
181
186
  backstory=backstory,
182
187
  verbose=verbose,
183
188
  allow_delegation=allow_delegation,
184
- llm=llm
189
+ llm=llm,
190
+ memory=memory, # Enable memory for context retention
191
+ max_iter=max_iter # Allow sufficient iterations to execute tools
185
192
  )
186
193
 
187
194
  # Store the tools_subset in the class dictionary
@@ -245,15 +252,21 @@ def run_with_mcp_tools(
245
252
  verbose: bool = True,
246
253
  inputs: Optional[Dict[str, Any]] = None,
247
254
  skip_health_check: bool = False,
248
- api_key: Optional[str] = None
255
+ api_key: Optional[str] = None,
256
+ d402_account: Optional[Any] = None,
257
+ d402_wallet_address: Optional[str] = None,
258
+ d402_max_value: Optional[int] = None,
259
+ d402_max_value_token: Optional[str] = None,
260
+ d402_max_value_network: Optional[str] = None
249
261
  ) -> Any:
250
262
  """
251
263
  Run tasks with agents that have access to MCP server tools.
252
264
 
253
- NOTE ON AUTHENTICATION:
254
- This function automatically detects if the MCP server requires authentication
255
- based on the server's metadata. If authentication is required, you must provide
256
- your API key using the api_key parameter.
265
+ NOTE ON AUTHENTICATION AND PAYMENT:
266
+ This function supports three modes of operation:
267
+ 1. Authenticated mode: Provide api_key if server requires authentication
268
+ 2. Payment mode: Provide d402_account (CLIENT's account) for servers using HTTP 402 payment protocol
269
+ 3. Standard mode: No authentication or payment required
257
270
 
258
271
  Args:
259
272
  tasks: List of tasks to run
@@ -264,6 +277,23 @@ def run_with_mcp_tools(
264
277
  inputs: Optional inputs for the crew
265
278
  skip_health_check: Skip server health check
266
279
  api_key: Optional API key for authenticated MCP servers
280
+ d402_account: CLIENT's operator account (EOA) with private key for signing payments.
281
+ This is the account that signs transactions on behalf of the wallet.
282
+ d402_wallet_address: CLIENT's IATPWallet contract address (holds funds).
283
+ If None, uses d402_account.address (for testing only).
284
+ In production, this must be the deployed IATPWallet contract address.
285
+ d402_max_value: Optional safety limit for maximum payment amount per request in base units.
286
+ This is a global safety check that prevents paying more than intended.
287
+ Typically, each MCP server uses one primary token, so this limit applies
288
+ to all endpoints using that token. Set it based on your most expensive
289
+ expected payment in the token's base units (e.g., for USDC with 6 decimals,
290
+ $1.00 = 1_000_000 base units).
291
+ If None, no limit is enforced (not recommended for production).
292
+ d402_max_value_token: Optional token address (e.g., "0x036CbD53842c5426634e7929541eC2318f3dCF7e" for USDC)
293
+ or token symbol (e.g., "USDC") that this max_value relates to.
294
+ Used for documentation/clarity - the actual validation is numeric only.
295
+ d402_max_value_network: Optional network name (e.g., "base-sepolia", "sepolia") that this
296
+ max_value relates to. Used for documentation/clarity.
267
297
 
268
298
  Returns:
269
299
  Result from the crew execution
@@ -280,8 +310,40 @@ def run_with_mcp_tools(
280
310
  requires_api_key = mcp_server.metadata.get("requires_api_key", False)
281
311
  api_key_header = mcp_server.metadata.get("api_key_header", "Authorization")
282
312
 
283
- # Create appropriate adapter
284
- if requires_api_key:
313
+ # Determine connection mode: d402 payment takes precedence over auth
314
+ if d402_account:
315
+ # Payment mode: use new D402MCPToolAdapter (simpler, no background tasks)
316
+ # d402_account is the CLIENT's operator account for signing payments
317
+ # d402_wallet_address is the CLIENT's IATPWallet contract (if None, uses operator address for testing)
318
+ try:
319
+ adapter = create_d402_mcp_adapter(
320
+ url=mcp_server.url,
321
+ account=d402_account,
322
+ wallet_address=d402_wallet_address,
323
+ max_value=d402_max_value
324
+ )
325
+ max_value_info = ""
326
+ if d402_max_value is not None:
327
+ max_value_info = f" (max: {d402_max_value}"
328
+ if d402_max_value_token:
329
+ max_value_info += f" {d402_max_value_token}"
330
+ if d402_max_value_network:
331
+ max_value_info += f" on {d402_max_value_network}"
332
+ max_value_info += ")"
333
+
334
+ wallet_info = d402_wallet_address or d402_account.address
335
+ print(f"\nšŸ’³ Using d402 payment protocol:")
336
+ print(f" Operator account: {d402_account.address} (signs payments)")
337
+ print(f" Wallet address: {wallet_info} ({'IATPWallet' if d402_wallet_address else 'EOA for testing'})")
338
+ if max_value_info:
339
+ print(f" Max value: {max_value_info}")
340
+ print(f" Using D402MCPToolAdapter (simple request/response, no background tasks)")
341
+ except ImportError as e:
342
+ print(f"\nāŒ Error: d402 payment hooks not available")
343
+ print("Ensure traia_iatp.d402 is installed")
344
+ sys.exit(1)
345
+ elif requires_api_key:
346
+ # Authenticated mode: use API key
285
347
  if not api_key:
286
348
  print(f"\nāš ļø WARNING: MCP server '{mcp_server.name}' requires authentication")
287
349
  print(f"Expected header: {api_key_header}")
@@ -289,6 +351,8 @@ def run_with_mcp_tools(
289
351
  print("\nTo provide authentication:")
290
352
  print("Pass your API key using the 'api_key' parameter")
291
353
  print("Example: run_with_mcp_tools(tasks, mcp_server, api_key='YOUR_API_KEY')")
354
+ print("\nAlternatively, use d402 payment protocol:")
355
+ print("Example: run_with_mcp_tools(tasks, mcp_server, d402_account=client_account)")
292
356
  sys.exit(1)
293
357
 
294
358
  # Use the provided API key directly (user provides raw key without Bearer prefix)
@@ -300,7 +364,7 @@ def run_with_mcp_tools(
300
364
  )
301
365
  print(f"\nšŸ” Using authenticated connection (header: {api_key_header})")
302
366
  else:
303
- # No authentication required
367
+ # Standard mode: no authentication or payment required
304
368
  adapter = create_mcp_adapter(url=mcp_server.url)
305
369
  print("\nšŸ”“ Using standard connection (no authentication)")
306
370
 
@@ -335,7 +399,8 @@ def run_with_mcp_tools(
335
399
  agents=agents,
336
400
  tasks=tasks,
337
401
  verbose=verbose,
338
- process=process
402
+ process=process,
403
+ tracing=True if os.getenv("AGENTOPS_API_KEY") else False,
339
404
  )
340
405
 
341
406
  # Kickoff the crew with inputs