mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-30 15:03:38 +00:00
🎨 **Tool Palette System** - Created palette system for organizing development tools by ecosystem - Pre-defined palettes: Python (Guido), Ruby (Matz), JavaScript, Go, Rust, DevOps, Full Stack, Minimal - `palette` tool to list, activate, and manage tool sets - Environment variables applied from active palette - Keeps MCP interface clean while supporting specialized workflows 🔧 **Unified Process Tools with OO Design** - Refactored all process execution to use clean base class hierarchy - `BaseProcessTool`, `BaseBinaryTool`, `BaseScriptTool` for consistent interface - Unified `bash`, `npx`, `uvx` tools with action-based approach (run/background) - `process` tool for managing background processes - All tools use same execution pipeline internally 🐚 **Smart Shell Detection** - Shell tool now detects user's preferred shell from $SHELL environment - Automatically uses zsh if .zshrc exists (perfect for your setup\!) - Falls back to bash if no special configuration found - All zsh aliases, functions, and configurations now available in MCP 🚀 **Development Mode with Hot Reload** - Added `--dev` flag to hanzo-mcp CLI for development mode - File watching with automatic reload detection using watchdog - `hanzo-mcp-dev` command for dedicated dev server - Watches Python files and config changes - Debounced reloading to prevent spam 🛠️ **New Tools Added** - `open`: Opens files/URLs in default application (platform-aware) - `watch`: Monitors files/directories for changes with pattern matching - `diff`: Compares files with unified/context diff formats - `config`: Git-style configuration management 📚 **Documentation & Infrastructure** - Comprehensive Sphinx documentation with black theme - Automated deployment to mcp.hanzo.ai via Cloudflare Pages - GitHub Actions workflow for docs deployment - 25+ documentation pages organized by category ⚙️ **Configuration Improvements** - Tool enable/disable system with palette override - Per-project vs global indexing configuration - Environment variable management through palettes - Clean separation of concerns in tool registration This release provides a much cleaner, more extensible architecture while maintaining backward compatibility. The palette system makes it easy to switch between development environments, and the unified process tools eliminate code duplication while providing consistent behavior. Co-authored-by: Hanzo Dev <[email protected]>
320 lines
10 KiB
Python
320 lines
10 KiB
Python
"""Base functionality for todo tools.
|
|
|
|
This module provides common functionality for todo tools, including in-memory storage
|
|
for managing todo lists across different Claude Desktop sessions.
|
|
"""
|
|
|
|
import re
|
|
import time
|
|
from abc import ABC
|
|
from typing import Any, final
|
|
|
|
from mcp.server.fastmcp import Context as MCPContext
|
|
|
|
from hanzo_mcp.tools.common.base import BaseTool
|
|
from hanzo_mcp.tools.common.context import ToolContext, create_tool_context
|
|
|
|
|
|
@final
|
|
class TodoStorage:
|
|
"""In-memory storage for todo lists, separated by session ID.
|
|
|
|
This class provides persistent storage for the lifetime of the MCP server process,
|
|
allowing different Claude Desktop conversations to maintain separate todo lists.
|
|
Each session stores both the todo list and a timestamp of when it was last updated.
|
|
"""
|
|
|
|
# Class-level storage shared across all tool instances
|
|
# Structure: {session_id: {"todos": [...], "last_updated": timestamp}}
|
|
_sessions: dict[str, dict[str, Any]] = {}
|
|
|
|
@classmethod
|
|
def get_todos(cls, session_id: str) -> list[dict[str, Any]]:
|
|
"""Get the todo list for a specific session.
|
|
|
|
Args:
|
|
session_id: Unique identifier for the Claude Desktop session
|
|
|
|
Returns:
|
|
List of todo items for the session, empty list if session doesn't exist
|
|
"""
|
|
session_data = cls._sessions.get(session_id, {})
|
|
return session_data.get("todos", [])
|
|
|
|
@classmethod
|
|
def set_todos(cls, session_id: str, todos: list[dict[str, Any]]) -> None:
|
|
"""Set the todo list for a specific session.
|
|
|
|
Args:
|
|
session_id: Unique identifier for the Claude Desktop session
|
|
todos: Complete list of todo items to store
|
|
"""
|
|
cls._sessions[session_id] = {"todos": todos, "last_updated": time.time()}
|
|
|
|
@classmethod
|
|
def get_session_count(cls) -> int:
|
|
"""Get the number of active sessions.
|
|
|
|
Returns:
|
|
Number of sessions with stored todos
|
|
"""
|
|
return len(cls._sessions)
|
|
|
|
@classmethod
|
|
def get_all_session_ids(cls) -> list[str]:
|
|
"""Get all active session IDs.
|
|
|
|
Returns:
|
|
List of all session IDs with stored todos
|
|
"""
|
|
return list(cls._sessions.keys())
|
|
|
|
@classmethod
|
|
def delete_session(cls, session_id: str) -> bool:
|
|
"""Delete a session and its todos.
|
|
|
|
Args:
|
|
session_id: Session ID to delete
|
|
|
|
Returns:
|
|
True if session was deleted, False if it didn't exist
|
|
"""
|
|
if session_id in cls._sessions:
|
|
del cls._sessions[session_id]
|
|
return True
|
|
return False
|
|
|
|
@classmethod
|
|
def get_session_last_updated(cls, session_id: str) -> float | None:
|
|
"""Get the last updated timestamp for a session.
|
|
|
|
Args:
|
|
session_id: Session ID to check
|
|
|
|
Returns:
|
|
Timestamp when session was last updated, or None if session doesn't exist
|
|
"""
|
|
session_data = cls._sessions.get(session_id)
|
|
if session_data:
|
|
return session_data.get("last_updated")
|
|
return None
|
|
|
|
@classmethod
|
|
def find_latest_active_session(cls) -> str | None:
|
|
"""Find the chronologically latest session with unfinished todos.
|
|
|
|
Returns the session ID of the most recently updated session that has unfinished todos.
|
|
Returns None if no sessions have unfinished todos.
|
|
|
|
Returns:
|
|
Session ID with unfinished todos that was most recently updated, or None if none found
|
|
"""
|
|
from hanzo_mcp.prompts.project_todo_reminder import has_unfinished_todos
|
|
|
|
latest_session = None
|
|
latest_timestamp = 0
|
|
|
|
for session_id, session_data in cls._sessions.items():
|
|
todos = session_data.get("todos", [])
|
|
if has_unfinished_todos(todos):
|
|
last_updated = session_data.get("last_updated", 0)
|
|
if last_updated > latest_timestamp:
|
|
latest_timestamp = last_updated
|
|
latest_session = session_id
|
|
|
|
return latest_session
|
|
|
|
|
|
class TodoBaseTool(BaseTool, ABC):
|
|
"""Base class for todo tools.
|
|
|
|
Provides common functionality for working with todo lists, including
|
|
session ID validation and todo structure validation.
|
|
"""
|
|
|
|
def create_tool_context(self, ctx: MCPContext) -> ToolContext:
|
|
"""Create a tool context with the tool name.
|
|
|
|
Args:
|
|
ctx: MCP context
|
|
|
|
Returns:
|
|
Tool context
|
|
"""
|
|
tool_ctx = create_tool_context(ctx)
|
|
return tool_ctx
|
|
|
|
def set_tool_context_info(self, tool_ctx: ToolContext) -> None:
|
|
"""Set the tool info on the context.
|
|
|
|
Args:
|
|
tool_ctx: Tool context
|
|
"""
|
|
tool_ctx.set_tool_info(self.name)
|
|
|
|
def normalize_todo_item(self, todo: dict[str, Any], index: int) -> dict[str, Any]:
|
|
"""Normalize a single todo item by auto-generating missing required fields.
|
|
|
|
Args:
|
|
todo: Todo item to normalize
|
|
index: Index of the todo item for generating unique IDs
|
|
|
|
Returns:
|
|
Normalized todo item with all required fields
|
|
"""
|
|
normalized = dict(todo) # Create a copy
|
|
|
|
# Auto-generate ID if missing or normalize existing ID to string
|
|
if "id" not in normalized or not str(normalized.get("id")).strip():
|
|
normalized["id"] = f"todo-{index + 1}"
|
|
else:
|
|
# Ensure ID is stored as a string for consistency
|
|
normalized["id"] = str(normalized["id"]).strip()
|
|
|
|
# Auto-generate priority if missing (but don't fix invalid values)
|
|
if "priority" not in normalized:
|
|
normalized["priority"] = "medium"
|
|
|
|
# Ensure status defaults to pending if missing (but don't fix invalid values)
|
|
if "status" not in normalized:
|
|
normalized["status"] = "pending"
|
|
|
|
return normalized
|
|
|
|
def normalize_todos_list(self, todos: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
|
"""Normalize a list of todo items by auto-generating missing fields.
|
|
|
|
Args:
|
|
todos: List of todo items to normalize
|
|
|
|
Returns:
|
|
Normalized list of todo items with all required fields
|
|
"""
|
|
if not isinstance(todos, list):
|
|
return [] # Return empty list for invalid input
|
|
|
|
normalized_todos = []
|
|
used_ids = set()
|
|
|
|
for i, todo in enumerate(todos):
|
|
if not isinstance(todo, dict):
|
|
continue # Skip invalid items
|
|
|
|
normalized = self.normalize_todo_item(todo, i)
|
|
|
|
# Don't auto-fix duplicate IDs - let validation catch them
|
|
used_ids.add(normalized["id"])
|
|
normalized_todos.append(normalized)
|
|
|
|
return normalized_todos
|
|
|
|
def validate_session_id(self, session_id: str | None) -> tuple[bool, str]:
|
|
"""Validate session ID format and security.
|
|
|
|
Args:
|
|
session_id: Session ID to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
# Check for None or empty first
|
|
if session_id is None or session_id == "":
|
|
return False, "Session ID is required but was empty"
|
|
|
|
# Check if it's a string
|
|
if not isinstance(session_id, str):
|
|
return False, "Session ID must be a string"
|
|
|
|
# Check length (reasonable bounds)
|
|
if len(session_id) < 5:
|
|
return False, "Session ID too short (minimum 5 characters)"
|
|
|
|
if len(session_id) > 100:
|
|
return False, "Session ID too long (maximum 100 characters)"
|
|
|
|
# Check format - allow alphanumeric, hyphens, underscores
|
|
# This prevents path traversal and other security issues
|
|
if not re.match(r"^[a-zA-Z0-9_-]+$", session_id):
|
|
return (
|
|
False,
|
|
"Session ID can only contain alphanumeric characters, hyphens, and underscores",
|
|
)
|
|
|
|
return True, ""
|
|
|
|
def validate_todo_item(self, todo: dict[str, Any]) -> tuple[bool, str]:
|
|
"""Validate a single todo item structure.
|
|
|
|
Args:
|
|
todo: Todo item to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
if not isinstance(todo, dict):
|
|
return False, "Todo item must be an object"
|
|
|
|
# Check required fields
|
|
required_fields = ["content", "status", "priority", "id"]
|
|
for field in required_fields:
|
|
if field not in todo:
|
|
return False, f"Todo item missing required field: {field}"
|
|
|
|
# Validate content
|
|
content = todo.get("content")
|
|
if not isinstance(content, str) or not content.strip():
|
|
return False, "Todo content must be a non-empty string"
|
|
|
|
# Validate status
|
|
valid_statuses = ["pending", "in_progress", "completed"]
|
|
status = todo.get("status")
|
|
if status not in valid_statuses:
|
|
return False, f"Todo status must be one of: {', '.join(valid_statuses)}"
|
|
|
|
# Validate priority
|
|
valid_priorities = ["high", "medium", "low"]
|
|
priority = todo.get("priority")
|
|
if priority not in valid_priorities:
|
|
return False, f"Todo priority must be one of: {', '.join(valid_priorities)}"
|
|
|
|
# Validate ID
|
|
todo_id = todo.get("id")
|
|
if todo_id is None:
|
|
return False, "Todo id is required"
|
|
|
|
# Accept string, int, or float IDs
|
|
if not isinstance(todo_id, (str, int, float)):
|
|
return False, "Todo id must be a string, integer, or number"
|
|
|
|
# Convert to string and check if it's non-empty after stripping
|
|
todo_id_str = str(todo_id).strip()
|
|
if not todo_id_str:
|
|
return False, "Todo id must not be empty"
|
|
|
|
return True, ""
|
|
|
|
def validate_todos_list(self, todos: list[dict[str, Any]]) -> tuple[bool, str]:
|
|
"""Validate a list of todo items.
|
|
|
|
Args:
|
|
todos: List of todo items to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
if not isinstance(todos, list):
|
|
return False, "Todos must be a list"
|
|
|
|
# Check each todo item
|
|
for i, todo in enumerate(todos):
|
|
is_valid, error_msg = self.validate_todo_item(todo)
|
|
if not is_valid:
|
|
return False, f"Todo item {i}: {error_msg}"
|
|
|
|
# Check for duplicate IDs
|
|
todo_ids = [todo.get("id") for todo in todos]
|
|
if len(todo_ids) != len(set(todo_ids)):
|
|
return False, "Todo items must have unique IDs"
|
|
|
|
return True, ""
|