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/mcp_agent.py ADDED
@@ -0,0 +1,632 @@
1
+ """
2
+ MCPAgent - An AutoGen agent with Model Context Protocol capabilities.
3
+
4
+ This module provides a transparent implementation of the Model Context Protocol
5
+ for AutoGen agents, allowing them to standardize context provision to LLMs and
6
+ interact with other MCP-capable systems with minimal configuration.
7
+
8
+ The Model Context Protocol (MCP) is a standardized way for AI agents to share and
9
+ manage context information. This implementation extends AutoGen's ConversableAgent
10
+ to provide MCP capabilities including:
11
+
12
+ - Context Management: Store and retrieve contextual information
13
+ - Tool Registration: Register and manage MCP-compatible tools
14
+ - Standardized Communication: Interact with other MCP agents seamlessly
15
+ - Task Tracking: Track completed tasks for idempotency
16
+
17
+ Example:
18
+ >>> agent = MCPAgent(name="my_agent")
19
+ >>> agent.register_mcp_tool(name="my_tool", description="Does something", func=my_func)
20
+ >>> agent.context_set("key", "value")
21
+ >>> context = agent.context_get("key")
22
+
23
+ Attributes:
24
+ context_store (Dict): Central store for agent's contextual information
25
+ mcp_tools (Dict): Registry of MCP-compatible tools available to the agent
26
+ mcp_id (str): Unique identifier for this MCP agent instance
27
+ mcp_version (str): Version of MCP protocol implemented
28
+ completed_task_ids (set): Set of completed task IDs for idempotency
29
+ """
30
+
31
+ import json
32
+ import uuid
33
+ import inspect
34
+ from typing import Any, Dict, List, Optional, Union, Callable, Tuple
35
+ import logging
36
+ import asyncio
37
+
38
+ # Setup basic logging
39
+ logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
40
+ logger = logging.getLogger(__name__)
41
+
42
+ # Import AutoGen
43
+ from autogen import ConversableAgent, Agent
44
+
45
+
46
+ class MCPAgent(ConversableAgent):
47
+ """
48
+ An AutoGen agent with Model Context Protocol capabilities.
49
+
50
+ This agent extends the ConversableAgent to implement the Model Context Protocol,
51
+ enabling standardized context provision to LLMs and seamless interaction with
52
+ other MCP-capable systems.
53
+
54
+ Attributes:
55
+ context_store (Dict): Store for the agent's current context
56
+ mcp_tools (Dict): Dictionary of MCP tools available to this agent
57
+ mcp_id (str): Unique identifier for this MCP agent
58
+ mcp_version (str): The MCP version implemented by this agent
59
+ completed_task_ids (set): Set of completed task IDs for idempotency
60
+ transport (Any): Optional transport layer for MCP communication
61
+ """
62
+
63
+ def __init__(
64
+ self,
65
+ name: str,
66
+ system_message: Optional[str] = None,
67
+ is_termination_msg: Optional[Callable[[Dict], bool]] = None,
68
+ max_consecutive_auto_reply: Optional[int] = None,
69
+ human_input_mode: str = "NEVER",
70
+ transport: Optional[Any] = None,
71
+ **kwargs,
72
+ ):
73
+ """
74
+ Initialize an MCPAgent.
75
+
76
+ Args:
77
+ name: The name of the agent
78
+ system_message: System message for the agent
79
+ is_termination_msg: Function to determine if a message should terminate a conversation
80
+ max_consecutive_auto_reply: Maximum number of consecutive automated replies
81
+ human_input_mode: Human input mode setting
82
+ transport: Optional transport layer for MCP communication
83
+ **kwargs: Additional keyword arguments passed to ConversableAgent
84
+ """
85
+ if system_message is None:
86
+ system_message = (
87
+ "You are an AI assistant that follows the Model Context Protocol (MCP). "
88
+ "You can access and manipulate context through the provided MCP tools. "
89
+ "Use these tools to enhance your responses with relevant information."
90
+ )
91
+
92
+ # Initialize ConversableAgent without transport
93
+ super().__init__(
94
+ name=name,
95
+ system_message=system_message,
96
+ is_termination_msg=is_termination_msg,
97
+ max_consecutive_auto_reply=max_consecutive_auto_reply,
98
+ human_input_mode=human_input_mode,
99
+ **kwargs,
100
+ )
101
+
102
+ # MCP specific attributes
103
+ self.context_store = {}
104
+ self.mcp_tools = {}
105
+ self.mcp_id = str(uuid.uuid4())
106
+ self.mcp_version = "0.1.0" # MCP version implemented
107
+ self.completed_task_ids = set() # Set of completed task IDs for idempotency
108
+ self.transport = transport # Store transport at MCPAgent level
109
+
110
+ # Register default MCP tools
111
+ self._register_default_mcp_tools()
112
+
113
+ def _register_default_mcp_tools(self):
114
+ """Register default MCP tools that are available to all MCP agents."""
115
+
116
+ # Context management tools
117
+ def context_get(key: str) -> Dict:
118
+ """Get a context item by key."""
119
+ return self._mcp_context_get(key)
120
+
121
+ def context_set(key: str, value: Any) -> Dict:
122
+ """Set a context item with the given key and value."""
123
+ return self._mcp_context_set(key, value)
124
+
125
+ def context_list() -> Dict:
126
+ """List all available context keys."""
127
+ return self._mcp_context_list()
128
+
129
+ def context_remove(key: str) -> Dict:
130
+ """Remove a context item by key."""
131
+ return self._mcp_context_remove(key)
132
+
133
+ def mcp_info() -> Dict:
134
+ """Get information about this MCP agent's capabilities."""
135
+ return self._mcp_info()
136
+
137
+ # Register the tools with valid names for AutoGen (only letters, numbers, underscore, dash)
138
+ self.register_mcp_tool(
139
+ name="context_get",
140
+ description="Get a specific context item by key",
141
+ func=context_get,
142
+ key_description="The key of the context item to retrieve"
143
+ )
144
+
145
+ self.register_mcp_tool(
146
+ name="context_set",
147
+ description="Set a context item with the given key and value",
148
+ func=context_set,
149
+ key_description="The key to store the value under",
150
+ value_description="The value to store"
151
+ )
152
+
153
+ self.register_mcp_tool(
154
+ name="context_list",
155
+ description="List all available context keys",
156
+ func=context_list
157
+ )
158
+
159
+ self.register_mcp_tool(
160
+ name="context_remove",
161
+ description="Remove a context item by key",
162
+ func=context_remove,
163
+ key_description="The key of the context item to remove"
164
+ )
165
+
166
+ # Metadata tools
167
+ self.register_mcp_tool(
168
+ name="mcp_info",
169
+ description="Get information about this MCP agent's capabilities",
170
+ func=mcp_info
171
+ )
172
+
173
+ def register_mcp_tool(
174
+ self, name: str, description: str, func: Callable, **kwargs
175
+ ) -> None:
176
+ """
177
+ Register an MCP tool with this agent.
178
+
179
+ Args:
180
+ name: The name of the tool, used for invocation
181
+ description: Description of what the tool does
182
+ func: The function to be called when the tool is invoked
183
+ **kwargs: Additional tool configuration
184
+ """
185
+ if name in self.mcp_tools:
186
+ print(f"Warning: Overriding existing MCP tool '{name}'")
187
+
188
+ # Inspect function signature to build parameter info
189
+ sig = inspect.signature(func)
190
+ params = []
191
+
192
+ for param_name, param in sig.parameters.items():
193
+ if param_name == 'self':
194
+ continue
195
+
196
+ param_info = {
197
+ "name": param_name,
198
+ "description": kwargs.get(f"{param_name}_description", f"Parameter {param_name}"),
199
+ "required": param.default == inspect.Parameter.empty
200
+ }
201
+
202
+ # Add type information if available
203
+ if param.annotation != inspect.Parameter.empty:
204
+ param_info["type"] = str(param.annotation.__name__)
205
+
206
+ params.append(param_info)
207
+
208
+ # Register the tool
209
+ self.mcp_tools[name] = {
210
+ "name": name,
211
+ "description": description,
212
+ "parameters": params,
213
+ "function": func,
214
+ }
215
+
216
+ # Create a wrapper that calls the function correctly
217
+ # For functions defined within context_management, they already handle self
218
+ def tool_wrapper(**kwargs):
219
+ return func(**kwargs)
220
+
221
+ # Register the tool with AutoGen's function mechanism
222
+ function_schema = {
223
+ "name": name,
224
+ "description": description,
225
+ "parameters": {
226
+ "type": "object",
227
+ "properties": {},
228
+ "required": []
229
+ }
230
+ }
231
+
232
+ # Add parameter descriptions to the schema
233
+ for param in params:
234
+ param_name = param["name"]
235
+ function_schema["parameters"]["properties"][param_name] = {
236
+ "type": param.get("type", "string"),
237
+ "description": param["description"]
238
+ }
239
+ if param["required"]:
240
+ function_schema["parameters"]["required"].append(param_name)
241
+
242
+ # Register with AutoGen - use the simplest form
243
+ self.register_function({name: tool_wrapper})
244
+
245
+ def register_agent_as_tool(self, agent: Agent, name: Optional[str] = None) -> None:
246
+ """
247
+ Register another agent as a tool that can be called by this agent.
248
+
249
+ Args:
250
+ agent: The agent to register as a tool
251
+ name: Optional custom name for the tool, defaults to agent's name
252
+ """
253
+ if name is None:
254
+ # Use valid characters for AutoGen
255
+ name = f"agent_{agent.name}"
256
+
257
+ def agent_tool_wrapper(message: str, **kwargs):
258
+ """Wrapper to call another agent and return its response."""
259
+ response = agent.generate_reply(sender=self, messages=[{"role": "user", "content": message}])
260
+ return {"response": response if response else "No response from agent."}
261
+
262
+ self.register_mcp_tool(
263
+ name=name,
264
+ description=f"Send a message to agent '{agent.name}' and get their response",
265
+ func=agent_tool_wrapper,
266
+ message_description="The message to send to the agent"
267
+ )
268
+
269
+ # MCP Context Tool Implementations
270
+ def has_context(self, key: str) -> bool:
271
+ """
272
+ Check if a key exists in the agent's context.
273
+
274
+ Args:
275
+ key: The key to check for existence
276
+
277
+ Returns:
278
+ True if the key exists in the context, False otherwise
279
+ """
280
+ return key in self.context_store
281
+
282
+ def _mcp_context_get(self, key: str) -> Dict:
283
+ """
284
+ Get a context item by key.
285
+
286
+ Args:
287
+ key: The key of the context item to retrieve
288
+
289
+ Returns:
290
+ Dict containing the value or an error message
291
+ """
292
+ if key in self.context_store:
293
+ return {"status": "success", "value": self.context_store[key]}
294
+ return {"status": "error", "message": f"Key '{key}' not found in context"}
295
+
296
+ def _mcp_context_set(self, key: str, value: Any) -> Dict:
297
+ """
298
+ Set a context item with the given key and value.
299
+
300
+ Args:
301
+ key: The key to store the value under
302
+ value: The value to store
303
+
304
+ Returns:
305
+ Dict indicating success or failure
306
+ """
307
+ self.context_store[key] = value
308
+ return {"status": "success", "message": f"Context key '{key}' set successfully"}
309
+
310
+ def _mcp_context_list(self) -> Dict:
311
+ """
312
+ List all available context keys.
313
+
314
+ Returns:
315
+ Dict containing the list of context keys
316
+ """
317
+ return {"status": "success", "keys": list(self.context_store.keys())}
318
+
319
+ def _mcp_context_remove(self, key: str) -> Dict:
320
+ """
321
+ Remove a context item by key.
322
+
323
+ Args:
324
+ key: The key of the context item to remove
325
+
326
+ Returns:
327
+ Dict indicating success or failure
328
+ """
329
+ if key in self.context_store:
330
+ del self.context_store[key]
331
+ return {"status": "success", "message": f"Context key '{key}' removed successfully"}
332
+ return {"status": "error", "message": f"Key '{key}' not found in context"}
333
+
334
+ def _mcp_info(self) -> Dict:
335
+ """
336
+ Get information about this MCP agent's capabilities.
337
+
338
+ Returns:
339
+ Dict containing MCP agent information
340
+ """
341
+ return {
342
+ "id": self.mcp_id,
343
+ "name": self.name,
344
+ "version": self.mcp_version,
345
+ "tools": [
346
+ {
347
+ "name": name,
348
+ "description": tool["description"],
349
+ "parameters": tool["parameters"]
350
+ }
351
+ for name, tool in self.mcp_tools.items()
352
+ ]
353
+ }
354
+
355
+ # Override ConversableAgent methods to integrate MCP
356
+ def generate_reply(
357
+ self,
358
+ messages: Optional[List[Dict]] = None,
359
+ sender: Optional[Agent] = None,
360
+ exclude_list: Optional[List[str]] = None,
361
+ **kwargs,
362
+ ) -> Union[str, Dict, None]:
363
+ """
364
+ Generate a reply based on the conversation history and with MCP context.
365
+
366
+ This overrides the base ConversableAgent method to integrate MCP context
367
+ into the generation process.
368
+
369
+ Args:
370
+ messages: Optional list of messages to process
371
+ sender: The sender agent of the message
372
+ exclude_list: List of function names to exclude from auto-function calling
373
+ **kwargs: Additional keyword arguments
374
+
375
+ Returns:
376
+ The generated reply
377
+ """
378
+ # Inject MCP context into the prompt if available
379
+ if messages:
380
+ last_message = messages[-1]
381
+ if "content" in last_message and isinstance(last_message["content"], str):
382
+ # Check if message contains MCP tool calls
383
+ self._process_mcp_tool_calls(last_message)
384
+
385
+ # For LLM-based generation, handle context in a different way
386
+ # For AutoGen, we can't directly modify system_message since it's a property
387
+ if hasattr(self, "llm_config") and self.llm_config:
388
+ context_summary = self._generate_context_summary()
389
+
390
+ if context_summary and messages:
391
+ # Instead of modifying system_message, add context in the message list
392
+ context_msg = {
393
+ "role": "system",
394
+ "content": f"Current context information:\n{context_summary}"
395
+ }
396
+
397
+ # Insert the context message at an appropriate position in the conversation
398
+ if len(messages) > 1:
399
+ # Insert before the last message
400
+ messages = messages[:-1] + [context_msg] + [messages[-1]]
401
+ else:
402
+ # Insert before the only message
403
+ messages = [context_msg] + messages
404
+
405
+ # Call the parent class method to generate the reply
406
+ reply = super().generate_reply(
407
+ messages=messages, sender=sender, exclude_list=exclude_list, **kwargs
408
+ )
409
+ return reply
410
+
411
+ def _mark_task_completed(self, task_id: Optional[str]) -> None:
412
+ """Mark a task as completed to prevent duplicate processing.
413
+
414
+ This method is used for idempotency to ensure tasks are not processed multiple times.
415
+ The task ID is stored in a set for efficient lookup.
416
+
417
+ Args:
418
+ task_id: The unique identifier of the task to mark as completed
419
+ """
420
+ if task_id:
421
+ self.completed_task_ids.add(task_id)
422
+ logger.info(f"[{self.name}] Marked task_id {task_id} as completed")
423
+
424
+ def _generate_context_summary(self) -> str:
425
+ """Generate a summary of available context for inclusion in the system message.
426
+
427
+ This method creates a human-readable summary of the current context store,
428
+ handling different types of values appropriately (dictionaries, lists, long strings).
429
+
430
+ Returns:
431
+ A formatted string containing a summary of all context items
432
+ """
433
+ if not self.context_store:
434
+ return ""
435
+
436
+ summary_parts = []
437
+ for key, value in self.context_store.items():
438
+ # For complex objects, just indicate their type
439
+ if isinstance(value, dict):
440
+ summary_parts.append(f"- {key}: Dictionary with {len(value)} items")
441
+ elif isinstance(value, list):
442
+ summary_parts.append(f"- {key}: List with {len(value)} items")
443
+ elif isinstance(value, str) and len(value) > 100:
444
+ summary_parts.append(f"- {key}: Text ({len(value)} chars)")
445
+ else:
446
+ summary_parts.append(f"- {key}: {value}")
447
+
448
+ return "\n".join(summary_parts)
449
+
450
+ def _process_mcp_tool_calls(self, message: Dict) -> None:
451
+ """Process any MCP tool calls in a message.
452
+
453
+ This method handles multiple tool call formats:
454
+ 1. OpenAI function call format
455
+ 2. Explicit MCP call format: mcp.call({...})
456
+ 3. Natural language tool call detection
457
+
458
+ The method executes tool calls and stores results in the context store
459
+ for future reference.
460
+
461
+ Args:
462
+ message: The message containing potential tool calls
463
+ """
464
+ content = message.get("content", "")
465
+ if not isinstance(content, str):
466
+ return
467
+
468
+ # Check for tool_calls in the OpenAI message format
469
+ if "tool_calls" in message:
470
+ tool_calls = message.get("tool_calls", [])
471
+ for tool_call in tool_calls:
472
+ try:
473
+ # Extract tool name and arguments
474
+ function = tool_call.get("function", {})
475
+ tool_name = function.get("name")
476
+ arguments_str = function.get("arguments", "{}")
477
+ arguments = json.loads(arguments_str)
478
+
479
+ if tool_name in self.mcp_tools:
480
+ # Execute the tool
481
+ func = self.mcp_tools[tool_name]["function"]
482
+ result = func(**arguments)
483
+
484
+ # Store the result in the context
485
+ result_key = f"result_{uuid.uuid4().hex[:8]}"
486
+ self.context_store[result_key] = result
487
+ print(f"Executed tool '{tool_name}' with result: {result}")
488
+ except Exception as e:
489
+ print(f"Error processing OpenAI tool call: {e}")
490
+
491
+ # Check for explicit MCP calls in the format mcp.call({...})
492
+ import re
493
+ tool_call_pattern = r"mcp\.call\(([^)]+)\)"
494
+ explicit_calls = re.findall(tool_call_pattern, content)
495
+ for call in explicit_calls:
496
+ try:
497
+ # Parse the tool call arguments
498
+ call_args = json.loads(f"{{{call}}}")
499
+ tool_name = call_args.get("tool")
500
+ arguments = call_args.get("arguments", {})
501
+
502
+ if tool_name in self.mcp_tools:
503
+ # Execute the tool
504
+ func = self.mcp_tools[tool_name]["function"]
505
+ result = func(**arguments)
506
+
507
+ # Store the result in the context
508
+ result_key = f"result_{uuid.uuid4().hex[:8]}"
509
+ self.context_store[result_key] = result
510
+ print(f"Executed explicit MCP call to '{tool_name}' with result: {result}")
511
+ except Exception as e:
512
+ print(f"Error processing explicit MCP tool call: {e}")
513
+
514
+ # Add basic natural language detection for common context operations
515
+ # This is a simplified approach - in production, you would use more robust NLP
516
+ content_lower = content.lower()
517
+
518
+ # Very basic pattern matching for user requests to update context
519
+ if ("add" in content_lower and "to my interests" in content_lower) or \
520
+ ("update my interests" in content_lower):
521
+ try:
522
+ # Extract the interest to add - very simplified regex extraction
523
+ interest_match = re.search(r"add ['\"]?([^'\"]+)['\"]? to my interests", content_lower)
524
+ if interest_match:
525
+ interest = interest_match.group(1).strip()
526
+ if "user_preferences" in self.context_store:
527
+ user_prefs = self.context_store["user_preferences"]
528
+ if isinstance(user_prefs, dict) and "interests" in user_prefs:
529
+ if interest not in user_prefs["interests"]:
530
+ user_prefs["interests"].append(interest)
531
+ self.update_context("user_preferences", user_prefs)
532
+ print(f"Added '{interest}' to user interests via natural language detection")
533
+ except Exception as e:
534
+ print(f"Error processing natural language context update: {e}")
535
+
536
+ def update_context(self, key: str, value: Any) -> None:
537
+ """
538
+ Update the MCP context with a new key-value pair.
539
+
540
+ Args:
541
+ key: The context key
542
+ value: The context value
543
+ """
544
+ self.context_store[key] = value
545
+
546
+ def get_context(self, key: str) -> Any:
547
+ """
548
+ Get a value from the MCP context.
549
+
550
+ Args:
551
+ key: The context key to retrieve
552
+
553
+ Returns:
554
+ The context value or None if not found
555
+ """
556
+ return self.context_store.get(key)
557
+
558
+ def list_available_tools(self) -> List[Dict]:
559
+ """
560
+ Get a list of all available MCP tools.
561
+
562
+ Returns:
563
+ List of tool definitions
564
+ """
565
+ return [
566
+ {
567
+ "name": name,
568
+ "description": tool["description"],
569
+ "parameters": tool["parameters"]
570
+ }
571
+ for name, tool in self.mcp_tools.items()
572
+ ]
573
+
574
+ def execute_tool(self, tool_name: str, **kwargs) -> Any:
575
+ """
576
+ Execute an MCP tool by name with the provided arguments.
577
+
578
+ Args:
579
+ tool_name: The name of the tool to execute
580
+ **kwargs: Arguments to pass to the tool
581
+
582
+ Returns:
583
+ The result of the tool execution
584
+
585
+ Raises:
586
+ ValueError: If the tool is not found
587
+ """
588
+ if tool_name not in self.mcp_tools:
589
+ raise ValueError(f"Tool '{tool_name}' not found")
590
+
591
+ # Get the tool and its function
592
+ tool = self.mcp_tools[tool_name]
593
+ func = tool["function"]
594
+
595
+ # Call the function directly without passing self again (it's already bound)
596
+ return func(**kwargs)
597
+
598
+ def _should_process_message(self, message: Dict[str, Any]) -> bool:
599
+ """
600
+ Checks if a message with a task_id has already been completed.
601
+
602
+ Args:
603
+ message: The message to check
604
+
605
+ Returns:
606
+ True if the message should be processed, False otherwise
607
+ """
608
+ if message is None:
609
+ return True # Can't determine, assume process
610
+
611
+ message_type = message.get('type')
612
+ task_id = message.get('task_id')
613
+
614
+ if message_type == 'task' and task_id:
615
+ if task_id in self.completed_task_ids:
616
+ logger.info(f"[{self.name}] Identified already completed task_id: {task_id}. Skipping processing.")
617
+ return False # Already completed, do not process
618
+
619
+ return True # Not a task with a known completed ID, or not a task at all
620
+
621
+ def _mark_task_completed(self, task_id: Optional[str]):
622
+ """
623
+ Marks a task ID as completed.
624
+
625
+ Args:
626
+ task_id: The task ID to mark as completed
627
+ """
628
+ if task_id:
629
+ logger.debug(f"[{self.name}] Marking task_id {task_id} as completed.")
630
+ self.completed_task_ids.add(task_id)
631
+ else:
632
+ logger.warning(f"[{self.name}] Attempted to mark task completed, but task_id was None.")