agent0-sdk 0.31__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.
- agent0_sdk/__init__.py +52 -0
- agent0_sdk/core/agent.py +992 -0
- agent0_sdk/core/contracts.py +497 -0
- agent0_sdk/core/endpoint_crawler.py +330 -0
- agent0_sdk/core/feedback_manager.py +1023 -0
- agent0_sdk/core/indexer.py +1754 -0
- agent0_sdk/core/ipfs_client.py +355 -0
- agent0_sdk/core/models.py +313 -0
- agent0_sdk/core/oasf_validator.py +98 -0
- agent0_sdk/core/sdk.py +1045 -0
- agent0_sdk/core/subgraph_client.py +833 -0
- agent0_sdk/core/web3_client.py +192 -0
- agent0_sdk/taxonomies/all_domains.json +1565 -0
- agent0_sdk/taxonomies/all_skills.json +1030 -0
- agent0_sdk-0.31.dist-info/METADATA +367 -0
- agent0_sdk-0.31.dist-info/RECORD +33 -0
- agent0_sdk-0.31.dist-info/WHEEL +5 -0
- agent0_sdk-0.31.dist-info/licenses/LICENSE +22 -0
- agent0_sdk-0.31.dist-info/top_level.txt +2 -0
- tests/__init__.py +1 -0
- tests/config.py +46 -0
- tests/conftest.py +22 -0
- tests/discover_test_data.py +445 -0
- tests/test_feedback.py +417 -0
- tests/test_models.py +224 -0
- tests/test_multi_chain.py +588 -0
- tests/test_oasf_management.py +404 -0
- tests/test_real_public_servers.py +103 -0
- tests/test_registration.py +267 -0
- tests/test_registrationIpfs.py +227 -0
- tests/test_sdk.py +240 -0
- tests/test_search.py +415 -0
- tests/test_transfer.py +255 -0
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Endpoint Crawler for MCP and A2A Servers
|
|
3
|
+
Automatically fetches capabilities (tools, prompts, resources, skills) from endpoints
|
|
4
|
+
when an agent is registered. Uses soft failure - never blocks registration.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import logging
|
|
8
|
+
import requests
|
|
9
|
+
import json
|
|
10
|
+
from typing import Optional, Dict, Any, List
|
|
11
|
+
from urllib.parse import urlparse
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger(__name__)
|
|
14
|
+
|
|
15
|
+
# JSON-RPC helpers
|
|
16
|
+
def create_jsonrpc_request(method: str, params: Dict = None, request_id: int = 1):
|
|
17
|
+
"""Create a JSON-RPC request."""
|
|
18
|
+
payload = {
|
|
19
|
+
"jsonrpc": "2.0",
|
|
20
|
+
"method": method,
|
|
21
|
+
"id": request_id
|
|
22
|
+
}
|
|
23
|
+
if params:
|
|
24
|
+
payload["params"] = params
|
|
25
|
+
return payload
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class EndpointCrawler:
|
|
29
|
+
"""Crawls MCP and A2A endpoints to fetch capabilities."""
|
|
30
|
+
|
|
31
|
+
def __init__(self, timeout: int = 5):
|
|
32
|
+
"""
|
|
33
|
+
Initialize the endpoint crawler.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
timeout: Request timeout in seconds (default: 5)
|
|
37
|
+
"""
|
|
38
|
+
self.timeout = timeout
|
|
39
|
+
|
|
40
|
+
def fetch_mcp_capabilities(self, endpoint: str) -> Optional[Dict[str, Any]]:
|
|
41
|
+
"""
|
|
42
|
+
Fetch MCP capabilities (tools, prompts, resources) from an MCP server.
|
|
43
|
+
|
|
44
|
+
MCP Protocol uses JSON-RPC over HTTP POST. Tries JSON-RPC first,
|
|
45
|
+
then falls back to static agentcard.json.
|
|
46
|
+
|
|
47
|
+
Args:
|
|
48
|
+
endpoint: MCP endpoint URL (must be http:// or https://)
|
|
49
|
+
|
|
50
|
+
Returns:
|
|
51
|
+
Dict with keys: 'mcpTools', 'mcpPrompts', 'mcpResources'
|
|
52
|
+
Returns None if unable to fetch
|
|
53
|
+
"""
|
|
54
|
+
# Ensure endpoint is HTTP/HTTPS
|
|
55
|
+
if not endpoint.startswith(('http://', 'https://')):
|
|
56
|
+
logger.warning(f"MCP endpoint must be HTTP/HTTPS, got: {endpoint}")
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
# Try JSON-RPC approach first (for real MCP servers)
|
|
60
|
+
capabilities = self._fetch_via_jsonrpc(endpoint)
|
|
61
|
+
if capabilities:
|
|
62
|
+
return capabilities
|
|
63
|
+
|
|
64
|
+
# Fallback to static agentcard.json
|
|
65
|
+
try:
|
|
66
|
+
agentcard_url = f"{endpoint}/agentcard.json"
|
|
67
|
+
logger.debug(f"Attempting to fetch MCP capabilities from {agentcard_url}")
|
|
68
|
+
|
|
69
|
+
response = requests.get(agentcard_url, timeout=self.timeout, allow_redirects=True)
|
|
70
|
+
|
|
71
|
+
if response.status_code == 200:
|
|
72
|
+
data = response.json()
|
|
73
|
+
|
|
74
|
+
# Extract capabilities from agentcard
|
|
75
|
+
capabilities = {
|
|
76
|
+
'mcpTools': self._extract_list(data, 'tools'),
|
|
77
|
+
'mcpPrompts': self._extract_list(data, 'prompts'),
|
|
78
|
+
'mcpResources': self._extract_list(data, 'resources')
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if any(capabilities.values()):
|
|
82
|
+
logger.info(f"Successfully fetched MCP capabilities from {endpoint}")
|
|
83
|
+
return capabilities
|
|
84
|
+
|
|
85
|
+
except Exception as e:
|
|
86
|
+
logger.debug(f"Could not fetch MCP capabilities from {endpoint}: {e}")
|
|
87
|
+
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
def _fetch_via_jsonrpc(self, http_url: str) -> Optional[Dict[str, Any]]:
|
|
91
|
+
"""Try to fetch capabilities via JSON-RPC."""
|
|
92
|
+
try:
|
|
93
|
+
# Try to call list_tools, list_resources, list_prompts
|
|
94
|
+
tools = self._jsonrpc_call(http_url, "tools/list")
|
|
95
|
+
resources = self._jsonrpc_call(http_url, "resources/list")
|
|
96
|
+
prompts = self._jsonrpc_call(http_url, "prompts/list")
|
|
97
|
+
|
|
98
|
+
mcp_tools = []
|
|
99
|
+
mcp_resources = []
|
|
100
|
+
mcp_prompts = []
|
|
101
|
+
|
|
102
|
+
# Extract names from tools
|
|
103
|
+
if tools and isinstance(tools, dict) and "tools" in tools:
|
|
104
|
+
for tool in tools["tools"]:
|
|
105
|
+
if isinstance(tool, dict) and "name" in tool:
|
|
106
|
+
mcp_tools.append(tool["name"])
|
|
107
|
+
|
|
108
|
+
# Extract names from resources
|
|
109
|
+
if resources and isinstance(resources, dict) and "resources" in resources:
|
|
110
|
+
for resource in resources["resources"]:
|
|
111
|
+
if isinstance(resource, dict) and "name" in resource:
|
|
112
|
+
mcp_resources.append(resource["name"])
|
|
113
|
+
|
|
114
|
+
# Extract names from prompts
|
|
115
|
+
if prompts and isinstance(prompts, dict) and "prompts" in prompts:
|
|
116
|
+
for prompt in prompts["prompts"]:
|
|
117
|
+
if isinstance(prompt, dict) and "name" in prompt:
|
|
118
|
+
mcp_prompts.append(prompt["name"])
|
|
119
|
+
|
|
120
|
+
if mcp_tools or mcp_resources or mcp_prompts:
|
|
121
|
+
logger.info(f"Successfully fetched MCP capabilities via JSON-RPC")
|
|
122
|
+
return {
|
|
123
|
+
'mcpTools': mcp_tools,
|
|
124
|
+
'mcpResources': mcp_resources,
|
|
125
|
+
'mcpPrompts': mcp_prompts
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
except Exception as e:
|
|
129
|
+
logger.debug(f"JSON-RPC approach failed: {e}")
|
|
130
|
+
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
def _jsonrpc_call(self, url: str, method: str, params: Dict = None) -> Optional[Dict[str, Any]]:
|
|
134
|
+
"""Make a JSON-RPC call and return the result. Handles SSE format."""
|
|
135
|
+
try:
|
|
136
|
+
payload = create_jsonrpc_request(method, params or {})
|
|
137
|
+
headers = {
|
|
138
|
+
'Content-Type': 'application/json',
|
|
139
|
+
'Accept': 'application/json, text/event-stream'
|
|
140
|
+
}
|
|
141
|
+
response = requests.post(url, json=payload, timeout=self.timeout, headers=headers, stream=True)
|
|
142
|
+
|
|
143
|
+
if response.status_code == 200:
|
|
144
|
+
# Check if response is SSE format
|
|
145
|
+
content_type = response.headers.get('content-type', '')
|
|
146
|
+
if 'text/event-stream' in content_type or 'event: message' in response.text[:200]:
|
|
147
|
+
# Parse SSE format
|
|
148
|
+
result = self._parse_sse_response(response.text)
|
|
149
|
+
if result:
|
|
150
|
+
return result
|
|
151
|
+
else:
|
|
152
|
+
# Regular JSON response
|
|
153
|
+
result = response.json()
|
|
154
|
+
if "result" in result:
|
|
155
|
+
return result["result"]
|
|
156
|
+
return result
|
|
157
|
+
except Exception as e:
|
|
158
|
+
logger.debug(f"JSON-RPC call {method} failed: {e}")
|
|
159
|
+
|
|
160
|
+
return None
|
|
161
|
+
|
|
162
|
+
def _parse_sse_response(self, sse_text: str) -> Optional[Dict[str, Any]]:
|
|
163
|
+
"""Parse Server-Sent Events (SSE) format response."""
|
|
164
|
+
try:
|
|
165
|
+
# Look for "data:" lines containing JSON
|
|
166
|
+
for line in sse_text.split('\n'):
|
|
167
|
+
if line.startswith('data: '):
|
|
168
|
+
json_str = line[6:] # Remove "data: " prefix
|
|
169
|
+
data = json.loads(json_str)
|
|
170
|
+
if "result" in data:
|
|
171
|
+
return data["result"]
|
|
172
|
+
return data
|
|
173
|
+
except Exception as e:
|
|
174
|
+
logger.debug(f"Failed to parse SSE response: {e}")
|
|
175
|
+
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
def fetch_a2a_capabilities(self, endpoint: str) -> Optional[Dict[str, Any]]:
|
|
179
|
+
"""
|
|
180
|
+
Fetch A2A capabilities (skills) from an A2A server.
|
|
181
|
+
|
|
182
|
+
A2A Protocol uses agent cards to describe agent capabilities.
|
|
183
|
+
Tries multiple well-known paths: agentcard.json, .well-known/agent.json, .well-known/agent-card.json
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
endpoint: A2A endpoint URL (must be http:// or https://)
|
|
187
|
+
|
|
188
|
+
Returns:
|
|
189
|
+
Dict with key: 'a2aSkills'
|
|
190
|
+
Returns None if unable to fetch
|
|
191
|
+
"""
|
|
192
|
+
try:
|
|
193
|
+
# Ensure endpoint is HTTP/HTTPS
|
|
194
|
+
if not endpoint.startswith(('http://', 'https://')):
|
|
195
|
+
logger.warning(f"A2A endpoint must be HTTP/HTTPS, got: {endpoint}")
|
|
196
|
+
return None
|
|
197
|
+
|
|
198
|
+
# Try multiple well-known paths for A2A agent cards
|
|
199
|
+
# Per ERC-8004, endpoint may already be full URL to agent card
|
|
200
|
+
# Per A2A spec section 5.3, recommended discovery path is /.well-known/agent-card.json
|
|
201
|
+
agentcard_urls = [
|
|
202
|
+
endpoint, # Try exact URL first (ERC-8004 format: full path to agent card)
|
|
203
|
+
f"{endpoint}/.well-known/agent-card.json", # Spec-recommended discovery path
|
|
204
|
+
f"{endpoint.rstrip('/')}/.well-known/agent-card.json",
|
|
205
|
+
f"{endpoint}/.well-known/agent.json", # Alternative well-known path
|
|
206
|
+
f"{endpoint.rstrip('/')}/.well-known/agent.json",
|
|
207
|
+
f"{endpoint}/agentcard.json" # Legacy path
|
|
208
|
+
]
|
|
209
|
+
|
|
210
|
+
for agentcard_url in agentcard_urls:
|
|
211
|
+
logger.debug(f"Attempting to fetch A2A capabilities from {agentcard_url}")
|
|
212
|
+
|
|
213
|
+
try:
|
|
214
|
+
response = requests.get(agentcard_url, timeout=self.timeout, allow_redirects=True)
|
|
215
|
+
|
|
216
|
+
if response.status_code == 200:
|
|
217
|
+
data = response.json()
|
|
218
|
+
|
|
219
|
+
# Extract skill tags from agentcard
|
|
220
|
+
skills = self._extract_a2a_skills(data)
|
|
221
|
+
|
|
222
|
+
if skills:
|
|
223
|
+
logger.info(f"Successfully fetched A2A capabilities from {agentcard_url}: {len(skills)} skills")
|
|
224
|
+
return {'a2aSkills': skills}
|
|
225
|
+
except requests.exceptions.RequestException as e:
|
|
226
|
+
# Try next URL
|
|
227
|
+
logger.debug(f"Failed to fetch from {agentcard_url}: {e}")
|
|
228
|
+
continue
|
|
229
|
+
|
|
230
|
+
except Exception as e:
|
|
231
|
+
logger.debug(f"Unexpected error fetching A2A capabilities from {endpoint}: {e}")
|
|
232
|
+
|
|
233
|
+
return None
|
|
234
|
+
|
|
235
|
+
def _extract_a2a_skills(self, data: Dict[str, Any]) -> List[str]:
|
|
236
|
+
"""
|
|
237
|
+
Extract skill tags from A2A agent card.
|
|
238
|
+
|
|
239
|
+
Per A2A Protocol spec (v0.3.0), agent cards should have:
|
|
240
|
+
skills: AgentSkill[] where each AgentSkill has a tags[] array
|
|
241
|
+
|
|
242
|
+
This method also handles non-standard formats for backward compatibility:
|
|
243
|
+
- detailedSkills[].tags[] (custom extension)
|
|
244
|
+
- skills: ["tag1", "tag2"] (non-compliant flat array)
|
|
245
|
+
|
|
246
|
+
Args:
|
|
247
|
+
data: Agent card JSON data
|
|
248
|
+
|
|
249
|
+
Returns:
|
|
250
|
+
List of unique skill tags (strings)
|
|
251
|
+
"""
|
|
252
|
+
result = []
|
|
253
|
+
|
|
254
|
+
# Try spec-compliant format first: skills[].tags[]
|
|
255
|
+
if 'skills' in data and isinstance(data['skills'], list):
|
|
256
|
+
for skill in data['skills']:
|
|
257
|
+
if isinstance(skill, dict) and 'tags' in skill:
|
|
258
|
+
# Spec-compliant: AgentSkill object with tags
|
|
259
|
+
tags = skill['tags']
|
|
260
|
+
if isinstance(tags, list):
|
|
261
|
+
for tag in tags:
|
|
262
|
+
if isinstance(tag, str):
|
|
263
|
+
result.append(tag)
|
|
264
|
+
elif isinstance(skill, str):
|
|
265
|
+
# Non-compliant: flat string array (fallback)
|
|
266
|
+
result.append(skill)
|
|
267
|
+
|
|
268
|
+
# Fallback to detailedSkills if no tags found in skills
|
|
269
|
+
# (custom extension used by some implementations)
|
|
270
|
+
if not result and 'detailedSkills' in data and isinstance(data['detailedSkills'], list):
|
|
271
|
+
for skill in data['detailedSkills']:
|
|
272
|
+
if isinstance(skill, dict) and 'tags' in skill:
|
|
273
|
+
tags = skill['tags']
|
|
274
|
+
if isinstance(tags, list):
|
|
275
|
+
for tag in tags:
|
|
276
|
+
if isinstance(tag, str):
|
|
277
|
+
result.append(tag)
|
|
278
|
+
|
|
279
|
+
# Remove duplicates while preserving order
|
|
280
|
+
seen = set()
|
|
281
|
+
unique_result = []
|
|
282
|
+
for item in result:
|
|
283
|
+
if item not in seen:
|
|
284
|
+
seen.add(item)
|
|
285
|
+
unique_result.append(item)
|
|
286
|
+
|
|
287
|
+
return unique_result
|
|
288
|
+
|
|
289
|
+
def _extract_list(self, data: Dict[str, Any], key: str) -> List[str]:
|
|
290
|
+
"""
|
|
291
|
+
Extract a list of strings from nested JSON data.
|
|
292
|
+
|
|
293
|
+
Args:
|
|
294
|
+
data: JSON data dictionary
|
|
295
|
+
key: Key to extract (e.g., 'tools', 'prompts', 'resources', 'skills')
|
|
296
|
+
|
|
297
|
+
Returns:
|
|
298
|
+
List of string names/IDs
|
|
299
|
+
"""
|
|
300
|
+
result = []
|
|
301
|
+
|
|
302
|
+
# Try top-level key
|
|
303
|
+
if key in data and isinstance(data[key], list):
|
|
304
|
+
for item in data[key]:
|
|
305
|
+
if isinstance(item, str):
|
|
306
|
+
result.append(item)
|
|
307
|
+
elif isinstance(item, dict):
|
|
308
|
+
# For objects, try to extract name/id field
|
|
309
|
+
for name_field in ['name', 'id', 'identifier', 'title']:
|
|
310
|
+
if name_field in item and isinstance(item[name_field], str):
|
|
311
|
+
result.append(item[name_field])
|
|
312
|
+
break
|
|
313
|
+
|
|
314
|
+
# Try nested in 'capabilities' or 'abilities'
|
|
315
|
+
if not result:
|
|
316
|
+
for container_key in ['capabilities', 'abilities', 'features']:
|
|
317
|
+
if container_key in data and isinstance(data[container_key], dict):
|
|
318
|
+
if key in data[container_key] and isinstance(data[container_key][key], list):
|
|
319
|
+
for item in data[container_key][key]:
|
|
320
|
+
if isinstance(item, str):
|
|
321
|
+
result.append(item)
|
|
322
|
+
elif isinstance(item, dict):
|
|
323
|
+
for name_field in ['name', 'id', 'identifier', 'title']:
|
|
324
|
+
if name_field in item and isinstance(item[name_field], str):
|
|
325
|
+
result.append(item[name_field])
|
|
326
|
+
break
|
|
327
|
+
if result:
|
|
328
|
+
break
|
|
329
|
+
|
|
330
|
+
return result
|