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.
- agent_mcp/__init__.py +16 -0
- agent_mcp/crewai_mcp_adapter.py +281 -0
- agent_mcp/enhanced_mcp_agent.py +601 -0
- agent_mcp/heterogeneous_group_chat.py +424 -0
- agent_mcp/langchain_mcp_adapter.py +325 -0
- agent_mcp/langgraph_mcp_adapter.py +325 -0
- agent_mcp/mcp_agent.py +632 -0
- agent_mcp/mcp_decorator.py +257 -0
- agent_mcp/mcp_langgraph.py +733 -0
- agent_mcp/mcp_transaction.py +97 -0
- agent_mcp/mcp_transport.py +700 -0
- agent_mcp/mcp_transport_enhanced.py +46 -0
- agent_mcp/proxy_agent.py +24 -0
- agent_mcp-0.1.3.dist-info/METADATA +331 -0
- agent_mcp-0.1.3.dist-info/RECORD +18 -0
- agent_mcp-0.1.3.dist-info/top_level.txt +1 -0
- agent_mcp-0.1.1.dist-info/METADATA +0 -474
- agent_mcp-0.1.1.dist-info/RECORD +0 -5
- agent_mcp-0.1.1.dist-info/top_level.txt +0 -1
- {agent_mcp-0.1.1.dist-info → agent_mcp-0.1.3.dist-info}/WHEEL +0 -0
- {agent_mcp-0.1.1.dist-info → agent_mcp-0.1.3.dist-info}/entry_points.txt +0 -0
|
@@ -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)}
|