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
|
@@ -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
|
|
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
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
#
|
|
284
|
-
if
|
|
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
|
-
#
|
|
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
|