traia-iatp 0.1.1__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.

Potentially problematic release.


This version of traia-iatp might be problematic. Click here for more details.

Files changed (72) hide show
  1. traia_iatp/README.md +368 -0
  2. traia_iatp/__init__.py +30 -0
  3. traia_iatp/cli/__init__.py +5 -0
  4. traia_iatp/cli/main.py +483 -0
  5. traia_iatp/client/__init__.py +10 -0
  6. traia_iatp/client/a2a_client.py +274 -0
  7. traia_iatp/client/crewai_a2a_tools.py +335 -0
  8. traia_iatp/client/grpc_a2a_tools.py +349 -0
  9. traia_iatp/client/root_path_a2a_client.py +1 -0
  10. traia_iatp/core/__init__.py +43 -0
  11. traia_iatp/core/models.py +161 -0
  12. traia_iatp/mcp/__init__.py +15 -0
  13. traia_iatp/mcp/client.py +201 -0
  14. traia_iatp/mcp/mcp_agent_template.py +422 -0
  15. traia_iatp/mcp/templates/Dockerfile.j2 +56 -0
  16. traia_iatp/mcp/templates/README.md.j2 +212 -0
  17. traia_iatp/mcp/templates/cursor-rules.md.j2 +326 -0
  18. traia_iatp/mcp/templates/deployment_params.json.j2 +20 -0
  19. traia_iatp/mcp/templates/docker-compose.yml.j2 +23 -0
  20. traia_iatp/mcp/templates/dockerignore.j2 +47 -0
  21. traia_iatp/mcp/templates/gitignore.j2 +77 -0
  22. traia_iatp/mcp/templates/mcp_health_check.py.j2 +150 -0
  23. traia_iatp/mcp/templates/pyproject.toml.j2 +26 -0
  24. traia_iatp/mcp/templates/run_local_docker.sh.j2 +94 -0
  25. traia_iatp/mcp/templates/server.py.j2 +240 -0
  26. traia_iatp/mcp/traia_mcp_adapter.py +381 -0
  27. traia_iatp/preview_diagrams.html +181 -0
  28. traia_iatp/registry/__init__.py +26 -0
  29. traia_iatp/registry/atlas_search_indexes.json +280 -0
  30. traia_iatp/registry/embeddings.py +298 -0
  31. traia_iatp/registry/iatp_search_api.py +839 -0
  32. traia_iatp/registry/mongodb_registry.py +771 -0
  33. traia_iatp/registry/readmes/ATLAS_SEARCH_INDEXES.md +252 -0
  34. traia_iatp/registry/readmes/ATLAS_SEARCH_SETUP.md +134 -0
  35. traia_iatp/registry/readmes/AUTHENTICATION_UPDATE.md +124 -0
  36. traia_iatp/registry/readmes/EMBEDDINGS_SETUP.md +172 -0
  37. traia_iatp/registry/readmes/IATP_SEARCH_API_GUIDE.md +257 -0
  38. traia_iatp/registry/readmes/MONGODB_X509_AUTH.md +208 -0
  39. traia_iatp/registry/readmes/README.md +251 -0
  40. traia_iatp/registry/readmes/REFACTORING_SUMMARY.md +191 -0
  41. traia_iatp/server/__init__.py +15 -0
  42. traia_iatp/server/a2a_server.py +215 -0
  43. traia_iatp/server/example_template_usage.py +72 -0
  44. traia_iatp/server/iatp_server_agent_generator.py +237 -0
  45. traia_iatp/server/iatp_server_template_generator.py +235 -0
  46. traia_iatp/server/templates/Dockerfile.j2 +49 -0
  47. traia_iatp/server/templates/README.md +137 -0
  48. traia_iatp/server/templates/README.md.j2 +425 -0
  49. traia_iatp/server/templates/__init__.py +1 -0
  50. traia_iatp/server/templates/__main__.py.j2 +450 -0
  51. traia_iatp/server/templates/agent.py.j2 +80 -0
  52. traia_iatp/server/templates/agent_config.json.j2 +22 -0
  53. traia_iatp/server/templates/agent_executor.py.j2 +264 -0
  54. traia_iatp/server/templates/docker-compose.yml.j2 +23 -0
  55. traia_iatp/server/templates/env.example.j2 +67 -0
  56. traia_iatp/server/templates/gitignore.j2 +78 -0
  57. traia_iatp/server/templates/grpc_server.py.j2 +218 -0
  58. traia_iatp/server/templates/pyproject.toml.j2 +76 -0
  59. traia_iatp/server/templates/run_local_docker.sh.j2 +103 -0
  60. traia_iatp/server/templates/server.py.j2 +190 -0
  61. traia_iatp/special_agencies/__init__.py +4 -0
  62. traia_iatp/special_agencies/registry_search_agency.py +392 -0
  63. traia_iatp/utils/__init__.py +10 -0
  64. traia_iatp/utils/docker_utils.py +251 -0
  65. traia_iatp/utils/general.py +64 -0
  66. traia_iatp/utils/iatp_utils.py +126 -0
  67. traia_iatp-0.1.1.dist-info/METADATA +414 -0
  68. traia_iatp-0.1.1.dist-info/RECORD +72 -0
  69. traia_iatp-0.1.1.dist-info/WHEEL +5 -0
  70. traia_iatp-0.1.1.dist-info/entry_points.txt +2 -0
  71. traia_iatp-0.1.1.dist-info/licenses/LICENSE +21 -0
  72. traia_iatp-0.1.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,771 @@
1
+ """MongoDB registry for utility agents and MCP servers (write operations only).
2
+
3
+ This module handles all write operations for the IATP registry:
4
+ - Adding utility agents and MCP servers
5
+ - Updating agent information and health status
6
+ - Managing agent lifecycle (activation/deactivation)
7
+ - Registry statistics
8
+
9
+ For search and query operations, use iatp_search_api.py instead.
10
+ """
11
+
12
+ import logging
13
+ import time
14
+ import asyncio
15
+ from datetime import datetime
16
+ from typing import List, Optional, Dict, Any
17
+ from pymongo import MongoClient, ASCENDING, TEXT
18
+ from pymongo import server_api
19
+ from pymongo.errors import ServerSelectionTimeoutError, NetworkTimeout, AutoReconnect
20
+ from pymongo.database import Database
21
+ from pymongo.collection import Collection
22
+ import os
23
+
24
+ # Handle imports for both module and script usage
25
+ if __name__ == "__main__":
26
+ # When running as a script, import directly
27
+ import sys
28
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
29
+ from iatp.core.models import UtilityAgentRegistryEntry, UtilityAgent
30
+ from iatp.registry.embeddings import get_embedding_service
31
+ from ..utils import get_now_in_utc
32
+ else:
33
+ # When imported as a module
34
+ from ..core.models import UtilityAgentRegistryEntry, UtilityAgent
35
+ from .embeddings import get_embedding_service
36
+ from ..utils import get_now_in_utc
37
+
38
+ logger = logging.getLogger(__name__)
39
+
40
+ CLUSTER_URI = "traia-iatp-cluster.yzwjvgd.mongodb.net/?retryWrites=true&w=majority&appName=Traia-IATP-Cluster"
41
+ DATABASE_NAME = "iatp"
42
+
43
+
44
+ def get_collection_names():
45
+ """Get environment-specific collection names."""
46
+ env = os.getenv("ENV", "test").lower()
47
+
48
+ # Validate environment
49
+ valid_envs = ["test", "staging", "prod"]
50
+ if env not in valid_envs:
51
+ logger.warning(f"Invalid ENV '{env}', defaulting to 'test'. Valid values: {valid_envs}")
52
+ env = "test"
53
+
54
+ return {
55
+ "utility_agent": f"iatp-utility-agent-registry-{env}",
56
+ "mcp_server": f"iatp-mcp-server-registry-{env}"
57
+ }
58
+
59
+
60
+ def get_search_index_names():
61
+ """Get environment-specific search index names."""
62
+ env = os.getenv("ENV", "test").lower()
63
+
64
+ # Validate environment
65
+ valid_envs = ["test", "staging", "prod"]
66
+ if env not in valid_envs:
67
+ env = "test"
68
+
69
+ return {
70
+ "utility_agent_atlas_search": f"utility_agent_atlas_search_{env}",
71
+ "utility_agent_vector_search": f"utility_agent_vector_search_{env}",
72
+ "mcp_server_atlas_search": f"mcp_server_atlas_search_{env}",
73
+ "mcp_server_vector_search": f"mcp_server_vector_search_{env}"
74
+ }
75
+
76
+
77
+ def _create_mongodb_client_with_retry(connection_string: str, max_retries: int = 3) -> MongoClient:
78
+ """Create MongoDB client with retry logic for connection resilience."""
79
+ for attempt in range(max_retries):
80
+ try:
81
+ client = MongoClient(
82
+ connection_string,
83
+ server_api=server_api.ServerApi('1'),
84
+ serverSelectionTimeoutMS=15000, # 15 second timeout
85
+ connectTimeoutMS=10000, # 10 second connect timeout
86
+ socketTimeoutMS=30000, # 30 second socket timeout
87
+ maxPoolSize=10, # Connection pool size
88
+ retryWrites=True # Enable retryable writes
89
+ )
90
+ # Test the connection
91
+ client.admin.command('ping')
92
+ logger.info(f"MongoDB connection established successfully (attempt {attempt + 1})")
93
+ return client
94
+ except (ServerSelectionTimeoutError, NetworkTimeout, AutoReconnect, Exception) as e:
95
+ error_msg = str(e).lower()
96
+ is_connection_error = any(term in error_msg for term in ['ssl', 'tls', 'handshake', 'timeout', 'network'])
97
+
98
+ if attempt < max_retries - 1 and is_connection_error:
99
+ delay = (2 ** attempt) + (attempt * 0.5) # Exponential backoff with jitter
100
+ logger.warning(f"MongoDB connection attempt {attempt + 1} failed: {e}")
101
+ logger.info(f"Retrying in {delay:.1f} seconds...")
102
+ time.sleep(delay)
103
+ else:
104
+ logger.error(f"All {max_retries} MongoDB connection attempts failed")
105
+ raise
106
+
107
+
108
+ class UtilityAgentRegistry:
109
+ """Cloud MongoDB-based registry for utility agents (write operations only).
110
+
111
+ This class handles all write operations for utility agents:
112
+ - Adding new agents to the registry
113
+ - Updating agent information and health status
114
+ - Managing agent lifecycle
115
+ - Registry statistics
116
+
117
+ For search and query operations, use iatp_search_api.py instead.
118
+ """
119
+
120
+ def __init__(self, connection_string: Optional[str] = None, database_name: str = DATABASE_NAME):
121
+ """Initialize MongoDB registry for cloud usage.
122
+
123
+ Args:
124
+ connection_string: MongoDB connection string (should be a cloud MongoDB URI)
125
+ database_name: Name of the database to use
126
+ """
127
+ if connection_string:
128
+ self.connection_string = connection_string
129
+ else:
130
+ # Try X.509 certificate authentication first
131
+ cert_file = os.getenv("MONGODB_X509_CERT_FILE")
132
+ if cert_file and os.path.exists(cert_file):
133
+ # For X.509 authentication, we need to extract the subject from the certificate
134
+ # to use as the username. MongoDB Atlas typically uses the full DN as username.
135
+ # The connection string format for X.509 is:
136
+ # mongodb+srv://cluster.mongodb.net/?authSource=$external&authMechanism=MONGODB-X509
137
+ # Extract just the cluster hostname without query parameters
138
+ cluster_host = CLUSTER_URI.split('?')[0]
139
+ self.connection_string = f"mongodb+srv://{cluster_host}?authSource=$external&authMechanism=MONGODB-X509&tls=true&tlsCertificateKeyFile={cert_file}"
140
+ logger.info(f"Using X.509 certificate authentication from {cert_file}")
141
+ else:
142
+ # Fallback to username/password authentication
143
+ user = os.getenv("MONGODB_USER")
144
+ password = os.getenv("MONGODB_PASSWORD")
145
+ if user and password:
146
+ self.connection_string = f"mongodb+srv://{user}:{password}@{CLUSTER_URI}"
147
+ logger.info("Using username/password authentication")
148
+ else:
149
+ # Try connection string as last resort
150
+ self.connection_string = os.getenv("MONGODB_CONNECTION_STRING")
151
+ if not self.connection_string:
152
+ raise ValueError(
153
+ "MongoDB authentication required. Please provide either:\n"
154
+ "1. MONGODB_X509_CERT_FILE - Path to X.509 certificate file\n"
155
+ "2. MONGODB_USER and MONGODB_PASSWORD - Username and password\n"
156
+ "3. MONGODB_CONNECTION_STRING - Full connection string"
157
+ )
158
+
159
+ self.database_name = database_name
160
+ self.client = _create_mongodb_client_with_retry(self.connection_string)
161
+ self.db: Database = self.client[self.database_name]
162
+
163
+ # Get environment-specific collection name
164
+ collection_names = get_collection_names()
165
+ self.registry: Collection = self.db[collection_names["utility_agent"]]
166
+ logger.info(f"Using collection: {collection_names['utility_agent']}")
167
+
168
+ # Create indexes for efficient searching (only if they don't exist)
169
+ self._ensure_indexes()
170
+
171
+ def _ensure_indexes(self):
172
+ """Ensure indexes exist for efficient searching."""
173
+ existing_indexes = [idx['name'] for idx in self.registry.list_indexes()]
174
+
175
+ # NOTE: Atlas Search and Vector Search indexes must be created through Atlas UI or API
176
+ # See atlas_search_indexes.json and ATLAS_SEARCH_SETUP.md for instructions
177
+
178
+ # Only create regular indexes (not text search)
179
+ index_specs = [
180
+ ("agent_id", "agent_id_index", True), # unique
181
+ ("name", "name_index", True), # unique
182
+ ("base_url", "base_url_index", True), # unique - base URL is the primary endpoint
183
+ ("is_active", "is_active_index", False),
184
+ ("tags", "tags_index", False),
185
+ ("capabilities", "capabilities_index", False),
186
+ ([("registered_at", ASCENDING)], "registered_at_index", False)
187
+ ]
188
+
189
+ for spec, index_name, is_unique in index_specs:
190
+ if index_name not in existing_indexes:
191
+ try:
192
+ if isinstance(spec, list):
193
+ self.registry.create_index(spec, name=index_name)
194
+ else:
195
+ self.registry.create_index(spec, name=index_name, unique=is_unique)
196
+ except Exception as e:
197
+ logger.warning(f"Could not create index {index_name}: {e}")
198
+
199
+ async def add_utility_agent(self, agent: UtilityAgent, tags: List[str] = None) -> UtilityAgentRegistryEntry:
200
+ """Add a utility agent to the cloud registry.
201
+
202
+ Args:
203
+ agent: UtilityAgent object with endpoints configured
204
+ tags: Optional additional tags for search
205
+
206
+ Returns:
207
+ UtilityAgentRegistryEntry created or updated
208
+ """
209
+ # Check if agent with same name already exists
210
+ existing = self.registry.find_one({"name": agent.name})
211
+
212
+ if existing:
213
+ # Update the existing entry with the same name
214
+ agent_id = existing["agent_id"]
215
+ logger.warning(f"Agent with name '{agent.name}' already exists (ID: {agent_id}). Updating it.")
216
+ else:
217
+ agent_id = agent.id
218
+
219
+ # Generate embeddings if enabled
220
+ description_embedding = None
221
+ tags_embedding = None
222
+ capabilities_embedding = None
223
+ agent_card_embedding = None
224
+ search_text_embedding = None
225
+
226
+ if os.getenv("ENABLE_EMBEDDINGS", "true").lower() == "true":
227
+ try:
228
+ embedding_service = get_embedding_service()
229
+
230
+ # Generate embedding for description
231
+ if agent.description:
232
+ description_embedding = await embedding_service.generate_embedding(agent.description)
233
+
234
+ # Generate embedding for tags (concatenated)
235
+ if tags or agent.tags:
236
+ all_tags = list(set((tags or []) + agent.tags))
237
+ tags_text = " ".join(all_tags)
238
+ tags_embedding = await embedding_service.generate_embedding(tags_text)
239
+
240
+ # Generate embedding for capabilities
241
+ if agent.capabilities:
242
+ capabilities_text = " ".join(agent.capabilities)
243
+ capabilities_embedding = await embedding_service.generate_embedding(capabilities_text)
244
+
245
+ # Generate embedding for agent card
246
+ if agent.agent_card:
247
+ # Create comprehensive agent card text
248
+ agent_card_parts = [
249
+ agent.agent_card.name,
250
+ agent.agent_card.description
251
+ ]
252
+
253
+ # Add skills information
254
+ for skill in agent.agent_card.skills:
255
+ agent_card_parts.append(skill.name)
256
+ agent_card_parts.append(skill.description)
257
+ agent_card_parts.extend(skill.examples)
258
+ agent_card_parts.extend(skill.tags)
259
+
260
+ agent_card_text = " ".join(filter(None, agent_card_parts))
261
+ agent_card_embedding = await embedding_service.generate_embedding(agent_card_text)
262
+
263
+ # Note: We'll generate search text embedding after creating the entry
264
+
265
+ logger.info(f"Generated embeddings for agent {agent.name}")
266
+ except Exception as e:
267
+ logger.warning(f"Failed to generate embeddings: {e}. Proceeding without embeddings.")
268
+
269
+ # Create entry with enhanced fields
270
+ entry = UtilityAgentRegistryEntry(
271
+ agent_id=agent_id,
272
+ name=agent.name,
273
+ description=agent.description,
274
+ capabilities=agent.capabilities,
275
+ tags=tags or agent.tags,
276
+ metadata=agent.metadata
277
+ )
278
+
279
+ # Add agent card if available
280
+ if agent.agent_card:
281
+ entry.agent_card = agent.agent_card
282
+ entry.skills = agent.agent_card.skills
283
+
284
+ # Add endpoints if available
285
+ if agent.endpoints:
286
+ entry.endpoints = agent.endpoints
287
+ # Store base_url for indexing since all endpoints are derived from it
288
+ entry.base_url = agent.endpoints.base_url
289
+
290
+ # Generate search text from available data
291
+ search_text_parts = [agent.name, agent.description]
292
+ search_text_parts.extend(agent.capabilities)
293
+ search_text_parts.extend(tags or agent.tags)
294
+
295
+ # Add agent card information to search text
296
+ if agent.agent_card:
297
+ search_text_parts.append(agent.agent_card.name)
298
+ search_text_parts.append(agent.agent_card.description)
299
+ for skill in agent.agent_card.skills:
300
+ search_text_parts.append(skill.name)
301
+ search_text_parts.append(skill.description)
302
+ search_text_parts.extend(skill.examples)
303
+ search_text_parts.extend(skill.tags)
304
+
305
+ # Set the generated search text
306
+ entry.search_text = " ".join(filter(None, search_text_parts))
307
+
308
+ # Generate embedding for the search text we just created
309
+ if os.getenv("ENABLE_EMBEDDINGS", "true").lower() == "true" and entry.search_text:
310
+ try:
311
+ embedding_service = get_embedding_service()
312
+ search_text_embedding = await embedding_service.generate_embedding(entry.search_text)
313
+ except Exception as e:
314
+ logger.warning(f"Failed to generate search text embedding: {e}")
315
+
316
+ # Insert or update in cloud MongoDB
317
+ # Convert to dict and ensure all values are JSON-serializable
318
+ entry_dict = entry.model_dump(mode='json')
319
+
320
+ # Add embeddings if available
321
+ embeddings = {}
322
+ if description_embedding:
323
+ embeddings["description"] = description_embedding
324
+ if tags_embedding:
325
+ embeddings["tags"] = tags_embedding
326
+ if capabilities_embedding:
327
+ embeddings["capabilities"] = capabilities_embedding
328
+ if agent_card_embedding:
329
+ embeddings["agent_card"] = agent_card_embedding
330
+ if search_text_embedding:
331
+ embeddings["search_text"] = search_text_embedding
332
+
333
+ if embeddings:
334
+ entry_dict["embeddings"] = embeddings
335
+
336
+ # Store the full UtilityAgent data as well
337
+ agent_dict = agent.model_dump(mode='json')
338
+ entry_dict["utility_agent_data"] = agent_dict
339
+
340
+ result = self.registry.replace_one(
341
+ {"agent_id": agent_id}, # Use agent_id as the key for upsert
342
+ entry_dict,
343
+ upsert=True
344
+ )
345
+
346
+ if result.upserted_id:
347
+ logger.info(f"Added new agent {agent.name} (ID: {agent_id}) to cloud registry")
348
+ else:
349
+ logger.info(f"Updated existing agent {agent.name} (ID: {agent_id}) in cloud registry")
350
+
351
+ return entry
352
+
353
+
354
+
355
+
356
+
357
+
358
+
359
+
360
+
361
+
362
+
363
+ async def get_agent_by_id(self, agent_id: str) -> Optional[UtilityAgentRegistryEntry]:
364
+ """Get a specific agent by ID from cloud registry."""
365
+ doc = self.registry.find_one({"agent_id": agent_id})
366
+ if doc:
367
+ doc.pop("_id", None)
368
+ return UtilityAgentRegistryEntry(**doc)
369
+ return None
370
+
371
+ async def update_health_status(self, agent_id: str, is_healthy: bool = True) -> bool:
372
+ """Update health status of an agent in cloud registry."""
373
+ update = {
374
+ "$set": {
375
+ "last_health_check": get_now_in_utc(),
376
+ "is_active": is_healthy
377
+ }
378
+ }
379
+
380
+ result = self.registry.update_one(
381
+ {"agent_id": agent_id},
382
+ update
383
+ )
384
+ return result.modified_count > 0
385
+
386
+ async def update_agent_base_url(self, agent_id: str, new_base_url: str) -> bool:
387
+ """Update the base URL for an agent.
388
+
389
+ This will also update the endpoints configuration with new derived URLs.
390
+
391
+ Args:
392
+ agent_id: ID of the agent to update
393
+ new_base_url: New base URL for the agent
394
+
395
+ Returns:
396
+ True if update was successful
397
+ """
398
+ # Fetch the current agent to get streaming configuration
399
+ doc = self.registry.find_one({"agent_id": agent_id})
400
+ if not doc:
401
+ return False
402
+
403
+ # Create new endpoints based on the new base URL
404
+ from iatp.utils.iatp_utils import create_iatp_endpoints
405
+ supports_streaming = doc.get("endpoints", {}).get("streaming_endpoint") is not None
406
+ new_endpoints = create_iatp_endpoints(new_base_url, supports_streaming)
407
+
408
+ result = self.registry.update_one(
409
+ {"agent_id": agent_id},
410
+ {"$set": {
411
+ "base_url": new_base_url,
412
+ "endpoints": new_endpoints.model_dump(mode='json'),
413
+ "updated_at": get_now_in_utc()
414
+ }}
415
+ )
416
+ return result.modified_count > 0
417
+
418
+ async def add_tags(self, agent_id: str, tags: List[str]) -> bool:
419
+ """Add tags to an agent."""
420
+ result = self.registry.update_one(
421
+ {"agent_id": agent_id},
422
+ {"$addToSet": {"tags": {"$each": tags}}}
423
+ )
424
+ return result.modified_count > 0
425
+
426
+ async def remove_agent(self, agent_id: str) -> bool:
427
+ """Remove an agent from the registry (soft delete by deactivating)."""
428
+ result = self.registry.update_one(
429
+ {"agent_id": agent_id},
430
+ {"$set": {"is_active": False, "deactivated_at": get_now_in_utc()}}
431
+ )
432
+ return result.modified_count > 0
433
+
434
+ async def update_utility_agent(self, agent_id: str, update_data: Dict[str, Any]) -> bool:
435
+ """Update utility agent data in the registry.
436
+
437
+ Args:
438
+ agent_id: The ID of the agent to update
439
+ update_data: Dictionary of fields to update
440
+
441
+ Returns:
442
+ True if update was successful, False otherwise
443
+ """
444
+ # Add updated_at timestamp
445
+ update_data["updated_at"] = get_now_in_utc()
446
+
447
+ result = self.registry.update_one(
448
+ {"agent_id": agent_id},
449
+ {"$set": update_data}
450
+ )
451
+
452
+ if result.modified_count > 0:
453
+ logger.info(f"Updated utility agent {agent_id} with {len(update_data)} fields")
454
+ return True
455
+ else:
456
+ logger.warning(f"No utility agent found with ID {agent_id} or no changes made")
457
+ return False
458
+
459
+ async def get_statistics(self) -> Dict[str, Any]:
460
+ """Get registry statistics."""
461
+ total_agents = self.registry.count_documents({})
462
+ active_agents = self.registry.count_documents({"is_active": True})
463
+
464
+ # Get capability distribution
465
+ pipeline = [
466
+ {"$match": {"is_active": True}},
467
+ {"$unwind": "$capabilities"},
468
+ {"$group": {"_id": "$capabilities", "count": {"$sum": 1}}},
469
+ {"$sort": {"count": -1}},
470
+ {"$limit": 10}
471
+ ]
472
+
473
+ capability_dist = list(self.registry.aggregate(pipeline))
474
+
475
+ return {
476
+ "total_agents": total_agents,
477
+ "active_agents": active_agents,
478
+ "top_capabilities": [{"capability": item["_id"], "count": item["count"]} for item in capability_dist]
479
+ }
480
+
481
+ def close(self):
482
+ """Close the MongoDB connection."""
483
+ self.client.close()
484
+
485
+
486
+ class MCPServerRegistry:
487
+ """Registry for MCP servers in cloud MongoDB (write operations only).
488
+
489
+ This class handles all write operations for MCP servers:
490
+ - Adding new MCP servers to the registry
491
+ - Updating server information
492
+ - Managing server lifecycle
493
+
494
+ For search and query operations, use iatp_search_api.py instead.
495
+ """
496
+
497
+ def __init__(self, connection_string: Optional[str] = None, database_name: str = "iatp"):
498
+ """Initialize MCP Server registry."""
499
+ if connection_string:
500
+ self.connection_string = connection_string
501
+ else:
502
+ # Try X.509 certificate authentication first
503
+ cert_file = os.getenv("MONGODB_X509_CERT_FILE")
504
+ if cert_file and os.path.exists(cert_file):
505
+ # For X.509 authentication, we need to extract the subject from the certificate
506
+ # to use as the username. MongoDB Atlas typically uses the full DN as username.
507
+ # The connection string format for X.509 is:
508
+ # mongodb+srv://cluster.mongodb.net/?authSource=$external&authMechanism=MONGODB-X509
509
+ # Extract just the cluster hostname without query parameters
510
+ cluster_host = CLUSTER_URI.split('?')[0]
511
+ self.connection_string = f"mongodb+srv://{cluster_host}?authSource=$external&authMechanism=MONGODB-X509&tls=true&tlsCertificateKeyFile={cert_file}"
512
+ logger.info(f"Using X.509 certificate authentication from {cert_file}")
513
+ else:
514
+ # Fallback to username/password authentication
515
+ user = os.getenv("MONGODB_USER")
516
+ password = os.getenv("MONGODB_PASSWORD")
517
+ if user and password:
518
+ self.connection_string = f"mongodb+srv://{user}:{password}@{CLUSTER_URI}"
519
+ logger.info("Using username/password authentication")
520
+ else:
521
+ # Try connection string as last resort
522
+ self.connection_string = os.getenv("MONGODB_CONNECTION_STRING")
523
+ if not self.connection_string:
524
+ raise ValueError(
525
+ "MongoDB authentication required. Please provide either:\n"
526
+ "1. MONGODB_X509_CERT_FILE - Path to X.509 certificate file\n"
527
+ "2. MONGODB_USER and MONGODB_PASSWORD - Username and password\n"
528
+ "3. MONGODB_CONNECTION_STRING - Full connection string"
529
+ )
530
+
531
+ self.client = _create_mongodb_client_with_retry(self.connection_string)
532
+ self.db: Database = self.client[database_name]
533
+
534
+ # Get environment-specific collection name
535
+ collection_names = get_collection_names()
536
+ self.collection: Collection = self.db[collection_names["mcp_server"]]
537
+ logger.info(f"Using collection: {collection_names['mcp_server']}")
538
+
539
+ # Ensure indexes
540
+ self._ensure_indexes()
541
+
542
+ def _ensure_indexes(self):
543
+ """Ensure indexes exist."""
544
+ existing_indexes = [idx['name'] for idx in self.collection.list_indexes()]
545
+
546
+ # NOTE: Atlas Search and Vector Search indexes must be created through Atlas UI or API
547
+ # See atlas_search_indexes.json and ATLAS_SEARCH_SETUP.md for instructions
548
+
549
+ # Only create regular indexes (not text search)
550
+ if 'name_index' not in existing_indexes:
551
+ self.collection.create_index("name", unique=True, name='name_index')
552
+
553
+ async def add_mcp_server(
554
+ self,
555
+ name: str,
556
+ url: str,
557
+ description: str,
558
+ server_type: str = "streamable-http",
559
+ capabilities: List[str] = None,
560
+ metadata: Dict[str, Any] = None
561
+ ) -> str:
562
+ """Add an MCP server to the registry with retry logic."""
563
+ # Generate embeddings if enabled
564
+ description_embedding = None
565
+ capabilities_embedding = None
566
+
567
+ if os.getenv("ENABLE_EMBEDDINGS", "true").lower() == "true":
568
+ try:
569
+ embedding_service = get_embedding_service()
570
+
571
+ # Generate embedding for description
572
+ if description:
573
+ description_embedding = await embedding_service.generate_embedding(description)
574
+
575
+ # Generate embedding for capabilities (concatenated)
576
+ if capabilities:
577
+ capabilities_text = " ".join(capabilities)
578
+ capabilities_embedding = await embedding_service.generate_embedding(capabilities_text)
579
+
580
+ logger.info(f"Generated embeddings for MCP server {name}")
581
+ except Exception as e:
582
+ logger.warning(f"Failed to generate embeddings: {e}. Proceeding without embeddings.")
583
+
584
+ doc = {
585
+ "name": name,
586
+ "url": url,
587
+ "description": description,
588
+ "server_type": server_type,
589
+ "capabilities": capabilities or [],
590
+ "metadata": metadata or {},
591
+ "registered_at": get_now_in_utc(),
592
+ "is_active": True
593
+ }
594
+
595
+ # Add embeddings if available
596
+ if description_embedding:
597
+ doc["description_embedding"] = description_embedding
598
+ if capabilities_embedding:
599
+ doc["capabilities_embedding"] = capabilities_embedding
600
+
601
+ # Retry database operation with exponential backoff
602
+ max_retries = 3
603
+ for attempt in range(max_retries):
604
+ try:
605
+ # Upsert by name
606
+ result = self.collection.replace_one(
607
+ {"name": name},
608
+ doc,
609
+ upsert=True
610
+ )
611
+
612
+ if result.upserted_id:
613
+ logger.info(f"Added new MCP server {name} to registry")
614
+ else:
615
+ logger.info(f"Updated existing MCP server {name} in registry")
616
+
617
+ return str(result.upserted_id or result.matched_count)
618
+
619
+ except (ServerSelectionTimeoutError, NetworkTimeout, AutoReconnect, Exception) as e:
620
+ error_msg = str(e).lower()
621
+ is_retryable = any(term in error_msg for term in ['ssl', 'tls', 'handshake', 'timeout', 'network', 'connection'])
622
+
623
+ if attempt < max_retries - 1 and is_retryable:
624
+ delay = (2 ** attempt) + (attempt * 0.2) # Exponential backoff with jitter
625
+ logger.warning(f"Database operation attempt {attempt + 1} failed: {e}")
626
+ logger.info(f"Retrying in {delay:.1f} seconds...")
627
+ await asyncio.sleep(delay)
628
+ else:
629
+ logger.error(f"Failed to add MCP server {name} after {max_retries} attempts")
630
+ raise
631
+
632
+
633
+
634
+
635
+
636
+
637
+
638
+
639
+
640
+ async def get_mcp_server(self, name: str) -> Optional[Dict[str, Any]]:
641
+ """Get an MCP server by name."""
642
+ doc = self.collection.find_one({"name": name})
643
+ if doc:
644
+ doc["_id"] = str(doc["_id"])
645
+ return doc
646
+ return None
647
+
648
+ def close(self):
649
+ """Close the connection."""
650
+ self.client.close()
651
+
652
+
653
+ if __name__ == "__main__":
654
+ import asyncio
655
+ import sys
656
+ import uuid
657
+ from dotenv import load_dotenv
658
+
659
+ # Load environment variables from .env file
660
+ load_dotenv()
661
+
662
+ # No additional imports needed
663
+
664
+ # Set up logging
665
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
666
+
667
+ async def test_registries():
668
+ """Test both registries."""
669
+ print("\n=== MongoDB Registry Test ===")
670
+ print(f"Environment: {os.getenv('ENV', 'test')}")
671
+ collection_names = get_collection_names()
672
+ print(f"Collections: {collection_names}")
673
+
674
+ # Test required environment variables
675
+ if not os.getenv("MONGODB_READWRITE_CONNECTION_STRING"):
676
+ if not os.getenv("MONGODB_USER") or not os.getenv("MONGODB_PASSWORD"):
677
+ print("\nERROR: Please set MONGODB_READWRITE_CONNECTION_STRING or MONGODB_USER/PASSWORD environment variables")
678
+ return
679
+
680
+ # Test UtilityAgentRegistry
681
+ print("\n--- Testing UtilityAgentRegistry ---")
682
+ try:
683
+ agent_registry = UtilityAgentRegistry()
684
+ print(f"✓ Connected to MongoDB")
685
+ print(f"✓ Using database: {agent_registry.database_name}")
686
+ print(f"✓ Using collection: {collection_names['utility_agent']}")
687
+
688
+ # Create test agent
689
+ test_agent = UtilityAgent(
690
+ id=str(uuid.uuid4()),
691
+ name="Test Weather Agent",
692
+ description="A test agent for weather analysis",
693
+ mcp_server_id="test-mcp-server-id",
694
+ capabilities=["weather_current", "weather_forecast"],
695
+ tags=["weather", "test"],
696
+ metadata={
697
+ "test": True,
698
+ "created_by": "test_script"
699
+ }
700
+ )
701
+
702
+ # Create test agent with endpoints
703
+ from iatp.utils.iatp_utils import create_iatp_endpoints
704
+ test_agent.endpoints = create_iatp_endpoints("http://weather-agent:8100", supports_streaming=True)
705
+
706
+ # Add to registry
707
+ entry = await agent_registry.add_utility_agent(
708
+ test_agent,
709
+ tags=["weather", "test", "api"]
710
+ )
711
+ print(f"✓ Added test agent: {entry.name} (ID: {entry.agent_id})")
712
+
713
+ # Note: Query methods moved to iatp_search_api.py
714
+ print(f"✓ Agent added successfully - use iatp_search_api.py for queries")
715
+
716
+ # Get statistics
717
+ stats = await agent_registry.get_statistics()
718
+ print(f"✓ Registry statistics: {stats}")
719
+
720
+ # Update health status - use the returned entry's agent_id
721
+ updated = await agent_registry.update_health_status(entry.agent_id)
722
+ print(f"✓ Updated health status: {updated}")
723
+
724
+ agent_registry.close()
725
+
726
+ except Exception as e:
727
+ print(f"✗ UtilityAgentRegistry error: {e}")
728
+ import traceback
729
+ traceback.print_exc()
730
+
731
+ # Test MCPServerRegistry
732
+ print("\n--- Testing MCPServerRegistry ---")
733
+ try:
734
+ # Need to set MONGODB_URI for MCPServerRegistry
735
+ if not os.getenv("MONGODB_URI"):
736
+ os.environ["MONGODB_URI"] = f"mongodb+srv://{os.getenv('MONGODB_USER')}:{os.getenv('MONGODB_PASSWORD')}@{CLUSTER_URI}"
737
+
738
+ mcp_registry = MCPServerRegistry()
739
+ print(f"✓ Connected to MongoDB")
740
+ print(f"✓ Using collection: {collection_names['mcp_server']}")
741
+
742
+ # Add test MCP server
743
+ server_id = await mcp_registry.add_mcp_server(
744
+ name="test-weather-mcp",
745
+ url="http://weather-mcp:8080",
746
+ description="Test weather MCP server",
747
+ server_type="streamable-http",
748
+ capabilities=["weather", "forecast"],
749
+ metadata={"version": "1.0.0"}
750
+ )
751
+ print(f"✓ Added test MCP server: test-weather-mcp")
752
+
753
+ # Note: Query methods moved to iatp_search_api.py
754
+ print(f"✓ MCP server added successfully - use iatp_search_api.py for queries")
755
+
756
+ # Get specific server
757
+ server = await mcp_registry.get_mcp_server("test-weather-mcp")
758
+ if server:
759
+ print(f"✓ Retrieved MCP server: {server['name']}")
760
+
761
+ mcp_registry.close()
762
+
763
+ except Exception as e:
764
+ print(f"✗ MCPServerRegistry error: {e}")
765
+ import traceback
766
+ traceback.print_exc()
767
+
768
+ print("\n=== Test Complete ===")
769
+
770
+ # Run the test
771
+ asyncio.run(test_registries())