hanzo-mcp 0.3.4__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.

Files changed (87) hide show
  1. hanzo_mcp/__init__.py +1 -1
  2. hanzo_mcp/cli.py +123 -160
  3. hanzo_mcp/cli_enhanced.py +438 -0
  4. hanzo_mcp/config/__init__.py +19 -0
  5. hanzo_mcp/config/settings.py +388 -0
  6. hanzo_mcp/config/tool_config.py +197 -0
  7. hanzo_mcp/prompts/__init__.py +117 -0
  8. hanzo_mcp/prompts/compact_conversation.py +77 -0
  9. hanzo_mcp/prompts/create_release.py +38 -0
  10. hanzo_mcp/prompts/project_system.py +120 -0
  11. hanzo_mcp/prompts/project_todo_reminder.py +111 -0
  12. hanzo_mcp/prompts/utils.py +286 -0
  13. hanzo_mcp/server.py +120 -98
  14. hanzo_mcp/tools/__init__.py +107 -31
  15. hanzo_mcp/tools/agent/__init__.py +8 -11
  16. hanzo_mcp/tools/agent/agent_tool.py +290 -224
  17. hanzo_mcp/tools/agent/prompt.py +16 -13
  18. hanzo_mcp/tools/agent/tool_adapter.py +9 -9
  19. hanzo_mcp/tools/common/__init__.py +17 -16
  20. hanzo_mcp/tools/common/base.py +79 -110
  21. hanzo_mcp/tools/common/batch_tool.py +330 -0
  22. hanzo_mcp/tools/common/context.py +26 -292
  23. hanzo_mcp/tools/common/permissions.py +12 -12
  24. hanzo_mcp/tools/common/thinking_tool.py +153 -0
  25. hanzo_mcp/tools/common/validation.py +1 -63
  26. hanzo_mcp/tools/filesystem/__init__.py +88 -41
  27. hanzo_mcp/tools/filesystem/base.py +32 -24
  28. hanzo_mcp/tools/filesystem/content_replace.py +114 -107
  29. hanzo_mcp/tools/filesystem/directory_tree.py +129 -105
  30. hanzo_mcp/tools/filesystem/edit.py +279 -0
  31. hanzo_mcp/tools/filesystem/grep.py +458 -0
  32. hanzo_mcp/tools/filesystem/grep_ast_tool.py +250 -0
  33. hanzo_mcp/tools/filesystem/multi_edit.py +362 -0
  34. hanzo_mcp/tools/filesystem/read.py +255 -0
  35. hanzo_mcp/tools/filesystem/write.py +156 -0
  36. hanzo_mcp/tools/jupyter/__init__.py +41 -29
  37. hanzo_mcp/tools/jupyter/base.py +66 -57
  38. hanzo_mcp/tools/jupyter/{edit_notebook.py → notebook_edit.py} +162 -139
  39. hanzo_mcp/tools/jupyter/notebook_read.py +152 -0
  40. hanzo_mcp/tools/shell/__init__.py +29 -20
  41. hanzo_mcp/tools/shell/base.py +87 -45
  42. hanzo_mcp/tools/shell/bash_session.py +731 -0
  43. hanzo_mcp/tools/shell/bash_session_executor.py +295 -0
  44. hanzo_mcp/tools/shell/command_executor.py +435 -384
  45. hanzo_mcp/tools/shell/run_command.py +284 -131
  46. hanzo_mcp/tools/shell/run_command_windows.py +328 -0
  47. hanzo_mcp/tools/shell/session_manager.py +196 -0
  48. hanzo_mcp/tools/shell/session_storage.py +325 -0
  49. hanzo_mcp/tools/todo/__init__.py +66 -0
  50. hanzo_mcp/tools/todo/base.py +319 -0
  51. hanzo_mcp/tools/todo/todo_read.py +148 -0
  52. hanzo_mcp/tools/todo/todo_write.py +378 -0
  53. hanzo_mcp/tools/vector/__init__.py +95 -0
  54. hanzo_mcp/tools/vector/infinity_store.py +365 -0
  55. hanzo_mcp/tools/vector/project_manager.py +361 -0
  56. hanzo_mcp/tools/vector/vector_index.py +115 -0
  57. hanzo_mcp/tools/vector/vector_search.py +215 -0
  58. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/METADATA +35 -3
  59. hanzo_mcp-0.5.0.dist-info/RECORD +63 -0
  60. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/WHEEL +1 -1
  61. hanzo_mcp/tools/agent/base_provider.py +0 -73
  62. hanzo_mcp/tools/agent/litellm_provider.py +0 -45
  63. hanzo_mcp/tools/agent/lmstudio_agent.py +0 -385
  64. hanzo_mcp/tools/agent/lmstudio_provider.py +0 -219
  65. hanzo_mcp/tools/agent/provider_registry.py +0 -120
  66. hanzo_mcp/tools/common/error_handling.py +0 -86
  67. hanzo_mcp/tools/common/logging_config.py +0 -115
  68. hanzo_mcp/tools/common/session.py +0 -91
  69. hanzo_mcp/tools/common/think_tool.py +0 -123
  70. hanzo_mcp/tools/common/version_tool.py +0 -120
  71. hanzo_mcp/tools/filesystem/edit_file.py +0 -287
  72. hanzo_mcp/tools/filesystem/get_file_info.py +0 -170
  73. hanzo_mcp/tools/filesystem/read_files.py +0 -198
  74. hanzo_mcp/tools/filesystem/search_content.py +0 -275
  75. hanzo_mcp/tools/filesystem/write_file.py +0 -162
  76. hanzo_mcp/tools/jupyter/notebook_operations.py +0 -514
  77. hanzo_mcp/tools/jupyter/read_notebook.py +0 -165
  78. hanzo_mcp/tools/project/__init__.py +0 -64
  79. hanzo_mcp/tools/project/analysis.py +0 -882
  80. hanzo_mcp/tools/project/base.py +0 -66
  81. hanzo_mcp/tools/project/project_analyze.py +0 -173
  82. hanzo_mcp/tools/shell/run_script.py +0 -215
  83. hanzo_mcp/tools/shell/script_tool.py +0 -244
  84. hanzo_mcp-0.3.4.dist-info/RECORD +0 -53
  85. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/entry_points.txt +0 -0
  86. {hanzo_mcp-0.3.4.dist-info → hanzo_mcp-0.5.0.dist-info}/licenses/LICENSE +0 -0
  87. {hanzo_mcp-0.3.4.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, ""