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.
- examples/advance_tui.py +508 -0
- examples/agent_with_todos.py +165 -0
- examples/file_freshness_example.py +97 -0
- examples/file_watching_example.py +110 -0
- examples/interruptible_tui.py +5 -0
- examples/message_response_children_demo.py +226 -0
- examples/rich_example.py +4 -0
- examples/simple_file_watching.py +57 -0
- examples/simple_tui.py +267 -0
- examples/simple_usage.py +69 -0
- minion_code/__init__.py +16 -0
- minion_code/agents/__init__.py +11 -0
- minion_code/agents/code_agent.py +320 -0
- minion_code/cli.py +502 -0
- minion_code/commands/__init__.py +90 -0
- minion_code/commands/clear_command.py +70 -0
- minion_code/commands/help_command.py +90 -0
- minion_code/commands/history_command.py +104 -0
- minion_code/commands/quit_command.py +32 -0
- minion_code/commands/status_command.py +115 -0
- minion_code/commands/tools_command.py +86 -0
- minion_code/commands/version_command.py +104 -0
- minion_code/components/Message.py +304 -0
- minion_code/components/MessageResponse.py +188 -0
- minion_code/components/PromptInput.py +534 -0
- minion_code/components/__init__.py +29 -0
- minion_code/screens/REPL.py +925 -0
- minion_code/screens/__init__.py +4 -0
- minion_code/services/__init__.py +50 -0
- minion_code/services/event_system.py +108 -0
- minion_code/services/file_freshness_service.py +582 -0
- minion_code/tools/__init__.py +69 -0
- minion_code/tools/bash_tool.py +58 -0
- minion_code/tools/file_edit_tool.py +238 -0
- minion_code/tools/file_read_tool.py +73 -0
- minion_code/tools/file_write_tool.py +36 -0
- minion_code/tools/glob_tool.py +58 -0
- minion_code/tools/grep_tool.py +105 -0
- minion_code/tools/ls_tool.py +65 -0
- minion_code/tools/multi_edit_tool.py +271 -0
- minion_code/tools/python_interpreter_tool.py +105 -0
- minion_code/tools/todo_read_tool.py +100 -0
- minion_code/tools/todo_write_tool.py +234 -0
- minion_code/tools/user_input_tool.py +53 -0
- minion_code/types.py +88 -0
- minion_code/utils/__init__.py +44 -0
- minion_code/utils/mcp_loader.py +211 -0
- minion_code/utils/todo_file_utils.py +110 -0
- minion_code/utils/todo_storage.py +149 -0
- minion_code-0.1.0.dist-info/METADATA +350 -0
- minion_code-0.1.0.dist-info/RECORD +59 -0
- minion_code-0.1.0.dist-info/WHEEL +5 -0
- minion_code-0.1.0.dist-info/entry_points.txt +4 -0
- minion_code-0.1.0.dist-info/licenses/LICENSE +661 -0
- minion_code-0.1.0.dist-info/top_level.txt +3 -0
- tests/__init__.py +1 -0
- tests/test_basic.py +20 -0
- tests/test_readonly_tools.py +102 -0
- 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()
|