hanzo-mcp 0.3.8__py3-none-any.whl → 0.5.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.
Potentially problematic release.
This version of hanzo-mcp might be problematic. Click here for more details.
- hanzo_mcp/__init__.py +1 -1
- hanzo_mcp/cli.py +118 -170
- hanzo_mcp/cli_enhanced.py +438 -0
- hanzo_mcp/config/__init__.py +19 -0
- hanzo_mcp/config/settings.py +388 -0
- hanzo_mcp/config/tool_config.py +197 -0
- hanzo_mcp/prompts/__init__.py +117 -0
- hanzo_mcp/prompts/compact_conversation.py +77 -0
- hanzo_mcp/prompts/create_release.py +38 -0
- hanzo_mcp/prompts/project_system.py +120 -0
- hanzo_mcp/prompts/project_todo_reminder.py +111 -0
- hanzo_mcp/prompts/utils.py +286 -0
- hanzo_mcp/server.py +117 -99
- hanzo_mcp/tools/__init__.py +105 -32
- hanzo_mcp/tools/agent/__init__.py +8 -11
- hanzo_mcp/tools/agent/agent_tool.py +290 -224
- hanzo_mcp/tools/agent/prompt.py +16 -13
- hanzo_mcp/tools/agent/tool_adapter.py +9 -9
- hanzo_mcp/tools/common/__init__.py +17 -16
- hanzo_mcp/tools/common/base.py +79 -110
- hanzo_mcp/tools/common/batch_tool.py +330 -0
- hanzo_mcp/tools/common/context.py +26 -292
- hanzo_mcp/tools/common/permissions.py +12 -12
- hanzo_mcp/tools/common/thinking_tool.py +153 -0
- hanzo_mcp/tools/common/validation.py +1 -63
- hanzo_mcp/tools/filesystem/__init__.py +88 -57
- hanzo_mcp/tools/filesystem/base.py +32 -24
- hanzo_mcp/tools/filesystem/content_replace.py +114 -107
- hanzo_mcp/tools/filesystem/directory_tree.py +129 -105
- hanzo_mcp/tools/filesystem/edit.py +279 -0
- hanzo_mcp/tools/filesystem/grep.py +458 -0
- hanzo_mcp/tools/filesystem/grep_ast_tool.py +250 -0
- hanzo_mcp/tools/filesystem/multi_edit.py +362 -0
- hanzo_mcp/tools/filesystem/read.py +255 -0
- hanzo_mcp/tools/filesystem/write.py +156 -0
- hanzo_mcp/tools/jupyter/__init__.py +41 -29
- hanzo_mcp/tools/jupyter/base.py +66 -57
- hanzo_mcp/tools/jupyter/{edit_notebook.py → notebook_edit.py} +162 -139
- hanzo_mcp/tools/jupyter/notebook_read.py +152 -0
- hanzo_mcp/tools/shell/__init__.py +29 -20
- hanzo_mcp/tools/shell/base.py +87 -45
- hanzo_mcp/tools/shell/bash_session.py +731 -0
- hanzo_mcp/tools/shell/bash_session_executor.py +295 -0
- hanzo_mcp/tools/shell/command_executor.py +435 -384
- hanzo_mcp/tools/shell/run_command.py +284 -131
- hanzo_mcp/tools/shell/run_command_windows.py +328 -0
- hanzo_mcp/tools/shell/session_manager.py +196 -0
- hanzo_mcp/tools/shell/session_storage.py +325 -0
- hanzo_mcp/tools/todo/__init__.py +66 -0
- hanzo_mcp/tools/todo/base.py +319 -0
- hanzo_mcp/tools/todo/todo_read.py +148 -0
- hanzo_mcp/tools/todo/todo_write.py +378 -0
- hanzo_mcp/tools/vector/__init__.py +95 -0
- hanzo_mcp/tools/vector/infinity_store.py +365 -0
- hanzo_mcp/tools/vector/project_manager.py +361 -0
- hanzo_mcp/tools/vector/vector_index.py +115 -0
- hanzo_mcp/tools/vector/vector_search.py +215 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/METADATA +33 -1
- hanzo_mcp-0.5.0.dist-info/RECORD +63 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/WHEEL +1 -1
- hanzo_mcp/tools/agent/base_provider.py +0 -73
- hanzo_mcp/tools/agent/litellm_provider.py +0 -45
- hanzo_mcp/tools/agent/lmstudio_agent.py +0 -385
- hanzo_mcp/tools/agent/lmstudio_provider.py +0 -219
- hanzo_mcp/tools/agent/provider_registry.py +0 -120
- hanzo_mcp/tools/common/error_handling.py +0 -86
- hanzo_mcp/tools/common/logging_config.py +0 -115
- hanzo_mcp/tools/common/session.py +0 -91
- hanzo_mcp/tools/common/think_tool.py +0 -123
- hanzo_mcp/tools/common/version_tool.py +0 -120
- hanzo_mcp/tools/filesystem/edit_file.py +0 -287
- hanzo_mcp/tools/filesystem/get_file_info.py +0 -170
- hanzo_mcp/tools/filesystem/read_files.py +0 -199
- hanzo_mcp/tools/filesystem/search_content.py +0 -275
- hanzo_mcp/tools/filesystem/write_file.py +0 -162
- hanzo_mcp/tools/jupyter/notebook_operations.py +0 -514
- hanzo_mcp/tools/jupyter/read_notebook.py +0 -165
- hanzo_mcp/tools/project/__init__.py +0 -64
- hanzo_mcp/tools/project/analysis.py +0 -886
- hanzo_mcp/tools/project/base.py +0 -66
- hanzo_mcp/tools/project/project_analyze.py +0 -173
- hanzo_mcp/tools/shell/run_script.py +0 -215
- hanzo_mcp/tools/shell/script_tool.py +0 -244
- hanzo_mcp-0.3.8.dist-info/RECORD +0 -53
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/licenses/LICENSE +0 -0
- {hanzo_mcp-0.3.8.dist-info → hanzo_mcp-0.5.0.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
"""Session storage module for shell command sessions.
|
|
2
|
+
|
|
3
|
+
This module provides storage functionality for managing persistent shell sessions.
|
|
4
|
+
It supports both global class-based storage (for backward compatibility) and
|
|
5
|
+
instance-based storage (for dependency injection scenarios).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import time
|
|
9
|
+
from typing import TYPE_CHECKING, final
|
|
10
|
+
|
|
11
|
+
if TYPE_CHECKING:
|
|
12
|
+
from hanzo_mcp.tools.shell.bash_session import BashSession
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@final
|
|
16
|
+
class SessionStorageInstance:
|
|
17
|
+
"""Instance-based storage for shell command sessions.
|
|
18
|
+
|
|
19
|
+
This class provides isolated storage for different SessionManager instances,
|
|
20
|
+
preventing shared state between different contexts. It includes LRU eviction
|
|
21
|
+
and TTL-based cleanup to prevent memory leaks.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, max_sessions: int = 20, default_ttl_seconds: int = 300):
|
|
25
|
+
"""Initialize instance storage.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
max_sessions: Maximum number of sessions to keep (LRU eviction after this)
|
|
29
|
+
default_ttl_seconds: Default TTL for sessions in seconds (5 minutes)
|
|
30
|
+
"""
|
|
31
|
+
self._sessions: dict[str, "BashSession"] = {}
|
|
32
|
+
self._last_access: dict[str, float] = {}
|
|
33
|
+
self._access_order: list[str] = [] # Track access order for LRU
|
|
34
|
+
self.max_sessions = max_sessions
|
|
35
|
+
self.default_ttl_seconds = default_ttl_seconds
|
|
36
|
+
|
|
37
|
+
def _update_access_order(self, session_id: str) -> None:
|
|
38
|
+
"""Update the access order for LRU tracking.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
session_id: The session that was accessed
|
|
42
|
+
"""
|
|
43
|
+
# Remove from current position if exists
|
|
44
|
+
if session_id in self._access_order:
|
|
45
|
+
self._access_order.remove(session_id)
|
|
46
|
+
# Add to end (most recently used)
|
|
47
|
+
self._access_order.append(session_id)
|
|
48
|
+
|
|
49
|
+
def _evict_lru_if_needed(self) -> None:
|
|
50
|
+
"""Evict least recently used sessions if over capacity."""
|
|
51
|
+
# More aggressive eviction: start evicting when we reach 80% capacity
|
|
52
|
+
eviction_threshold = max(1, int(self.max_sessions * 0.8))
|
|
53
|
+
while len(self._sessions) >= eviction_threshold and self._access_order:
|
|
54
|
+
# Get least recently used session (first in list)
|
|
55
|
+
lru_session_id = self._access_order[0]
|
|
56
|
+
self.remove_session(lru_session_id)
|
|
57
|
+
|
|
58
|
+
def get_session(self, session_id: str) -> "BashSession | None":
|
|
59
|
+
"""Get a session by ID.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
session_id: The session identifier
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
The session if found, None otherwise
|
|
66
|
+
"""
|
|
67
|
+
session = self._sessions.get(session_id)
|
|
68
|
+
if session:
|
|
69
|
+
current_time = time.time()
|
|
70
|
+
self._last_access[session_id] = current_time
|
|
71
|
+
self._update_access_order(session_id)
|
|
72
|
+
|
|
73
|
+
# Check if session has expired
|
|
74
|
+
session_age = current_time - self._last_access.get(session_id, current_time)
|
|
75
|
+
if session_age > self.default_ttl_seconds:
|
|
76
|
+
self.remove_session(session_id)
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
return session
|
|
80
|
+
|
|
81
|
+
def set_session(self, session_id: str, session: "BashSession") -> None:
|
|
82
|
+
"""Store a session.
|
|
83
|
+
|
|
84
|
+
Args:
|
|
85
|
+
session_id: The session identifier
|
|
86
|
+
session: The session to store
|
|
87
|
+
"""
|
|
88
|
+
current_time = time.time()
|
|
89
|
+
|
|
90
|
+
# If session already exists, update it
|
|
91
|
+
if session_id in self._sessions:
|
|
92
|
+
self._sessions[session_id] = session
|
|
93
|
+
self._last_access[session_id] = current_time
|
|
94
|
+
self._update_access_order(session_id)
|
|
95
|
+
else:
|
|
96
|
+
# New session - check if we need to evict first
|
|
97
|
+
self._evict_lru_if_needed()
|
|
98
|
+
|
|
99
|
+
# Add new session
|
|
100
|
+
self._sessions[session_id] = session
|
|
101
|
+
self._last_access[session_id] = current_time
|
|
102
|
+
self._update_access_order(session_id)
|
|
103
|
+
|
|
104
|
+
def remove_session(self, session_id: str) -> bool:
|
|
105
|
+
"""Remove a session from storage.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
session_id: The session identifier
|
|
109
|
+
|
|
110
|
+
Returns:
|
|
111
|
+
True if session was removed, False if not found
|
|
112
|
+
"""
|
|
113
|
+
session = self._sessions.pop(session_id, None)
|
|
114
|
+
self._last_access.pop(session_id, None)
|
|
115
|
+
|
|
116
|
+
# Remove from access order tracking
|
|
117
|
+
if session_id in self._access_order:
|
|
118
|
+
self._access_order.remove(session_id)
|
|
119
|
+
|
|
120
|
+
if session:
|
|
121
|
+
# Clean up the session resources
|
|
122
|
+
try:
|
|
123
|
+
session.close()
|
|
124
|
+
except Exception:
|
|
125
|
+
pass # Ignore cleanup errors
|
|
126
|
+
return True
|
|
127
|
+
return False
|
|
128
|
+
|
|
129
|
+
def get_session_count(self) -> int:
|
|
130
|
+
"""Get the number of active sessions.
|
|
131
|
+
|
|
132
|
+
Returns:
|
|
133
|
+
Number of active sessions
|
|
134
|
+
"""
|
|
135
|
+
return len(self._sessions)
|
|
136
|
+
|
|
137
|
+
def get_all_session_ids(self) -> list[str]:
|
|
138
|
+
"""Get all active session IDs.
|
|
139
|
+
|
|
140
|
+
Returns:
|
|
141
|
+
List of active session IDs
|
|
142
|
+
"""
|
|
143
|
+
return list(self._sessions.keys())
|
|
144
|
+
|
|
145
|
+
def cleanup_expired_sessions(self, max_age_seconds: int | None = None) -> int:
|
|
146
|
+
"""Clean up sessions that haven't been accessed recently.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
max_age_seconds: Maximum age in seconds before cleanup.
|
|
150
|
+
If None, uses instance default TTL.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Number of sessions cleaned up
|
|
154
|
+
"""
|
|
155
|
+
max_age = (
|
|
156
|
+
max_age_seconds if max_age_seconds is not None else self.default_ttl_seconds
|
|
157
|
+
)
|
|
158
|
+
current_time = time.time()
|
|
159
|
+
expired_sessions: list[str] = []
|
|
160
|
+
|
|
161
|
+
for session_id, last_access in self._last_access.items():
|
|
162
|
+
if current_time - last_access > max_age:
|
|
163
|
+
expired_sessions.append(session_id)
|
|
164
|
+
|
|
165
|
+
cleaned_count = 0
|
|
166
|
+
for session_id in expired_sessions:
|
|
167
|
+
if self.remove_session(session_id):
|
|
168
|
+
cleaned_count += 1
|
|
169
|
+
|
|
170
|
+
return cleaned_count
|
|
171
|
+
|
|
172
|
+
def clear_all_sessions(self) -> int:
|
|
173
|
+
"""Clear all sessions.
|
|
174
|
+
|
|
175
|
+
Returns:
|
|
176
|
+
Number of sessions cleared
|
|
177
|
+
"""
|
|
178
|
+
session_ids = list(self._sessions.keys())
|
|
179
|
+
cleared_count = 0
|
|
180
|
+
|
|
181
|
+
for session_id in session_ids:
|
|
182
|
+
if self.remove_session(session_id):
|
|
183
|
+
cleared_count += 1
|
|
184
|
+
|
|
185
|
+
return cleared_count
|
|
186
|
+
|
|
187
|
+
def get_lru_session_ids(self) -> list[str]:
|
|
188
|
+
"""Get session IDs in LRU order (least recently used first).
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
List of session IDs in LRU order
|
|
192
|
+
"""
|
|
193
|
+
return self._access_order.copy()
|
|
194
|
+
|
|
195
|
+
def get_session_stats(self) -> dict:
|
|
196
|
+
"""Get storage statistics.
|
|
197
|
+
|
|
198
|
+
Returns:
|
|
199
|
+
Dictionary with storage statistics
|
|
200
|
+
"""
|
|
201
|
+
return {
|
|
202
|
+
"total_sessions": len(self._sessions),
|
|
203
|
+
"max_sessions": self.max_sessions,
|
|
204
|
+
"utilization": len(self._sessions) / self.max_sessions
|
|
205
|
+
if self.max_sessions > 0
|
|
206
|
+
else 0,
|
|
207
|
+
"default_ttl_seconds": self.default_ttl_seconds,
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
class SessionStorage:
|
|
212
|
+
"""Global class-based storage for shell command sessions.
|
|
213
|
+
|
|
214
|
+
This class maintains backward compatibility while providing the same
|
|
215
|
+
interface as SessionStorageInstance for global session management.
|
|
216
|
+
"""
|
|
217
|
+
|
|
218
|
+
_sessions: dict[str, "BashSession"] = {}
|
|
219
|
+
_last_access: dict[str, float] = {}
|
|
220
|
+
|
|
221
|
+
@classmethod
|
|
222
|
+
def get_session(cls, session_id: str) -> "BashSession | None":
|
|
223
|
+
"""Get a session by ID.
|
|
224
|
+
|
|
225
|
+
Args:
|
|
226
|
+
session_id: The session identifier
|
|
227
|
+
|
|
228
|
+
Returns:
|
|
229
|
+
The session if found, None otherwise
|
|
230
|
+
"""
|
|
231
|
+
session = cls._sessions.get(session_id)
|
|
232
|
+
if session:
|
|
233
|
+
cls._last_access[session_id] = time.time()
|
|
234
|
+
return session
|
|
235
|
+
|
|
236
|
+
@classmethod
|
|
237
|
+
def set_session(cls, session_id: str, session: "BashSession") -> None:
|
|
238
|
+
"""Store a session.
|
|
239
|
+
|
|
240
|
+
Args:
|
|
241
|
+
session_id: The session identifier
|
|
242
|
+
session: The session to store
|
|
243
|
+
"""
|
|
244
|
+
cls._sessions[session_id] = session
|
|
245
|
+
cls._last_access[session_id] = time.time()
|
|
246
|
+
|
|
247
|
+
@classmethod
|
|
248
|
+
def remove_session(cls, session_id: str) -> bool:
|
|
249
|
+
"""Remove a session from storage.
|
|
250
|
+
|
|
251
|
+
Args:
|
|
252
|
+
session_id: The session identifier
|
|
253
|
+
|
|
254
|
+
Returns:
|
|
255
|
+
True if session was removed, False if not found
|
|
256
|
+
"""
|
|
257
|
+
session = cls._sessions.pop(session_id, None)
|
|
258
|
+
cls._last_access.pop(session_id, None)
|
|
259
|
+
|
|
260
|
+
if session:
|
|
261
|
+
# Clean up the session resources
|
|
262
|
+
try:
|
|
263
|
+
session.close()
|
|
264
|
+
except Exception:
|
|
265
|
+
pass # Ignore cleanup errors
|
|
266
|
+
return True
|
|
267
|
+
return False
|
|
268
|
+
|
|
269
|
+
@classmethod
|
|
270
|
+
def get_session_count(cls) -> int:
|
|
271
|
+
"""Get the number of active sessions.
|
|
272
|
+
|
|
273
|
+
Returns:
|
|
274
|
+
Number of active sessions
|
|
275
|
+
"""
|
|
276
|
+
return len(cls._sessions)
|
|
277
|
+
|
|
278
|
+
@classmethod
|
|
279
|
+
def get_all_session_ids(cls) -> list[str]:
|
|
280
|
+
"""Get all active session IDs.
|
|
281
|
+
|
|
282
|
+
Returns:
|
|
283
|
+
List of active session IDs
|
|
284
|
+
"""
|
|
285
|
+
return list(cls._sessions.keys())
|
|
286
|
+
|
|
287
|
+
@classmethod
|
|
288
|
+
def cleanup_expired_sessions(cls, max_age_seconds: int = 300) -> int:
|
|
289
|
+
"""Clean up sessions that haven't been accessed recently.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
max_age_seconds: Maximum age in seconds before cleanup (default: 5 minutes)
|
|
293
|
+
|
|
294
|
+
Returns:
|
|
295
|
+
Number of sessions cleaned up
|
|
296
|
+
"""
|
|
297
|
+
current_time = time.time()
|
|
298
|
+
expired_sessions: list[str] = []
|
|
299
|
+
|
|
300
|
+
for session_id, last_access in cls._last_access.items():
|
|
301
|
+
if current_time - last_access > max_age_seconds:
|
|
302
|
+
expired_sessions.append(session_id)
|
|
303
|
+
|
|
304
|
+
cleaned_count = 0
|
|
305
|
+
for session_id in expired_sessions:
|
|
306
|
+
if cls.remove_session(session_id):
|
|
307
|
+
cleaned_count += 1
|
|
308
|
+
|
|
309
|
+
return cleaned_count
|
|
310
|
+
|
|
311
|
+
@classmethod
|
|
312
|
+
def clear_all_sessions(cls) -> int:
|
|
313
|
+
"""Clear all sessions.
|
|
314
|
+
|
|
315
|
+
Returns:
|
|
316
|
+
Number of sessions cleared
|
|
317
|
+
"""
|
|
318
|
+
session_ids = list(cls._sessions.keys())
|
|
319
|
+
cleared_count = 0
|
|
320
|
+
|
|
321
|
+
for session_id in session_ids:
|
|
322
|
+
if cls.remove_session(session_id):
|
|
323
|
+
cleared_count += 1
|
|
324
|
+
|
|
325
|
+
return cleared_count
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Todo tools package for Hanzo MCP.
|
|
2
|
+
|
|
3
|
+
This package provides tools for managing todo lists across different Claude Desktop sessions,
|
|
4
|
+
using in-memory storage to maintain separate task lists for each conversation.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from fastmcp import FastMCP
|
|
8
|
+
|
|
9
|
+
from hanzo_mcp.tools.common.base import BaseTool, ToolRegistry
|
|
10
|
+
from hanzo_mcp.tools.todo.todo_read import TodoReadTool
|
|
11
|
+
from hanzo_mcp.tools.todo.todo_write import TodoWriteTool
|
|
12
|
+
|
|
13
|
+
# Export all tool classes
|
|
14
|
+
__all__ = [
|
|
15
|
+
"TodoReadTool",
|
|
16
|
+
"TodoWriteTool",
|
|
17
|
+
"get_todo_tools",
|
|
18
|
+
"register_todo_tools",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def get_todo_tools() -> list[BaseTool]:
|
|
23
|
+
"""Create instances of all todo tools.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
List of todo tool instances
|
|
27
|
+
"""
|
|
28
|
+
return [
|
|
29
|
+
TodoReadTool(),
|
|
30
|
+
TodoWriteTool(),
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def register_todo_tools(
|
|
35
|
+
mcp_server: FastMCP,
|
|
36
|
+
enabled_tools: dict[str, bool] | None = None,
|
|
37
|
+
) -> list[BaseTool]:
|
|
38
|
+
"""Register todo tools with the MCP server.
|
|
39
|
+
|
|
40
|
+
Args:
|
|
41
|
+
mcp_server: The FastMCP server instance
|
|
42
|
+
enabled_tools: Dictionary of individual tool enable states (default: None)
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
List of registered tools
|
|
46
|
+
"""
|
|
47
|
+
# Define tool mapping
|
|
48
|
+
tool_classes = {
|
|
49
|
+
"todo_read": TodoReadTool,
|
|
50
|
+
"todo_write": TodoWriteTool,
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
tools = []
|
|
54
|
+
|
|
55
|
+
if enabled_tools:
|
|
56
|
+
# Use individual tool configuration
|
|
57
|
+
for tool_name, enabled in enabled_tools.items():
|
|
58
|
+
if enabled and tool_name in tool_classes:
|
|
59
|
+
tool_class = tool_classes[tool_name]
|
|
60
|
+
tools.append(tool_class())
|
|
61
|
+
else:
|
|
62
|
+
# Use all tools (backward compatibility)
|
|
63
|
+
tools = get_todo_tools()
|
|
64
|
+
|
|
65
|
+
ToolRegistry.register_tools(mcp_server, tools)
|
|
66
|
+
return tools
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
"""Base functionality for todo tools.
|
|
2
|
+
|
|
3
|
+
This module provides common functionality for todo tools, including in-memory storage
|
|
4
|
+
for managing todo lists across different Claude Desktop sessions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import re
|
|
8
|
+
import time
|
|
9
|
+
from abc import ABC
|
|
10
|
+
from typing import Any, final
|
|
11
|
+
|
|
12
|
+
from fastmcp import Context as MCPContext
|
|
13
|
+
|
|
14
|
+
from hanzo_mcp.tools.common.base import BaseTool
|
|
15
|
+
from hanzo_mcp.tools.common.context import ToolContext, create_tool_context
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@final
|
|
19
|
+
class TodoStorage:
|
|
20
|
+
"""In-memory storage for todo lists, separated by session ID.
|
|
21
|
+
|
|
22
|
+
This class provides persistent storage for the lifetime of the MCP server process,
|
|
23
|
+
allowing different Claude Desktop conversations to maintain separate todo lists.
|
|
24
|
+
Each session stores both the todo list and a timestamp of when it was last updated.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Class-level storage shared across all tool instances
|
|
28
|
+
# Structure: {session_id: {"todos": [...], "last_updated": timestamp}}
|
|
29
|
+
_sessions: dict[str, dict[str, Any]] = {}
|
|
30
|
+
|
|
31
|
+
@classmethod
|
|
32
|
+
def get_todos(cls, session_id: str) -> list[dict[str, Any]]:
|
|
33
|
+
"""Get the todo list for a specific session.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
session_id: Unique identifier for the Claude Desktop session
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
List of todo items for the session, empty list if session doesn't exist
|
|
40
|
+
"""
|
|
41
|
+
session_data = cls._sessions.get(session_id, {})
|
|
42
|
+
return session_data.get("todos", [])
|
|
43
|
+
|
|
44
|
+
@classmethod
|
|
45
|
+
def set_todos(cls, session_id: str, todos: list[dict[str, Any]]) -> None:
|
|
46
|
+
"""Set the todo list for a specific session.
|
|
47
|
+
|
|
48
|
+
Args:
|
|
49
|
+
session_id: Unique identifier for the Claude Desktop session
|
|
50
|
+
todos: Complete list of todo items to store
|
|
51
|
+
"""
|
|
52
|
+
cls._sessions[session_id] = {"todos": todos, "last_updated": time.time()}
|
|
53
|
+
|
|
54
|
+
@classmethod
|
|
55
|
+
def get_session_count(cls) -> int:
|
|
56
|
+
"""Get the number of active sessions.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
Number of sessions with stored todos
|
|
60
|
+
"""
|
|
61
|
+
return len(cls._sessions)
|
|
62
|
+
|
|
63
|
+
@classmethod
|
|
64
|
+
def get_all_session_ids(cls) -> list[str]:
|
|
65
|
+
"""Get all active session IDs.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
List of all session IDs with stored todos
|
|
69
|
+
"""
|
|
70
|
+
return list(cls._sessions.keys())
|
|
71
|
+
|
|
72
|
+
@classmethod
|
|
73
|
+
def delete_session(cls, session_id: str) -> bool:
|
|
74
|
+
"""Delete a session and its todos.
|
|
75
|
+
|
|
76
|
+
Args:
|
|
77
|
+
session_id: Session ID to delete
|
|
78
|
+
|
|
79
|
+
Returns:
|
|
80
|
+
True if session was deleted, False if it didn't exist
|
|
81
|
+
"""
|
|
82
|
+
if session_id in cls._sessions:
|
|
83
|
+
del cls._sessions[session_id]
|
|
84
|
+
return True
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
@classmethod
|
|
88
|
+
def get_session_last_updated(cls, session_id: str) -> float | None:
|
|
89
|
+
"""Get the last updated timestamp for a session.
|
|
90
|
+
|
|
91
|
+
Args:
|
|
92
|
+
session_id: Session ID to check
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
Timestamp when session was last updated, or None if session doesn't exist
|
|
96
|
+
"""
|
|
97
|
+
session_data = cls._sessions.get(session_id)
|
|
98
|
+
if session_data:
|
|
99
|
+
return session_data.get("last_updated")
|
|
100
|
+
return None
|
|
101
|
+
|
|
102
|
+
@classmethod
|
|
103
|
+
def find_latest_active_session(cls) -> str | None:
|
|
104
|
+
"""Find the chronologically latest session with unfinished todos.
|
|
105
|
+
|
|
106
|
+
Returns the session ID of the most recently updated session that has unfinished todos.
|
|
107
|
+
Returns None if no sessions have unfinished todos.
|
|
108
|
+
|
|
109
|
+
Returns:
|
|
110
|
+
Session ID with unfinished todos that was most recently updated, or None if none found
|
|
111
|
+
"""
|
|
112
|
+
from hanzo_mcp.prompts.project_todo_reminder import has_unfinished_todos
|
|
113
|
+
|
|
114
|
+
latest_session = None
|
|
115
|
+
latest_timestamp = 0
|
|
116
|
+
|
|
117
|
+
for session_id, session_data in cls._sessions.items():
|
|
118
|
+
todos = session_data.get("todos", [])
|
|
119
|
+
if has_unfinished_todos(todos):
|
|
120
|
+
last_updated = session_data.get("last_updated", 0)
|
|
121
|
+
if last_updated > latest_timestamp:
|
|
122
|
+
latest_timestamp = last_updated
|
|
123
|
+
latest_session = session_id
|
|
124
|
+
|
|
125
|
+
return latest_session
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TodoBaseTool(BaseTool, ABC):
|
|
129
|
+
"""Base class for todo tools.
|
|
130
|
+
|
|
131
|
+
Provides common functionality for working with todo lists, including
|
|
132
|
+
session ID validation and todo structure validation.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
def create_tool_context(self, ctx: MCPContext) -> ToolContext:
|
|
136
|
+
"""Create a tool context with the tool name.
|
|
137
|
+
|
|
138
|
+
Args:
|
|
139
|
+
ctx: MCP context
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
Tool context
|
|
143
|
+
"""
|
|
144
|
+
tool_ctx = create_tool_context(ctx)
|
|
145
|
+
return tool_ctx
|
|
146
|
+
|
|
147
|
+
def set_tool_context_info(self, tool_ctx: ToolContext) -> None:
|
|
148
|
+
"""Set the tool info on the context.
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
tool_ctx: Tool context
|
|
152
|
+
"""
|
|
153
|
+
tool_ctx.set_tool_info(self.name)
|
|
154
|
+
|
|
155
|
+
def normalize_todo_item(self, todo: dict[str, Any], index: int) -> dict[str, Any]:
|
|
156
|
+
"""Normalize a single todo item by auto-generating missing required fields.
|
|
157
|
+
|
|
158
|
+
Args:
|
|
159
|
+
todo: Todo item to normalize
|
|
160
|
+
index: Index of the todo item for generating unique IDs
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
Normalized todo item with all required fields
|
|
164
|
+
"""
|
|
165
|
+
normalized = dict(todo) # Create a copy
|
|
166
|
+
|
|
167
|
+
# Auto-generate ID if missing or normalize existing ID to string
|
|
168
|
+
if "id" not in normalized or not str(normalized.get("id")).strip():
|
|
169
|
+
normalized["id"] = f"todo-{index + 1}"
|
|
170
|
+
else:
|
|
171
|
+
# Ensure ID is stored as a string for consistency
|
|
172
|
+
normalized["id"] = str(normalized["id"]).strip()
|
|
173
|
+
|
|
174
|
+
# Auto-generate priority if missing (but don't fix invalid values)
|
|
175
|
+
if "priority" not in normalized:
|
|
176
|
+
normalized["priority"] = "medium"
|
|
177
|
+
|
|
178
|
+
# Ensure status defaults to pending if missing (but don't fix invalid values)
|
|
179
|
+
if "status" not in normalized:
|
|
180
|
+
normalized["status"] = "pending"
|
|
181
|
+
|
|
182
|
+
return normalized
|
|
183
|
+
|
|
184
|
+
def normalize_todos_list(self, todos: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
185
|
+
"""Normalize a list of todo items by auto-generating missing fields.
|
|
186
|
+
|
|
187
|
+
Args:
|
|
188
|
+
todos: List of todo items to normalize
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
Normalized list of todo items with all required fields
|
|
192
|
+
"""
|
|
193
|
+
if not isinstance(todos, list):
|
|
194
|
+
return [] # Return empty list for invalid input
|
|
195
|
+
|
|
196
|
+
normalized_todos = []
|
|
197
|
+
used_ids = set()
|
|
198
|
+
|
|
199
|
+
for i, todo in enumerate(todos):
|
|
200
|
+
if not isinstance(todo, dict):
|
|
201
|
+
continue # Skip invalid items
|
|
202
|
+
|
|
203
|
+
normalized = self.normalize_todo_item(todo, i)
|
|
204
|
+
|
|
205
|
+
# Don't auto-fix duplicate IDs - let validation catch them
|
|
206
|
+
used_ids.add(normalized["id"])
|
|
207
|
+
normalized_todos.append(normalized)
|
|
208
|
+
|
|
209
|
+
return normalized_todos
|
|
210
|
+
|
|
211
|
+
def validate_session_id(self, session_id: str | None) -> tuple[bool, str]:
|
|
212
|
+
"""Validate session ID format and security.
|
|
213
|
+
|
|
214
|
+
Args:
|
|
215
|
+
session_id: Session ID to validate
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Tuple of (is_valid, error_message)
|
|
219
|
+
"""
|
|
220
|
+
# Check for None or empty first
|
|
221
|
+
if session_id is None or session_id == "":
|
|
222
|
+
return False, "Session ID is required but was empty"
|
|
223
|
+
|
|
224
|
+
# Check if it's a string
|
|
225
|
+
if not isinstance(session_id, str):
|
|
226
|
+
return False, "Session ID must be a string"
|
|
227
|
+
|
|
228
|
+
# Check length (reasonable bounds)
|
|
229
|
+
if len(session_id) < 5:
|
|
230
|
+
return False, "Session ID too short (minimum 5 characters)"
|
|
231
|
+
|
|
232
|
+
if len(session_id) > 100:
|
|
233
|
+
return False, "Session ID too long (maximum 100 characters)"
|
|
234
|
+
|
|
235
|
+
# Check format - allow alphanumeric, hyphens, underscores
|
|
236
|
+
# This prevents path traversal and other security issues
|
|
237
|
+
if not re.match(r"^[a-zA-Z0-9_-]+$", session_id):
|
|
238
|
+
return (
|
|
239
|
+
False,
|
|
240
|
+
"Session ID can only contain alphanumeric characters, hyphens, and underscores",
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
return True, ""
|
|
244
|
+
|
|
245
|
+
def validate_todo_item(self, todo: dict[str, Any]) -> tuple[bool, str]:
|
|
246
|
+
"""Validate a single todo item structure.
|
|
247
|
+
|
|
248
|
+
Args:
|
|
249
|
+
todo: Todo item to validate
|
|
250
|
+
|
|
251
|
+
Returns:
|
|
252
|
+
Tuple of (is_valid, error_message)
|
|
253
|
+
"""
|
|
254
|
+
if not isinstance(todo, dict):
|
|
255
|
+
return False, "Todo item must be an object"
|
|
256
|
+
|
|
257
|
+
# Check required fields
|
|
258
|
+
required_fields = ["content", "status", "priority", "id"]
|
|
259
|
+
for field in required_fields:
|
|
260
|
+
if field not in todo:
|
|
261
|
+
return False, f"Todo item missing required field: {field}"
|
|
262
|
+
|
|
263
|
+
# Validate content
|
|
264
|
+
content = todo.get("content")
|
|
265
|
+
if not isinstance(content, str) or not content.strip():
|
|
266
|
+
return False, "Todo content must be a non-empty string"
|
|
267
|
+
|
|
268
|
+
# Validate status
|
|
269
|
+
valid_statuses = ["pending", "in_progress", "completed"]
|
|
270
|
+
status = todo.get("status")
|
|
271
|
+
if status not in valid_statuses:
|
|
272
|
+
return False, f"Todo status must be one of: {', '.join(valid_statuses)}"
|
|
273
|
+
|
|
274
|
+
# Validate priority
|
|
275
|
+
valid_priorities = ["high", "medium", "low"]
|
|
276
|
+
priority = todo.get("priority")
|
|
277
|
+
if priority not in valid_priorities:
|
|
278
|
+
return False, f"Todo priority must be one of: {', '.join(valid_priorities)}"
|
|
279
|
+
|
|
280
|
+
# Validate ID
|
|
281
|
+
todo_id = todo.get("id")
|
|
282
|
+
if todo_id is None:
|
|
283
|
+
return False, "Todo id is required"
|
|
284
|
+
|
|
285
|
+
# Accept string, int, or float IDs
|
|
286
|
+
if not isinstance(todo_id, (str, int, float)):
|
|
287
|
+
return False, "Todo id must be a string, integer, or number"
|
|
288
|
+
|
|
289
|
+
# Convert to string and check if it's non-empty after stripping
|
|
290
|
+
todo_id_str = str(todo_id).strip()
|
|
291
|
+
if not todo_id_str:
|
|
292
|
+
return False, "Todo id must not be empty"
|
|
293
|
+
|
|
294
|
+
return True, ""
|
|
295
|
+
|
|
296
|
+
def validate_todos_list(self, todos: list[dict[str, Any]]) -> tuple[bool, str]:
|
|
297
|
+
"""Validate a list of todo items.
|
|
298
|
+
|
|
299
|
+
Args:
|
|
300
|
+
todos: List of todo items to validate
|
|
301
|
+
|
|
302
|
+
Returns:
|
|
303
|
+
Tuple of (is_valid, error_message)
|
|
304
|
+
"""
|
|
305
|
+
if not isinstance(todos, list):
|
|
306
|
+
return False, "Todos must be a list"
|
|
307
|
+
|
|
308
|
+
# Check each todo item
|
|
309
|
+
for i, todo in enumerate(todos):
|
|
310
|
+
is_valid, error_msg = self.validate_todo_item(todo)
|
|
311
|
+
if not is_valid:
|
|
312
|
+
return False, f"Todo item {i}: {error_msg}"
|
|
313
|
+
|
|
314
|
+
# Check for duplicate IDs
|
|
315
|
+
todo_ids = [todo.get("id") for todo in todos]
|
|
316
|
+
if len(todo_ids) != len(set(todo_ids)):
|
|
317
|
+
return False, "Todo items must have unique IDs"
|
|
318
|
+
|
|
319
|
+
return True, ""
|