minion-code 0.1.0__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.
Files changed (59) hide show
  1. examples/advance_tui.py +508 -0
  2. examples/agent_with_todos.py +165 -0
  3. examples/file_freshness_example.py +97 -0
  4. examples/file_watching_example.py +110 -0
  5. examples/interruptible_tui.py +5 -0
  6. examples/message_response_children_demo.py +226 -0
  7. examples/rich_example.py +4 -0
  8. examples/simple_file_watching.py +57 -0
  9. examples/simple_tui.py +267 -0
  10. examples/simple_usage.py +69 -0
  11. minion_code/__init__.py +16 -0
  12. minion_code/agents/__init__.py +11 -0
  13. minion_code/agents/code_agent.py +320 -0
  14. minion_code/cli.py +502 -0
  15. minion_code/commands/__init__.py +90 -0
  16. minion_code/commands/clear_command.py +70 -0
  17. minion_code/commands/help_command.py +90 -0
  18. minion_code/commands/history_command.py +104 -0
  19. minion_code/commands/quit_command.py +32 -0
  20. minion_code/commands/status_command.py +115 -0
  21. minion_code/commands/tools_command.py +86 -0
  22. minion_code/commands/version_command.py +104 -0
  23. minion_code/components/Message.py +304 -0
  24. minion_code/components/MessageResponse.py +188 -0
  25. minion_code/components/PromptInput.py +534 -0
  26. minion_code/components/__init__.py +29 -0
  27. minion_code/screens/REPL.py +925 -0
  28. minion_code/screens/__init__.py +4 -0
  29. minion_code/services/__init__.py +50 -0
  30. minion_code/services/event_system.py +108 -0
  31. minion_code/services/file_freshness_service.py +582 -0
  32. minion_code/tools/__init__.py +69 -0
  33. minion_code/tools/bash_tool.py +58 -0
  34. minion_code/tools/file_edit_tool.py +238 -0
  35. minion_code/tools/file_read_tool.py +73 -0
  36. minion_code/tools/file_write_tool.py +36 -0
  37. minion_code/tools/glob_tool.py +58 -0
  38. minion_code/tools/grep_tool.py +105 -0
  39. minion_code/tools/ls_tool.py +65 -0
  40. minion_code/tools/multi_edit_tool.py +271 -0
  41. minion_code/tools/python_interpreter_tool.py +105 -0
  42. minion_code/tools/todo_read_tool.py +100 -0
  43. minion_code/tools/todo_write_tool.py +234 -0
  44. minion_code/tools/user_input_tool.py +53 -0
  45. minion_code/types.py +88 -0
  46. minion_code/utils/__init__.py +44 -0
  47. minion_code/utils/mcp_loader.py +211 -0
  48. minion_code/utils/todo_file_utils.py +110 -0
  49. minion_code/utils/todo_storage.py +149 -0
  50. minion_code-0.1.0.dist-info/METADATA +350 -0
  51. minion_code-0.1.0.dist-info/RECORD +59 -0
  52. minion_code-0.1.0.dist-info/WHEEL +5 -0
  53. minion_code-0.1.0.dist-info/entry_points.txt +4 -0
  54. minion_code-0.1.0.dist-info/licenses/LICENSE +661 -0
  55. minion_code-0.1.0.dist-info/top_level.txt +3 -0
  56. tests/__init__.py +1 -0
  57. tests/test_basic.py +20 -0
  58. tests/test_readonly_tools.py +102 -0
  59. tests/test_tools.py +83 -0
@@ -0,0 +1,234 @@
1
+ """Todo Write Tool for managing todo items."""
2
+
3
+ import uuid
4
+ import json
5
+ from typing import List, Dict, Any, Optional
6
+ from minion.tools import BaseTool
7
+ from minion.types import AgentState
8
+
9
+ from ..utils.todo_storage import (
10
+ TodoItem, TodoStatus, TodoPriority,
11
+ get_todos, set_todos
12
+ )
13
+
14
+
15
+
16
+
17
+
18
+ class ValidationResult:
19
+ """Result of todo validation."""
20
+ def __init__(self, result: bool, error_code: int = 0, message: str = "", meta: Dict[str, Any] = None):
21
+ self.result = result
22
+ self.error_code = error_code
23
+ self.message = message
24
+ self.meta = meta or {}
25
+
26
+
27
+ def validate_todos(todos: List[TodoItem]) -> ValidationResult:
28
+ """Validate a list of todos."""
29
+ # Check for duplicate IDs
30
+ ids = [todo.id for todo in todos]
31
+ unique_ids = set(ids)
32
+ if len(ids) != len(unique_ids):
33
+ duplicate_ids = [id for id in ids if ids.count(id) > 1]
34
+ return ValidationResult(
35
+ result=False,
36
+ error_code=1,
37
+ message="Duplicate todo IDs found",
38
+ meta={"duplicate_ids": list(set(duplicate_ids))}
39
+ )
40
+
41
+ # Check for multiple in_progress tasks
42
+ in_progress_tasks = [todo for todo in todos if todo.status == TodoStatus.IN_PROGRESS]
43
+ if len(in_progress_tasks) > 1:
44
+ return ValidationResult(
45
+ result=False,
46
+ error_code=2,
47
+ message="Only one task can be in_progress at a time",
48
+ meta={"in_progress_task_ids": [t.id for t in in_progress_tasks]}
49
+ )
50
+
51
+ # Validate each todo
52
+ for todo in todos:
53
+ if not todo.content.strip():
54
+ return ValidationResult(
55
+ result=False,
56
+ error_code=3,
57
+ message=f'Todo with ID "{todo.id}" has empty content',
58
+ meta={"todo_id": todo.id}
59
+ )
60
+
61
+ return ValidationResult(result=True)
62
+
63
+
64
+ def generate_todo_summary(todos: List[TodoItem]) -> str:
65
+ """Generate a summary of todos."""
66
+ stats = {
67
+ 'total': len(todos),
68
+ 'pending': len([t for t in todos if t.status == TodoStatus.PENDING]),
69
+ 'in_progress': len([t for t in todos if t.status == TodoStatus.IN_PROGRESS]),
70
+ 'completed': len([t for t in todos if t.status == TodoStatus.COMPLETED])
71
+ }
72
+
73
+ summary = f"Updated {stats['total']} todo(s)"
74
+ if stats['total'] > 0:
75
+ summary += f" ({stats['pending']} pending, {stats['in_progress']} in progress, {stats['completed']} completed)"
76
+ summary += ". Continue tracking your progress with the todo list."
77
+
78
+ return summary
79
+
80
+
81
+ def format_todos_display(todos: List[TodoItem]) -> str:
82
+ """Format todos for display."""
83
+ if not todos:
84
+ return "No todos currently"
85
+
86
+ # Sort: [completed, in_progress, pending]
87
+ order = [TodoStatus.COMPLETED, TodoStatus.IN_PROGRESS, TodoStatus.PENDING]
88
+ sorted_todos = sorted(todos, key=lambda t: (order.index(t.status), t.content))
89
+
90
+ # Find the next pending task
91
+ next_pending_index = next(
92
+ (i for i, todo in enumerate(sorted_todos) if todo.status == TodoStatus.PENDING),
93
+ -1
94
+ )
95
+
96
+ lines = []
97
+ for i, todo in enumerate(sorted_todos):
98
+ # Determine checkbox and formatting
99
+ if todo.status == TodoStatus.COMPLETED:
100
+ checkbox = "☒"
101
+ prefix = " ⎿ "
102
+ content = f"~~{todo.content}~~" # Strikethrough for completed
103
+ elif todo.status == TodoStatus.IN_PROGRESS:
104
+ checkbox = "☐"
105
+ prefix = " ⎿ "
106
+ content = f"**{todo.content}**" # Bold for in progress
107
+ else: # pending
108
+ checkbox = "☐"
109
+ prefix = " ⎿ "
110
+ if i == next_pending_index:
111
+ content = f"**{todo.content}**" # Bold for next pending
112
+ else:
113
+ content = todo.content
114
+
115
+ lines.append(f"{prefix}{checkbox} {content}")
116
+
117
+ return "\n".join(lines)
118
+
119
+
120
+ class TodoWriteTool(BaseTool):
121
+ """Tool for writing and managing todo items."""
122
+
123
+ name = "todo_write"
124
+ description = "Creates and manages todo items for task tracking and progress management in the current session."
125
+ readonly = False # Writing tool, modifies system state
126
+ needs_state = True # Tool needs agent state
127
+ inputs = {
128
+ "todos_json": {
129
+ "type": "string",
130
+ "description": "JSON string containing array of todo items with id, content, status, and priority fields"
131
+ },
132
+ }
133
+ output_type = "string"
134
+
135
+ def _get_agent_id(self, state: AgentState) -> str:
136
+ """Get agent ID from agent state."""
137
+ # Try to get from metadata
138
+ # if 'agent_id' in state.metadata:
139
+ # return state.metadata['agent_id']
140
+ #
141
+ # # Try to get from input if available
142
+ # if state.input and hasattr(state.input, 'mind_id'):
143
+ # return state.input.mind_id
144
+ #
145
+ # # Generate a unique ID based on task or use default
146
+ # if state.task:
147
+ # # Use a hash of the task as agent ID
148
+ # import hashlib
149
+ # return hashlib.md5(state.task.encode()).hexdigest()[:8]
150
+
151
+ # Default fallback
152
+ return state.agent.agent_id
153
+
154
+ def forward(self, todos_json: str, *, state: AgentState) -> str:
155
+ """Execute the todo write operation."""
156
+ try:
157
+ # Get agent ID from agent state
158
+ agent_id = self._get_agent_id(state)
159
+
160
+ # Parse JSON input
161
+ try:
162
+ todos_data = json.loads(todos_json)
163
+ except json.JSONDecodeError as e:
164
+ return f"Error: Invalid JSON format - {str(e)}"
165
+
166
+ if not isinstance(todos_data, list):
167
+ return "Error: todos_json must be an array of todo items"
168
+
169
+ # Convert to TodoItem objects and validate
170
+ todo_items = []
171
+ for i, todo_data in enumerate(todos_data):
172
+ if not isinstance(todo_data, dict):
173
+ return f"Error: Todo item {i} must be an object"
174
+
175
+ # Validate required fields
176
+ required_fields = ['id', 'content', 'status', 'priority']
177
+ for field in required_fields:
178
+ if field not in todo_data:
179
+ return f"Error: Todo item {i} missing required field '{field}'"
180
+
181
+ # Validate status
182
+ if todo_data['status'] not in ['pending', 'in_progress', 'completed']:
183
+ return f"Error: Invalid status '{todo_data['status']}' in todo item {i}"
184
+
185
+ # Validate priority
186
+ if todo_data['priority'] not in ['high', 'medium', 'low']:
187
+ return f"Error: Invalid priority '{todo_data['priority']}' in todo item {i}"
188
+
189
+ # Validate content
190
+ if not todo_data['content'].strip():
191
+ return f"Error: Todo item {i} has empty content"
192
+
193
+ todo_item = TodoItem(
194
+ id=todo_data['id'],
195
+ content=todo_data['content'],
196
+ status=TodoStatus(todo_data['status']),
197
+ priority=TodoPriority(todo_data['priority'])
198
+ )
199
+ todo_items.append(todo_item)
200
+
201
+ # Validate todos
202
+ validation = validate_todos(todo_items)
203
+ if not validation.result:
204
+ return f"Validation Error: {validation.message}"
205
+
206
+ # Get previous todos for comparison
207
+ previous_todos = get_todos(agent_id)
208
+
209
+ # Update todos in storage
210
+ set_todos(todo_items, agent_id)
211
+
212
+ # Update agent metadata with todo info
213
+ state.metadata['todo_count'] = len(todo_items)
214
+ state.metadata['last_todo_update'] = json.dumps({
215
+ 'total': len(todo_items),
216
+ 'pending': len([t for t in todo_items if t.status == TodoStatus.PENDING]),
217
+ 'in_progress': len([t for t in todo_items if t.status == TodoStatus.IN_PROGRESS]),
218
+ 'completed': len([t for t in todo_items if t.status == TodoStatus.COMPLETED])
219
+ })
220
+
221
+ # Reset iteration counter since todo tool was used
222
+ state.metadata["iteration_without_todos"] = 0
223
+
224
+ # Generate summary
225
+ summary = generate_todo_summary(todo_items)
226
+
227
+ # Format display
228
+ display = format_todos_display(todo_items)
229
+
230
+ result = f"{summary}\n\n{display}"
231
+ return result
232
+
233
+ except Exception as e:
234
+ return f"Error updating todos: {str(e)}"
@@ -0,0 +1,53 @@
1
+ #!/usr/bin/env python
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ User input tool
5
+ """
6
+
7
+ from typing import Optional
8
+ from minion.tools import BaseTool
9
+
10
+
11
+ class UserInputTool(BaseTool):
12
+ """User input tool"""
13
+
14
+ name = "user_input"
15
+ description = "Ask user a specific question and get input"
16
+ readonly = True # Read-only tool, does not modify system state
17
+ inputs = {
18
+ "question": {"type": "string", "description": "Question to ask the user"},
19
+ "default_value": {
20
+ "type": "string",
21
+ "description": "Default value (optional)",
22
+ "nullable": True,
23
+ },
24
+ }
25
+ output_type = "string"
26
+
27
+ def forward(self, question: str, default_value: Optional[str] = None) -> str:
28
+ """Ask user a question"""
29
+ try:
30
+ # Build prompt message
31
+ prompt = f"Question: {question}"
32
+ if default_value:
33
+ prompt += f" (default: {default_value})"
34
+ prompt += "\nPlease enter your answer: "
35
+
36
+ # Get user input
37
+ user_response = input(prompt).strip()
38
+
39
+ # Use default value if user didn't input anything and default exists
40
+ if not user_response and default_value:
41
+ user_response = default_value
42
+
43
+ result = f"User question: {question}\n"
44
+ if default_value:
45
+ result += f"Default value: {default_value}\n"
46
+ result += f"User answer: {user_response}"
47
+
48
+ return result
49
+
50
+ except KeyboardInterrupt:
51
+ return "User cancelled input"
52
+ except Exception as e:
53
+ return f"Error getting user input: {str(e)}"
minion_code/types.py ADDED
@@ -0,0 +1,88 @@
1
+ """
2
+ Type definitions for minion_code
3
+ Shared types to avoid circular imports
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ from enum import Enum
8
+ from typing import List, Dict, Any, Optional, Union
9
+ import uuid
10
+ import time
11
+
12
+
13
+ class MessageType(Enum):
14
+ USER = "user"
15
+ ASSISTANT = "assistant"
16
+ PROGRESS = "progress"
17
+ TOOL_USE = "tool_use"
18
+ TOOL_RESULT = "tool_result"
19
+
20
+
21
+ class InputMode(Enum):
22
+ BASH = "bash"
23
+ PROMPT = "prompt"
24
+ KODING = "koding"
25
+
26
+
27
+ @dataclass
28
+ class MessageContent:
29
+ """Represents message content - can be text or structured content"""
30
+ content: Union[str, List[Dict[str, Any]]]
31
+ type: str = "text"
32
+
33
+
34
+ @dataclass
35
+ class Message:
36
+ """Core message structure equivalent to TypeScript MessageType"""
37
+ uuid: str = field(default_factory=lambda: str(uuid.uuid4()))
38
+ type: MessageType = MessageType.USER
39
+ message: MessageContent = field(default_factory=lambda: MessageContent(""))
40
+ timestamp: float = field(default_factory=time.time)
41
+ options: Optional[Dict[str, Any]] = None
42
+
43
+
44
+ @dataclass
45
+ class ToolUseConfirm:
46
+ """Tool use confirmation context"""
47
+ tool_name: str
48
+ parameters: Dict[str, Any]
49
+ on_confirm: Any # Callable[[], None]
50
+ on_abort: Any # Callable[[], None]
51
+
52
+
53
+ @dataclass
54
+ class BinaryFeedbackContext:
55
+ """Binary feedback context for comparing two assistant messages"""
56
+ m1: Message
57
+ m2: Message
58
+ resolve: Any # Callable[[str], None]
59
+
60
+
61
+ @dataclass
62
+ class ToolJSXContext:
63
+ """Tool JSX rendering context"""
64
+ jsx: Optional[Any] = None
65
+ should_hide_prompt_input: bool = False
66
+
67
+
68
+ @dataclass
69
+ class ModelInfo:
70
+ """Model information display"""
71
+ name: str
72
+ provider: str
73
+ context_length: int
74
+ current_tokens: int
75
+ id: Optional[str] = None
76
+
77
+
78
+ class REPLConfig:
79
+ """Configuration equivalent to getGlobalConfig()"""
80
+ def __init__(self):
81
+ self.verbose: bool = False
82
+ self.debug: bool = False
83
+ self.safe_mode: bool = False
84
+ self.has_acknowledged_cost_threshold: bool = False
85
+ self.model_name: str = "claude-3-5-sonnet-20241022"
86
+
87
+ def get_model_name(self, context: str = "main") -> str:
88
+ return self.model_name
@@ -0,0 +1,44 @@
1
+ # Utils package
2
+
3
+ from .todo_file_utils import (
4
+ get_todo_file_path,
5
+ get_default_storage_dir,
6
+ ensure_storage_dir_exists,
7
+ list_todo_files,
8
+ extract_agent_id_from_todo_file,
9
+ is_todo_file,
10
+ )
11
+
12
+ from .todo_storage import (
13
+ TodoItem,
14
+ TodoStatus,
15
+ TodoPriority,
16
+ TodoStorage,
17
+ get_todos,
18
+ set_todos,
19
+ add_todo,
20
+ update_todo,
21
+ remove_todo,
22
+ clear_todos,
23
+ )
24
+
25
+ __all__ = [
26
+ # Todo file utilities
27
+ 'get_todo_file_path',
28
+ 'get_default_storage_dir',
29
+ 'ensure_storage_dir_exists',
30
+ 'list_todo_files',
31
+ 'extract_agent_id_from_todo_file',
32
+ 'is_todo_file',
33
+ # Todo storage
34
+ 'TodoItem',
35
+ 'TodoStatus',
36
+ 'TodoPriority',
37
+ 'TodoStorage',
38
+ 'get_todos',
39
+ 'set_todos',
40
+ 'add_todo',
41
+ 'update_todo',
42
+ 'remove_todo',
43
+ 'clear_todos',
44
+ ]
@@ -0,0 +1,211 @@
1
+ #!/usr/bin/env python3
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ MCP (Model Context Protocol) Tools Loader
5
+
6
+ This module provides functionality to load MCP tools from configuration files
7
+ and integrate them with MinionCodeAgent.
8
+ """
9
+
10
+ import json
11
+ import logging
12
+ import subprocess
13
+ import asyncio
14
+ from pathlib import Path
15
+ from typing import Dict, List, Any, Optional
16
+ from dataclasses import dataclass
17
+
18
+ try:
19
+ from minion.tools.mcp.mcp_toolset import MCPToolset, StdioServerParameters
20
+ MCP_AVAILABLE = True
21
+ except ImportError:
22
+ MCP_AVAILABLE = False
23
+ MCPToolset = None
24
+ StdioServerParameters = None
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+
29
+ @dataclass
30
+ class MCPServerConfig:
31
+ """Configuration for an MCP server."""
32
+ name: str
33
+ command: str
34
+ args: List[str]
35
+ env: Optional[Dict[str, str]] = None
36
+ disabled: bool = False
37
+ auto_approve: List[str] = None
38
+
39
+ def __post_init__(self):
40
+ if self.auto_approve is None:
41
+ self.auto_approve = []
42
+
43
+
44
+ class MCPToolsLoader:
45
+ """Loader for MCP tools from configuration files."""
46
+
47
+ def __init__(self, config_path: Optional[Path] = None):
48
+ """
49
+ Initialize MCP tools loader.
50
+
51
+ Args:
52
+ config_path: Path to MCP configuration file
53
+ """
54
+ self.config_path = config_path
55
+ self.servers: Dict[str, MCPServerConfig] = {}
56
+ self.loaded_tools = []
57
+ self.toolsets: List[Any] = [] # Store MCPToolset instances for cleanup
58
+
59
+ def load_config(self, config_path: Optional[Path] = None) -> Dict[str, MCPServerConfig]:
60
+ """
61
+ Load MCP configuration from JSON file.
62
+
63
+ Args:
64
+ config_path: Path to configuration file
65
+
66
+ Returns:
67
+ Dictionary of server configurations
68
+ """
69
+ if config_path:
70
+ self.config_path = config_path
71
+
72
+ if not self.config_path or not self.config_path.exists():
73
+ logger.warning(f"MCP config file not found: {self.config_path}")
74
+ return {}
75
+
76
+ try:
77
+ with open(self.config_path, 'r', encoding='utf-8') as f:
78
+ config_data = json.load(f)
79
+
80
+ servers_config = config_data.get('mcpServers', {})
81
+
82
+ for server_name, server_data in servers_config.items():
83
+ self.servers[server_name] = MCPServerConfig(
84
+ name=server_name,
85
+ command=server_data.get('command', ''),
86
+ args=server_data.get('args', []),
87
+ env=server_data.get('env', {}),
88
+ disabled=server_data.get('disabled', False),
89
+ auto_approve=server_data.get('autoApprove', [])
90
+ )
91
+
92
+ logger.info(f"Loaded {len(self.servers)} MCP server configurations")
93
+ return self.servers
94
+
95
+ except Exception as e:
96
+ logger.error(f"Failed to load MCP config from {self.config_path}: {e}")
97
+ return {}
98
+
99
+ async def load_tools_from_server(self, server_config: MCPServerConfig) -> List[Any]:
100
+ """
101
+ Load tools from an MCP server.
102
+
103
+ Args:
104
+ server_config: Server configuration
105
+
106
+ Returns:
107
+ List of loaded tools
108
+ """
109
+ if server_config.disabled:
110
+ logger.info(f"Skipping disabled MCP server: {server_config.name}")
111
+ return []
112
+
113
+ if not MCP_AVAILABLE:
114
+ logger.warning("MCP framework not available, skipping MCP server loading")
115
+ return []
116
+
117
+ try:
118
+ logger.info(f"Loading tools from MCP server: {server_config.name}")
119
+ logger.info(f"Command: {server_config.command} {' '.join(server_config.args)}")
120
+
121
+ # Create MCPToolset with StdioServerParameters
122
+ toolset = await MCPToolset.create(
123
+ connection_params=StdioServerParameters(
124
+ command=server_config.command,
125
+ args=server_config.args,
126
+ env=server_config.env or {}
127
+ ),
128
+ name=server_config.name,
129
+ structured_output=False # Set to False as requested
130
+ )
131
+
132
+ # Store toolset for cleanup
133
+ self.toolsets.append(toolset)
134
+
135
+ # Get tools from the toolset
136
+ tools = toolset.tools if hasattr(toolset, 'tools') else []
137
+
138
+ logger.info(f"Successfully loaded {len(tools)} tools from {server_config.name}")
139
+ return tools
140
+
141
+ except Exception as e:
142
+ logger.error(f"Failed to load tools from MCP server {server_config.name}: {e}")
143
+ return []
144
+
145
+ async def load_all_tools(self) -> List[Any]:
146
+ """
147
+ Load tools from all configured MCP servers.
148
+
149
+ Returns:
150
+ List of all loaded MCP tools
151
+ """
152
+ all_tools = []
153
+
154
+ for server_name, server_config in self.servers.items():
155
+ if not server_config.disabled:
156
+ tools = await self.load_tools_from_server(server_config)
157
+ all_tools.extend(tools)
158
+ logger.info(f"Loaded {len(tools)} tools from {server_name}")
159
+
160
+ self.loaded_tools = all_tools
161
+ logger.info(f"Total MCP tools loaded: {len(all_tools)}")
162
+ return all_tools
163
+
164
+ def get_server_info(self) -> Dict[str, Dict[str, Any]]:
165
+ """
166
+ Get information about configured servers.
167
+
168
+ Returns:
169
+ Dictionary with server information
170
+ """
171
+ info = {}
172
+ for name, config in self.servers.items():
173
+ info[name] = {
174
+ 'command': config.command,
175
+ 'args': config.args,
176
+ 'disabled': config.disabled,
177
+ 'auto_approve_count': len(config.auto_approve)
178
+ }
179
+ return info
180
+
181
+ async def close(self):
182
+ """
183
+ Close all MCP toolsets and clean up resources.
184
+ """
185
+ logger.info(f"Closing {len(self.toolsets)} MCP toolsets...")
186
+
187
+ for toolset in self.toolsets:
188
+ try:
189
+ await toolset.close()
190
+ logger.debug(f"Closed toolset: {getattr(toolset, 'name', 'unknown')}")
191
+ except Exception as e:
192
+ logger.error(f"Error closing toolset: {e}")
193
+
194
+ self.toolsets.clear()
195
+ logger.info("All MCP toolsets closed")
196
+
197
+
198
+ # Convenience function
199
+ async def load_mcp_tools(config_path: Path) -> List[Any]:
200
+ """
201
+ Convenience function to load MCP tools from a configuration file.
202
+
203
+ Args:
204
+ config_path: Path to MCP configuration file
205
+
206
+ Returns:
207
+ List of loaded MCP tools
208
+ """
209
+ loader = MCPToolsLoader(config_path)
210
+ loader.load_config()
211
+ return await loader.load_all_tools()