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.
@@ -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