agent-mcp 0.1.1__py3-none-any.whl → 0.1.3__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,700 @@
1
+ """
2
+ MCP Transport Layer - Handles communication between MCP agents.
3
+
4
+ This module provides the transport layer for the Model Context Protocol (MCP),
5
+ enabling agents to communicate over HTTP and SSE.
6
+ """
7
+
8
+ from abc import ABC, abstractmethod
9
+ import asyncio
10
+ import json
11
+ import aiohttp # Added this line
12
+ from typing import Dict, Any, Optional, Callable, AsyncGenerator, Tuple
13
+ from aiohttp import web, ClientSession, TCPConnector, ClientTimeout, ClientConnectorError, ClientPayloadError
14
+ from fastapi import FastAPI, Request
15
+ import uvicorn
16
+ from threading import Thread
17
+ import traceback
18
+ import logging
19
+ from collections import deque
20
+ import time
21
+ from datetime import datetime, timezone, timedelta
22
+ from dateutil.parser import isoparse
23
+
24
+ # Configure logging
25
+ logging.basicConfig(
26
+ level=logging.INFO,
27
+ format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
28
+ )
29
+ logger = logging.getLogger(__name__)
30
+
31
+ class MCPTransport(ABC):
32
+ """Base transport layer for MCP communication"""
33
+
34
+ @abstractmethod
35
+ async def send_message(self, target: str, message: Dict[str, Any]) -> Dict[str, Any]:
36
+ """Send a message to another agent"""
37
+ pass
38
+
39
+ @abstractmethod
40
+ async def receive_message(self) -> Tuple[Dict[str, Any], str]:
41
+ """Receive a message from another agent"""
42
+ pass
43
+
44
+ class HTTPTransport(MCPTransport):
45
+ """HTTP transport layer for MCP communication.
46
+
47
+ This class implements the MCPTransport interface using HTTP and SSE for
48
+ communication between agents. It provides:
49
+
50
+ - HTTP Endpoints: REST API for message exchange
51
+ - SSE Support: Real-time event streaming for continuous updates
52
+ - Connection Management: Handles connection lifecycle and reconnection
53
+ - Message Queueing: Buffers messages for reliable delivery
54
+ - Error Recovery: Robust error handling and automatic retries
55
+
56
+ The transport can operate in two modes:
57
+ 1. Server Mode: Runs a local HTTP server (when is_remote=False)
58
+ 2. Client Mode: Connects to remote server (when is_remote=True)
59
+ """
60
+
61
+ def __init__(self, host: str = "localhost", port: int = 8000, poll_interval: int = 2):
62
+ """
63
+ Initialize the HTTP transport.
64
+
65
+ Args:
66
+ host: Host to bind to
67
+ port: Port to bind to
68
+ poll_interval: How often to poll the server in seconds
69
+ """
70
+ self.host = host
71
+ self.port = port
72
+ self.app = FastAPI()
73
+ self.app.post("/message")(self._handle_message)
74
+ self.message_queue = asyncio.Queue()
75
+ self.message_handler: Optional[Callable] = None
76
+ self.server_thread = None
77
+ self.is_remote = False
78
+ self.remote_url = None
79
+ self.agent_name = None
80
+ self.token = None
81
+ self.auth_token = None
82
+ self.last_message_id = None # Track last seen message ID
83
+ self._stop_polling_event = asyncio.Event() # Event to signal polling loop to stop
84
+ self._polling_task = None # To hold the polling task
85
+ self._client_session = None # Shared aiohttp client session
86
+ self._recently_acked_ids = deque(maxlen=500) # Track message IDs
87
+ self._seen_task_ids = deque(maxlen=500) # Track task IDs across polls
88
+ self.poll_interval = poll_interval
89
+
90
+ def get_url(self) -> str:
91
+ """Get the URL for this transport"""
92
+ if hasattr(self, 'is_remote') and self.is_remote:
93
+ return self.remote_url
94
+ return f"http://{self.host}:{self.port}"
95
+
96
+ @classmethod
97
+ def from_url(cls, url: str, agent_name: Optional[str] = None, token: Optional[str] = None) -> 'HTTPTransport':
98
+ """Create a transport instance from a URL.
99
+
100
+ Args:
101
+ url: The URL to connect to (e.g., 'https://mcp-server-ixlfhxquwq-ew.a.run.app')
102
+ agent_name: The name of the agent this transport is for (used for event stream)
103
+ token: The JWT token for authenticating the event stream connection (can be set later)
104
+
105
+ Returns:
106
+ An HTTPTransport instance configured for the URL
107
+ """
108
+ # For remote URLs, we don't need to start a local server
109
+ transport = cls(poll_interval=2) # Set default poll interval
110
+ transport.remote_url = url
111
+ transport.is_remote = True
112
+ transport.agent_name = agent_name # Store agent name
113
+ transport.token = token # Store token (might be None initially)
114
+
115
+ # DO NOT start event stream connection here, wait for start_event_stream() call
116
+
117
+ return transport
118
+
119
+ async def _handle_message(self, request: Request):
120
+ """Handle incoming HTTP messages"""
121
+ try:
122
+ message = await request.json()
123
+ # Use None as message_id since this is direct HTTP
124
+ await self.message_queue.put((message, None))
125
+ return {"status": "ok"}
126
+ except Exception as e:
127
+ return {"status": "error", "message": str(e)}
128
+
129
+ async def _ensure_session(self, force_reconnect: bool = False) -> None:
130
+ """Ensure we have a valid client session.
131
+
132
+ Args:
133
+ force_reconnect: If True, create a new session even if one exists
134
+ """
135
+ if force_reconnect or not self._client_session or self._client_session.closed:
136
+ if self._client_session and not self._client_session.closed:
137
+ await self._client_session.close()
138
+
139
+ # Create new session with proper headers
140
+ headers = {"Authorization": f"Bearer {self.token}"} if self.token else {}
141
+ self._client_session = aiohttp.ClientSession(
142
+ connector=aiohttp.TCPConnector(verify_ssl=False),
143
+ headers=headers
144
+ )
145
+ logger.info(f"[{self.agent_name}] Created new client session")
146
+
147
+ async def _poll_for_messages(self) -> None:
148
+ """Poll for messages from the server.
149
+
150
+ This method runs in a loop, polling the server for new messages.
151
+ It handles reconnection and error recovery.
152
+ """
153
+ retry_count = 0
154
+ max_retries = 5
155
+ base_delay = 1.0 # Base delay in seconds
156
+ max_delay = 30.0 # Maximum delay in seconds
157
+
158
+ while not self._stop_polling_event.is_set():
159
+ try:
160
+ # Ensure we have a valid session
161
+ await self._ensure_session()
162
+
163
+ # Create headers with authentication token
164
+ headers = {}
165
+ if self.token:
166
+ headers["Authorization"] = f"Bearer {self.token}"
167
+
168
+ # Poll for messages with authentication headers
169
+ async with self._client_session.get(
170
+ f"{self.remote_url}/messages/{self.agent_name}",
171
+ headers=headers
172
+ ) as response:
173
+ if response.status == 200:
174
+ data = await response.json()
175
+ logger.info(f"[{self.agent_name}] Raw server response: {json.dumps(data, indent=2)}")
176
+
177
+ # Extract messages from the response body
178
+ messages = []
179
+ if isinstance(data, dict):
180
+ body = data.get('body', '[]')
181
+ try:
182
+ messages = json.loads(body)
183
+ logger.info(f"[{self.agent_name}] Parsed messages from body: {json.dumps(messages, indent=2)}")
184
+ except json.JSONDecodeError:
185
+ logger.warning(f"[{self.agent_name}] Failed to parse messages from body: {body}")
186
+ messages = []
187
+
188
+ if messages:
189
+ # Sort messages by timestamp before processing
190
+ messages.sort(key=lambda x: x.get('timestamp', ''))
191
+
192
+ # Clear old messages from the queue to prevent buildup
193
+ while not self.message_queue.empty():
194
+ try:
195
+ self.message_queue.get_nowait()
196
+ self.message_queue.task_done()
197
+ except asyncio.QueueEmpty:
198
+ break
199
+
200
+ logger.info(f"[{self.agent_name}] Processing {len(messages)} messages")
201
+ for msg in messages:
202
+ try:
203
+ # Validate message format
204
+ if not isinstance(msg, dict):
205
+ logger.warning(f"[{self.agent_name}] Invalid message format: {msg}")
206
+ continue
207
+
208
+ # Extract message ID and content
209
+ message_id = msg.get('id')
210
+ message_content = msg.get('content')
211
+
212
+ # Skip if we've seen this message before - check BEFORE processing
213
+ if message_id in self._seen_task_ids:
214
+ logger.debug(f"[{self.agent_name}] Message {message_id} already processed. Skipping.")
215
+ continue
216
+
217
+ # Add to seen messages BEFORE processing
218
+ self._seen_task_ids.append(message_id)
219
+
220
+ # Standardize message content format
221
+ if isinstance(message_content, str):
222
+ message_content = {'text': message_content}
223
+ msg['content'] = message_content
224
+ elif isinstance(message_content, dict):
225
+ if message_content.get('type') == 'task':
226
+ # Preserve task structure
227
+ pass
228
+ elif 'text' not in message_content:
229
+ # Wrap non-task dictionaries that don't have a text field
230
+ message_content = {'text': json.dumps(message_content)}
231
+ msg['content'] = message_content
232
+
233
+ logger.info(f"[{self.agent_name}] Processing message - ID: {message_id}, Content: {json.dumps(message_content, indent=2)}")
234
+
235
+ # Add message to queue for processing
236
+ await self.message_queue.put((msg, message_id))
237
+ logger.info(f"[{self.agent_name}] Added message to queue: {message_id}")
238
+ except Exception as e:
239
+ logger.error(f"[{self.agent_name}] Error processing message: {e}")
240
+ continue
241
+ else:
242
+ logger.debug(f"[{self.agent_name}] No new messages")
243
+ else:
244
+ logger.warning(f"[{self.agent_name}] Server returned status {response.status}")
245
+ if response.status == 401:
246
+ # Authentication error - try to reauthenticate
247
+ await self._ensure_session(force_reconnect=True)
248
+ elif response.status >= 500:
249
+ # Server error - use exponential backoff
250
+ retry_count += 1
251
+ if retry_count < max_retries:
252
+ delay = min(base_delay * (2 ** retry_count), max_delay)
253
+ logger.warning(f"[{self.agent_name}] Server error, retrying in {delay}s...")
254
+ await asyncio.sleep(delay)
255
+ continue
256
+
257
+ # Reset retry count on successful poll
258
+ retry_count = 0
259
+ await asyncio.sleep(self.poll_interval)
260
+
261
+ except asyncio.CancelledError:
262
+ logger.info(f"[{self.agent_name}] Polling task cancelled")
263
+ break
264
+ except Exception as e:
265
+ logger.error(f"[{self.agent_name}] Error in polling task: {e}")
266
+ retry_count += 1
267
+ if retry_count < max_retries:
268
+ delay = min(base_delay * (2 ** retry_count), max_delay)
269
+ logger.warning(f"[{self.agent_name}] Error occurred, retrying in {delay}s...")
270
+ await asyncio.sleep(delay)
271
+ else:
272
+ logger.error(f"[{self.agent_name}] Max retries reached, stopping polling")
273
+ break
274
+
275
+ logger.info(f"[{self.agent_name}] Polling task stopped")
276
+
277
+ async def start_polling(self, poll_interval: int = 2):
278
+ """Starts the background message polling task."""
279
+ # Set connection time before polling starts, ensuring we use UTC
280
+ self._connection_time = datetime.utcnow().replace(tzinfo=timezone.utc)
281
+ self.last_message_id = None # Also reset message tracking here
282
+
283
+ if not self.is_remote:
284
+ logger.warning("Polling is only applicable in remote mode. Agent: {self.agent_name}")
285
+ return
286
+
287
+ if not self.agent_name or not self.auth_token:
288
+ logger.error("Cannot start polling without agent_name and auth_token. Agent: {self.agent_name}")
289
+ raise ValueError("Agent name and authentication token must be set before starting polling.")
290
+
291
+ if self._polling_task and not self._polling_task.done():
292
+ logger.info(f"Polling task already running for agent: {self.agent_name}")
293
+ return
294
+
295
+ # Ensure stop event is clear before starting
296
+ self._stop_polling_event.clear()
297
+
298
+ # Create client session if it doesn't exist or is closed
299
+ if self._client_session is None or self._client_session.closed:
300
+ # Configure timeout (e.g., 30 seconds total timeout)
301
+ timeout = aiohttp.ClientTimeout(total=30)
302
+ # Disable SSL verification if needed (use cautiously)
303
+ connector = aiohttp.TCPConnector(ssl=False) # Or ssl=True for verification
304
+ self._client_session = aiohttp.ClientSession(connector=connector, timeout=timeout)
305
+ logger.debug(f"Created new ClientSession for agent: {self.agent_name}")
306
+
307
+ logger.info(f"Starting polling task for agent: {self.agent_name} with interval {poll_interval}s")
308
+ self._polling_task = asyncio.create_task(self._poll_for_messages())
309
+
310
+
311
+ async def connect(self, agent_name: Optional[str] = None, token: Optional[str] = None, poll_interval: int = 2):
312
+ """Connects to the remote server and starts polling for messages.
313
+
314
+ This method should be called when in remote mode (is_remote=True).
315
+ It sets the agent name and token if provided, and starts the background
316
+ polling task.
317
+
318
+ Args:
319
+ agent_name: The name of the agent to poll messages for. Overrides existing if provided.
320
+ token: The JWT token for authentication. Overrides existing if provided.
321
+ poll_interval: How often to poll the server in seconds.
322
+ """
323
+ self.last_message_id = None # Reset message tracking on new connection
324
+
325
+ if not self.is_remote:
326
+ logger.warning("connect() called but transport is not in remote mode. Did you mean start()?)")
327
+ return
328
+
329
+ if agent_name:
330
+ self.agent_name = agent_name
331
+ if token:
332
+ self.token = token
333
+
334
+ if not self.agent_name or not self.token:
335
+ logger.error("Cannot connect: agent_name or token is missing.")
336
+ raise ValueError("Agent name and token must be set before connecting.")
337
+
338
+ if self._polling_task and not self._polling_task.done():
339
+ logger.warning(f"[{self.agent_name}] connect() called but polling task is already running.")
340
+ return
341
+
342
+ # Reset the stop event before starting
343
+ self._stop_polling_event.clear()
344
+
345
+ logger.info(f"[{self.agent_name}] Creating and starting polling task.")
346
+ self._polling_task = asyncio.create_task(self._poll_for_messages(), name=f"poll_messages_{self.agent_name}")
347
+ # Add error handling for task creation?
348
+
349
+ async def disconnect(self):
350
+ """Disconnects from the remote server and stops polling for messages.
351
+
352
+ This method signals the background polling task to stop and waits for it
353
+ to complete.
354
+ """
355
+ if not self.is_remote:
356
+ logger.warning("disconnect() called but transport is not in remote mode. Did you mean stop()?)")
357
+ return
358
+
359
+ if self._polling_task and not self._polling_task.done():
360
+ logger.info(f"[{self.agent_name}] Signaling polling task to stop.")
361
+ self._stop_polling_event.set()
362
+ try:
363
+ # Wait for the task to finish gracefully
364
+ await asyncio.wait_for(self._polling_task, timeout=10.0)
365
+ logger.info(f"[{self.agent_name}] Polling task finished gracefully.")
366
+ except asyncio.TimeoutError:
367
+ logger.warning(f"[{self.agent_name}] Polling task did not finish in time, cancelling.")
368
+ self._polling_task.cancel()
369
+ try:
370
+ await self._polling_task # Await cancellation
371
+ except asyncio.CancelledError:
372
+ logger.info(f"[{self.agent_name}] Polling task successfully cancelled.")
373
+ except Exception as e:
374
+ logger.error(f"[{self.agent_name}] Error occurred while waiting for polling task: {e}")
375
+ finally:
376
+ self._polling_task = None # Clear the task reference
377
+ else:
378
+ logger.info(f"[{self.agent_name}] disconnect() called but no active polling task found.")
379
+
380
+ # Ensure session is explicitly closed here *after* the polling task has stopped
381
+ if self._client_session and not self._client_session.closed:
382
+ logger.info(f"[{self.agent_name}] Closing client session in disconnect.")
383
+ await self._client_session.close()
384
+ self._client_session = None
385
+ else:
386
+ logger.debug(f"[{self.agent_name}] Client session already closed or None in disconnect.")
387
+
388
+ # --- Message Sending ---
389
+ async def send_message(self, target: str, message: Dict[str, Any]):
390
+ """Send a message to another agent."""
391
+ try:
392
+ # Ensure message has proper structure
393
+ if isinstance(message, dict) and 'content' not in message:
394
+ message = {
395
+ "type": message.get("type", "message"),
396
+ "content": message,
397
+ "reply_to": message.get("reply_to", f"{self.remote_url}/message/{self.agent_name}")
398
+ }
399
+
400
+ # Create a ClientSession with optimized settings
401
+ timeout = aiohttp.ClientTimeout(total=55) # 55s timeout (Cloud Run's limit is 60s)
402
+ async with ClientSession(
403
+ connector=TCPConnector(verify_ssl=False),
404
+ timeout=timeout
405
+ ) as session:
406
+ try:
407
+ # --- FIX: Parse target if it looks like a full URL ---
408
+ parsed_target = target
409
+ if "://" in target:
410
+ try:
411
+ # Extract the last part of the path as the agent name
412
+ parsed_target = target.split('/')[-1]
413
+ if not parsed_target: # Handle trailing slash case
414
+ parsed_target = target.split('/')[-2]
415
+ logger.info(f"[{self.agent_name}] Parsed target URL '{target}' to agent name '{parsed_target}'")
416
+ except IndexError:
417
+ logger.warning(f"[{self.agent_name}] Could not parse agent name from target URL '{target}', using original.")
418
+ parsed_target = target # Fallback to original if parsing fails
419
+
420
+ # Construct the URL using the potentially parsed target
421
+ url = f"{self.remote_url}/message/{parsed_target}"
422
+
423
+ headers = {"Authorization": f"Bearer {self.token}"}
424
+ logger.info(f"[{self.agent_name}] Sending message to {url} (original target was '{target}')")
425
+
426
+ async with session.post(url, json=message, headers=headers) as response:
427
+ response_text = await response.text()
428
+ try:
429
+ response_data = json.loads(response_text)
430
+ except json.JSONDecodeError:
431
+ response_data = {"status": "error", "message": response_text}
432
+
433
+ if response.status != 200:
434
+ logger.error(f"[{self.agent_name}] Error sending message: {response.status}")
435
+ logger.error(f"[{self.agent_name}] Response: {response_data}")
436
+ return {"status": "error", "code": response.status, "message": response_data}
437
+
438
+ logger.info(f"[{self.agent_name}] sent this Message : {response_data} successfully")
439
+
440
+ # Handle body parsing if present
441
+ if isinstance(response_data, dict):
442
+ if 'body' in response_data:
443
+ try:
444
+ # Attempt to parse the body string as JSON
445
+ parsed_body = json.loads(response_data['body'])
446
+ if isinstance(parsed_body, list):
447
+ response_data['body'] = parsed_body
448
+ logger.info(f"[{self.agent_name}] Successfully parsed message body as JSON list.")
449
+ else:
450
+ logger.info(f"[{self.agent_name}] Message body is not a list: {type(parsed_body)}")
451
+ except json.JSONDecodeError as e:
452
+ logger.info(f"[{self.agent_name}] Failed to decode message body as JSON: {e}")
453
+
454
+ # Queue task messages
455
+ if response_data.get('type') == 'task':
456
+ message_id = response_data.get('message_id')
457
+ logger.info(f"[{self.agent_name}] Queueing task message {message_id}")
458
+ await self.message_queue.put((response_data, message_id))
459
+
460
+ return response_data
461
+ except Exception as e:
462
+ logger.error(f"[{self.agent_name}] Error sending message: {e}")
463
+ return {"status": "error", "message": str(e)}
464
+ except Exception as e:
465
+ logger.error(f"[{self.agent_name}] Error in send_message: {e}")
466
+ return {"status": "error", "message": str(e)}
467
+
468
+ async def acknowledge_message(self, target: str, message_id: str):
469
+ """Acknowledge receipt of a message"""
470
+ if not self.is_remote:
471
+ # Return True because there's nothing to acknowledge locally
472
+ logger.debug(f"[{self.agent_name}] No remote server configured. Skipping acknowledgment for message ID: {message_id}")
473
+ return True
474
+
475
+ if not self.agent_name or not self.token:
476
+ logger.error(f"Cannot acknowledge message: Missing agent name or token")
477
+ return False
478
+
479
+ ack_url = f"{self.remote_url}/message/{self.agent_name}/acknowledge/{message_id}"
480
+ headers = {"Authorization": f"Bearer {self.token}"}
481
+
482
+ logger.info(f"[{self.agent_name}] Attempting to acknowledge message {message_id} to {ack_url}")
483
+
484
+ # Check if already recently acknowledged
485
+ if message_id in self._recently_acked_ids:
486
+ logger.debug(f"[{self.agent_name}] Message {message_id} already recently acknowledged. Skipping redundant ack.")
487
+ return True # Treat as success, as it was likely acked before
488
+
489
+ if not self._client_session or self._client_session.closed:
490
+ logger.error(f"[{self.agent_name}] Cannot acknowledge message {message_id}: Client session is not available or closed.")
491
+ return False
492
+
493
+ try:
494
+ # Use the shared client session
495
+ async with self._client_session.post(ack_url, headers=headers) as response:
496
+ if response.status == 200:
497
+ logger.info(f"[{self.agent_name}] Successfully acknowledged message {message_id}")
498
+ self._recently_acked_ids.append(message_id)
499
+ return True
500
+ else:
501
+ response_text = await response.text()
502
+ logger.error(f"[{self.agent_name}] Failed to acknowledge message {message_id}. Status: {response.status}, Response: {response_text}")
503
+ return False
504
+ except Exception as e:
505
+ logger.error(f"[{self.agent_name}] Error acknowledging message {message_id}: {e}")
506
+ return False
507
+
508
+ async def receive_message(self, timeout: float = 5.0) -> Tuple[Optional[Dict[str, Any]], Optional[str]]:
509
+ """Receive a message fetched by the polling task.
510
+
511
+ Waits for a message from the internal queue with a timeout.
512
+ Checks if the polling task is still active.
513
+
514
+ Args:
515
+ timeout (float): Maximum time to wait for a message in seconds.
516
+
517
+ Returns:
518
+ A tuple containing the message dictionary and its ID, or (None, None)
519
+ if no message is received within the timeout, the polling task
520
+ has stopped, or an error occurs.
521
+ """
522
+ # Check if polling is active before waiting
523
+ if not self._polling_task or self._polling_task.done():
524
+ # If polling task is not running or finished, try to restart it
525
+ logger.warning(f"[{self.agent_name}] Polling task inactive, attempting to restart...")
526
+ try:
527
+ await self.start_polling()
528
+ # Wait a bit for polling to start
529
+ await asyncio.sleep(1)
530
+ except Exception as e:
531
+ logger.error(f"[{self.agent_name}] Failed to restart polling: {e}")
532
+ return None, None
533
+
534
+ try:
535
+ # Wait for a message from the queue with a timeout
536
+ if timeout > 0:
537
+ logger.info(f"[{self.agent_name}] Waiting for message from queue (timeout={timeout}s)...")
538
+ try:
539
+ message, message_id = await asyncio.wait_for(self.message_queue.get(), timeout=timeout)
540
+ logger.info(f"[{self.agent_name}] Received message from queue: {json.dumps(message, indent=2)}")
541
+ except asyncio.TimeoutError:
542
+ logger.info(f"[{self.agent_name}] Timeout waiting for message. Returning None.")
543
+ return None, None
544
+ else:
545
+ # Non-blocking get if timeout is 0
546
+ try:
547
+ message, message_id = self.message_queue.get_nowait()
548
+ except asyncio.QueueEmpty:
549
+ logger.info(f"[{self.agent_name}] Queue empty on get_nowait. Returning None.")
550
+ return None, None
551
+
552
+ # Validate message before returning
553
+ if message and isinstance(message, dict):
554
+ # More lenient validation - only check for essential fields
555
+ if 'content' in message or 'text' in message or 'description' in message:
556
+ logger.info(f"[{self.agent_name}] Message validation passed, returning message with ID: {message_id}")
557
+ # Mark task done *after* successful retrieval and validation
558
+ self.message_queue.task_done()
559
+ # Acknowledge the message after successfully receiving it
560
+ if message.get('from') and message_id:
561
+ await self.acknowledge_message(message.get('from'), message_id)
562
+ return message, message_id
563
+ else:
564
+ logger.warning(f"[{self.agent_name}] Message missing required 'content' field. Message: {message}")
565
+ else:
566
+ logger.warning(f"[{self.agent_name}] Invalid message format. Message: {message}")
567
+
568
+ # Mark task as done even if validation failed
569
+ self.message_queue.task_done()
570
+ return None, None
571
+
572
+ except asyncio.CancelledError:
573
+ logger.info(f"[{self.agent_name}] receive_message task cancelled.")
574
+ raise
575
+ except Exception as e:
576
+ logger.error(f"[{self.agent_name}] Error receiving message: {e}")
577
+ traceback.print_exc()
578
+ try:
579
+ self.message_queue.task_done()
580
+ except ValueError:
581
+ pass
582
+ except Exception as inner_e:
583
+ logger.error(f"[{self.agent_name}] Error calling task_done in exception handler: {inner_e}")
584
+ return None, None
585
+
586
+ # Legacy method - replaced by new acknowledge_message with target parameter
587
+ async def _legacy_acknowledge_message(self, message_id: str):
588
+ """Legacy method to acknowledge a message"""
589
+ if not self.remote_url or not self.agent_name or not self.token:
590
+ print(f"[{self.agent_name}] Cannot acknowledge message: Missing remote URL, agent name, or token.")
591
+ return
592
+
593
+ ack_url = f"{self.remote_url}/message/{self.agent_name}/ack"
594
+ headers = {"Authorization": f"Bearer {self.token}"}
595
+ payload = {"message_id": message_id}
596
+
597
+ print(f"[{self.agent_name}] Acknowledging message {message_id} to {ack_url}")
598
+ try:
599
+ # Use a new session for acknowledgement
600
+ async with ClientSession(
601
+ connector=TCPConnector(verify_ssl=False), # Adjust SSL verification as needed
602
+ timeout=ClientTimeout(total=10) # Add a reasonable timeout
603
+ ) as session:
604
+ async with session.post(ack_url, headers=headers, json=payload) as response:
605
+ if response.status == 200:
606
+ print(f"[{self.agent_name}] Successfully acknowledged message {message_id}.")
607
+ else:
608
+ print(f"[{self.agent_name}] Failed to acknowledge message {message_id}. Status: {response.status}, Response: {await response.text()}")
609
+ except Exception as e:
610
+ print(f"[{self.agent_name}] Error acknowledging message {message_id}: {e}")
611
+
612
+ def start(self):
613
+ """Starts the local HTTP server (if not in remote mode).
614
+
615
+ This method initializes and starts a local HTTP server for handling agent
616
+ communication when operating in local mode. In remote mode, use connect()
617
+ instead.
618
+
619
+ The server runs in a separate daemon thread to avoid blocking the main
620
+ application thread.
621
+ """
622
+ # Skip starting local server if we're in remote mode
623
+ if hasattr(self, 'is_remote') and self.is_remote:
624
+ logger.info(f"[{self.agent_name or 'Unknown'}] In remote mode. Call connect() to start polling.")
625
+ return
626
+
627
+ def run_server():
628
+ uvicorn.run(self.app, host=self.host, port=self.port)
629
+
630
+ self.server_thread = Thread(target=run_server, daemon=True)
631
+ self.server_thread.start()
632
+
633
+ def stop(self):
634
+ """Stops the local HTTP server (if running).
635
+
636
+ This method gracefully shuts down the local HTTP server when operating in
637
+ local mode. For remote connections, use disconnect() instead.
638
+
639
+ The method ensures proper cleanup of server resources and thread termination.
640
+ """
641
+ if self.is_remote:
642
+ logger.info(f"[{self.agent_name or 'Unknown'}] In remote mode. Call disconnect() to stop polling.")
643
+ pass
644
+ elif self.server_thread:
645
+ logger.info(f"Stopping local server thread (implementation pending)...")
646
+ pass
647
+
648
+ def set_message_handler(self, handler: Callable):
649
+ """Set a handler for incoming messages.
650
+
651
+ This method registers a callback function to process incoming messages.
652
+ The handler will be called for each message received by the transport.
653
+
654
+ Args:
655
+ handler: Function to handle incoming messages. Should accept a message
656
+ dictionary as its argument.
657
+ """
658
+ self.message_handler = handler
659
+
660
+ async def register_agent(self, agent) -> Dict[str, Any]:
661
+ """Register an agent with the remote server.
662
+
663
+ This method registers an agent with the remote MCP server, providing the
664
+ server with information about the agent's capabilities and configuration.
665
+
666
+ Args:
667
+ agent: The MCPAgent instance to register
668
+
669
+ Returns:
670
+ Dict containing the server's response
671
+
672
+ Raises:
673
+ ValueError: If called in local mode
674
+ ClientError: If there are network or connection issues
675
+ """
676
+ if not hasattr(self, 'is_remote') or not self.is_remote:
677
+ raise ValueError("register_agent can only be used with remote servers")
678
+
679
+ # Create a ClientSession with SSL verification disabled
680
+ async with ClientSession(
681
+ connector=TCPConnector(verify_ssl=False)
682
+ ) as session:
683
+ try:
684
+ registration_data = {
685
+ "agent_id": agent.name,
686
+ "info": {
687
+ "name": agent.name,
688
+ "system_message": agent.system_message if hasattr(agent, 'system_message') else "",
689
+ "capabilities": agent.capabilities if hasattr(agent, 'capabilities') else []
690
+ }
691
+ }
692
+
693
+ async with session.post(
694
+ f"{self.remote_url}/register",
695
+ json=registration_data
696
+ ) as response:
697
+ return await response.json()
698
+ except Exception as e:
699
+ print(f"Error registering agent: {e}")
700
+ return {"status": "error", "message": str(e)}