mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
feat(tools): bidirectional HIP-0300 action sync across Python/TS/Rust
Sync all tool actions across all three language implementations: - code: +8 actions (search_symbol, outline, metrics, exports, types, hierarchy, rename, grep_replace) - fetch: +2 actions (request, open) - think: restructured to 12 action-based handlers - tasks: +12 actions (stats, search, batch, archive, move, prioritize, assign, subtasks, notes, export, import, delete) - plan: +13 actions (create, show, list, next, archive, add_step, remove_step, estimate, visualize, clone, cancel, notes, progress) - mode: +3 actions (switch alias, list_presets, select_preset) - memory: +10 actions (search, stats, clear, export, import, merge, tag, untag, namespaces, history) - fs: +1 action (mv) - git: +21 actions (blame, show, stash, tag, remote, merge, rebase, cherry_pick, reset, clean, init, clone, fetch, pull, push, config, worktree, reflog, shortlog, rev_parse, describe, bisect)
This commit is contained in:
@@ -640,5 +640,178 @@ All operations are PURE - safe to cache and parallelize.
|
||||
"next_actions": next_actions,
|
||||
}
|
||||
|
||||
# ── TS-parity actions ──────────────────────────────────────────
|
||||
|
||||
@self.action("search_symbol", "Find symbols across project")
|
||||
async def search_symbol(
|
||||
ctx: MCPContext,
|
||||
query: str,
|
||||
path: str | None = None,
|
||||
max_results: int = 20,
|
||||
) -> dict:
|
||||
import re
|
||||
search_dir = Path(path) if path else Path(self.cwd)
|
||||
results = []
|
||||
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
||||
for p in search_dir.rglob("*"):
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
|
||||
continue
|
||||
try:
|
||||
text = p.read_text(errors="ignore")
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
if query in line and re.search(r'\b(def|class|function|fn|struct|enum|interface|type|const|let|var|pub)\b', line):
|
||||
results.append({"uri": str(p), "line": i, "text": line.strip(), "type": "definition"})
|
||||
if len(results) >= max_results:
|
||||
break
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
return {"query": query, "results": results, "count": len(results)}
|
||||
|
||||
@self.action("outline", "List symbols with import count and export flag")
|
||||
async def outline(ctx: MCPContext, path: str, text: str | None = None) -> dict:
|
||||
import re
|
||||
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
|
||||
if text is None:
|
||||
if not full_path.exists():
|
||||
raise NotFoundError(f"File not found: {path}")
|
||||
text = full_path.read_text()
|
||||
lines = text.splitlines()
|
||||
syms = []
|
||||
imports = 0
|
||||
sym_re = re.compile(r'(?:export\s+)?(?:async\s+)?(?:function|class|interface|type|enum|const|let|var|def|fn|pub\s+fn|pub\s+struct|struct|impl)\s+(\w+)')
|
||||
imp_re = re.compile(r'^(?:import|from|require|use)\b')
|
||||
for i, line in enumerate(lines, 1):
|
||||
if imp_re.match(line.strip()):
|
||||
imports += 1
|
||||
m = sym_re.search(line)
|
||||
if m:
|
||||
kind_m = re.search(r'\b(class|interface|type|enum|function|const|struct|impl|fn|def)\b', line)
|
||||
syms.append({"name": m.group(1), "kind": kind_m.group(1) if kind_m else "symbol", "line": i, "exported": line.strip().startswith(("export ", "pub "))})
|
||||
return {"uri": str(full_path), "symbols": syms, "imports": imports, "lines": len(lines)}
|
||||
|
||||
@self.action("metrics", "Count files and lines by extension")
|
||||
async def metrics(ctx: MCPContext, path: str | None = None) -> dict:
|
||||
search_dir = Path(path) if path else Path(self.cwd)
|
||||
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
||||
by_ext: dict[str, dict[str, int]] = {}
|
||||
total_files = 0
|
||||
total_lines = 0
|
||||
for p in search_dir.rglob("*"):
|
||||
if any(sd in p.parts for sd in skip_dirs) or not p.is_file():
|
||||
continue
|
||||
ext = p.suffix or "other"
|
||||
try:
|
||||
lines = len(p.read_text(errors="ignore").splitlines())
|
||||
if ext not in by_ext:
|
||||
by_ext[ext] = {"files": 0, "lines": 0}
|
||||
by_ext[ext]["files"] += 1
|
||||
by_ext[ext]["lines"] += lines
|
||||
total_files += 1
|
||||
total_lines += lines
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
return {"total_files": total_files, "total_lines": total_lines, "by_extension": by_ext}
|
||||
|
||||
@self.action("exports", "Extract public exports from file")
|
||||
async def exports(ctx: MCPContext, path: str) -> dict:
|
||||
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
|
||||
if not full_path.exists():
|
||||
raise NotFoundError(f"File not found: {path}")
|
||||
text = full_path.read_text()
|
||||
export_lines = [l.strip() for l in text.splitlines() if l.strip().startswith(("export ", "pub ", "__all__"))]
|
||||
return {"uri": str(full_path), "exports": export_lines, "count": len(export_lines)}
|
||||
|
||||
@self.action("types", "Find type definitions in file")
|
||||
async def types(ctx: MCPContext, path: str) -> dict:
|
||||
import re
|
||||
full_path = Path(path) if Path(path).is_absolute() else Path(self.cwd) / path
|
||||
if not full_path.exists():
|
||||
raise NotFoundError(f"File not found: {path}")
|
||||
text = full_path.read_text()
|
||||
type_defs = []
|
||||
type_re = re.compile(r'(?:interface|type|enum|struct|class)\s+(\w+)')
|
||||
for i, line in enumerate(text.splitlines(), 1):
|
||||
m = type_re.search(line)
|
||||
if m:
|
||||
type_defs.append({"name": m.group(1), "line": i, "text": line.strip()})
|
||||
return {"uri": str(full_path), "types": type_defs, "count": len(type_defs)}
|
||||
|
||||
@self.action("hierarchy", "Build class inheritance tree")
|
||||
async def hierarchy(ctx: MCPContext, query: str, path: str | None = None) -> dict:
|
||||
import re
|
||||
search_dir = Path(path) if path else Path(self.cwd)
|
||||
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
||||
classes: dict[str, list[str]] = {}
|
||||
class_re = re.compile(r'class\s+(\w+)(?:\s*\(([^)]+)\)|\s+extends\s+(\w+))?')
|
||||
for p in search_dir.rglob("*"):
|
||||
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
|
||||
continue
|
||||
try:
|
||||
for m in class_re.finditer(p.read_text(errors="ignore")):
|
||||
name = m.group(1)
|
||||
parent = m.group(3) or (m.group(2).split(",")[0].strip() if m.group(2) else None)
|
||||
if name not in classes:
|
||||
classes[name] = []
|
||||
if parent and parent not in ("object", "Object"):
|
||||
classes.setdefault(parent, []).append(name)
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
def build(n: str, d: int = 0) -> str:
|
||||
out = " " * d + n + "\n"
|
||||
for c in classes.get(n, []):
|
||||
out += build(c, d + 1)
|
||||
return out
|
||||
return {"root": query, "tree": build(query), "children": classes.get(query, [])}
|
||||
|
||||
@self.action("rename", "Rename symbol across files")
|
||||
async def rename(ctx: MCPContext, query: str, new_name: str, path: str | None = None) -> dict:
|
||||
import re
|
||||
search_dir = Path(path) if path else Path(self.cwd)
|
||||
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
||||
total_changes = 0
|
||||
changed = []
|
||||
word_re = re.compile(rf'\b{re.escape(query)}\b')
|
||||
for p in search_dir.rglob("*"):
|
||||
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
|
||||
continue
|
||||
try:
|
||||
text = p.read_text(errors="ignore")
|
||||
if word_re.search(text):
|
||||
count = len(word_re.findall(text))
|
||||
p.write_text(word_re.sub(new_name, text))
|
||||
total_changes += count
|
||||
changed.append(f"{p}: {count} replacements")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
return {"old_name": query, "new_name": new_name, "files_changed": len(changed), "total_replacements": total_changes, "changed": changed}
|
||||
|
||||
@self.action("grep_replace", "Pattern replacement across codebase")
|
||||
async def grep_replace(ctx: MCPContext, query: str, replacement: str, path: str | None = None) -> dict:
|
||||
import re as re_mod
|
||||
search_dir = Path(path) if path else Path(self.cwd)
|
||||
skip_dirs = {".git", "node_modules", "target", "dist", "__pycache__", ".venv"}
|
||||
total_changes = 0
|
||||
changed = []
|
||||
try:
|
||||
regex = re_mod.compile(query)
|
||||
except re_mod.error as e:
|
||||
raise InvalidParamsError(f"Invalid regex: {e}", param="query")
|
||||
for p in search_dir.rglob("*"):
|
||||
if any(sd in p.parts for sd in skip_dirs) or not p.is_file() or p.suffix not in LANG_MAP:
|
||||
continue
|
||||
try:
|
||||
text = p.read_text(errors="ignore")
|
||||
if regex.search(text):
|
||||
count = len(regex.findall(text))
|
||||
p.write_text(regex.sub(replacement, text))
|
||||
total_changes += count
|
||||
changed.append(f"{p}: {count}")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
continue
|
||||
return {"pattern": query, "replacement": replacement, "files_changed": len(changed), "total_replacements": total_changes, "changed": changed}
|
||||
|
||||
|
||||
# Singleton instance
|
||||
code_tool = CodeTool
|
||||
|
||||
@@ -24,14 +24,17 @@ class ModeTool(BaseTool):
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description."""
|
||||
return """Manage development modes (programmer personalities). Actions: list (default), activate, show, current.
|
||||
return """Manage development modes (programmer personalities). Actions: list (default), activate, switch, show, current, list_presets, select_preset.
|
||||
|
||||
Usage:
|
||||
mode
|
||||
mode --action list
|
||||
mode --action activate guido
|
||||
mode --action switch guido
|
||||
mode --action show linus
|
||||
mode --action current"""
|
||||
mode --action current
|
||||
mode --action list_presets
|
||||
mode --action select_preset --name fullstack"""
|
||||
|
||||
@override
|
||||
async def run(
|
||||
@@ -198,7 +201,7 @@ mode --action current"""
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
elif action == "activate":
|
||||
elif action in ("activate", "switch"):
|
||||
if not name:
|
||||
return "Error: Mode name required for activate action"
|
||||
|
||||
@@ -304,8 +307,40 @@ mode --action current"""
|
||||
|
||||
return "\n".join(output)
|
||||
|
||||
elif action == "list_presets":
|
||||
modes = ModeRegistry.list()
|
||||
presets = [m for m in modes if m.name in [
|
||||
"fullstack", "minimal", "data_scientist", "devops",
|
||||
"security", "academic", "startup", "enterprise",
|
||||
"creative", "hanzo",
|
||||
]]
|
||||
if not presets:
|
||||
return "No presets available"
|
||||
output = ["Available presets:"]
|
||||
for p in presets:
|
||||
output.append(f" {p.name}: {p.programmer} - {p.description}")
|
||||
return "\n".join(output)
|
||||
|
||||
elif action == "select_preset":
|
||||
if not name:
|
||||
return "Error: Preset name required"
|
||||
# select_preset is same as activate but only for preset modes
|
||||
preset_names = [
|
||||
"fullstack", "minimal", "data_scientist", "devops",
|
||||
"security", "academic", "startup", "enterprise",
|
||||
"creative", "hanzo",
|
||||
]
|
||||
if name not in preset_names:
|
||||
return f"Error: '{name}' is not a preset. Use list_presets to see available presets."
|
||||
try:
|
||||
ModeRegistry.set_active(name)
|
||||
mode = ModeRegistry.get(name)
|
||||
return f"Selected preset: {mode.name} ({mode.programmer})"
|
||||
except ValueError as e:
|
||||
return str(e)
|
||||
|
||||
else:
|
||||
return f"Unknown action: {action}. Use 'list', 'activate', 'show', or 'current'"
|
||||
return f"Unknown action: {action}. Use 'list', 'activate', 'switch', 'show', 'current', 'list_presets', or 'select_preset'"
|
||||
|
||||
def register(self, server: FastMCP) -> None:
|
||||
"""Register the tool with the MCP server."""
|
||||
|
||||
@@ -766,6 +766,35 @@ patch supports Rust grammar format: *** Begin Patch / *** Update File: / @@ / -o
|
||||
},
|
||||
}
|
||||
|
||||
@self.action("mv", "Move or rename file/directory")
|
||||
async def mv(ctx: MCPContext, uri: str, destination: str) -> dict:
|
||||
"""Move or rename a file or directory.
|
||||
|
||||
Args:
|
||||
uri: Source path
|
||||
destination: Destination path
|
||||
"""
|
||||
import shutil
|
||||
|
||||
src_str = uri.replace("file://", "") if uri.startswith("file://") else uri
|
||||
dst_str = destination.replace("file://", "") if destination.startswith("file://") else destination
|
||||
src_path = self._validate_path(src_str)
|
||||
dst_path = self._validate_path(dst_str)
|
||||
self._check_permission(str(src_path))
|
||||
self._check_permission(str(dst_path))
|
||||
|
||||
if not src_path.exists():
|
||||
raise NotFoundError(f"Source not found: {src_path}", uri=uri)
|
||||
|
||||
dst_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(src_path), str(dst_path))
|
||||
|
||||
return {
|
||||
"source": file_uri(str(src_path)),
|
||||
"destination": file_uri(str(dst_path)),
|
||||
"moved": True,
|
||||
}
|
||||
|
||||
@self.action("mkdir", "Create directory")
|
||||
async def mkdir(ctx: MCPContext, uri: str) -> dict:
|
||||
"""Create directory and parent directories."""
|
||||
|
||||
@@ -66,6 +66,16 @@ Action = Annotated[
|
||||
"summarize", # Summarize and store
|
||||
"kb", # Manage knowledge bases
|
||||
"list", # List memories/knowledge bases
|
||||
"search", # Search memories (alias for recall)
|
||||
"stats", # Memory statistics
|
||||
"clear", # Clear all memories
|
||||
"export", # Export memories as JSON
|
||||
"import", # Import memories from JSON
|
||||
"merge", # Merge duplicate memories
|
||||
"tag", # Add tags to memory
|
||||
"untag", # Remove tags from memory
|
||||
"namespaces", # List namespaces/scopes
|
||||
"history", # Memory change history
|
||||
],
|
||||
Field(description="Memory action to perform"),
|
||||
]
|
||||
@@ -187,8 +197,30 @@ Examples:
|
||||
return await self._manage_kb(kb_action or "list", name)
|
||||
elif action == "list":
|
||||
return await self._list(list_type, scope, limit)
|
||||
elif action == "search":
|
||||
return await self._recall(
|
||||
queries or ([query] if query else []), scope, limit, backend
|
||||
)
|
||||
elif action == "stats":
|
||||
return await self._stats(scope)
|
||||
elif action == "clear":
|
||||
return await self._clear(scope)
|
||||
elif action == "export":
|
||||
return await self._export(scope, limit)
|
||||
elif action == "import":
|
||||
return await self._import(content)
|
||||
elif action == "merge":
|
||||
return await self._merge(ids or [])
|
||||
elif action == "tag":
|
||||
return await self._tag(id, tags or [])
|
||||
elif action == "untag":
|
||||
return await self._untag(id, tags or [])
|
||||
elif action == "namespaces":
|
||||
return await self._namespaces()
|
||||
elif action == "history":
|
||||
return await self._history(id, limit)
|
||||
else:
|
||||
return f"Unknown action: {action}. Use: recall, create, update, delete, manage, facts, store, summarize, kb, list"
|
||||
return f"Unknown action: {action}. Use: recall, create, update, delete, manage, facts, store, summarize, kb, list, search, stats, clear, export, import, merge, tag, untag, namespaces, history"
|
||||
|
||||
except ImportError as e:
|
||||
return f"Memory service not available: {e}"
|
||||
@@ -517,6 +549,139 @@ Examples:
|
||||
lines.append(f" [{mem.memory_id[:8]}] {mem.content[:60]}...")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _stats(self, scope: str) -> str:
|
||||
"""Get memory statistics."""
|
||||
from hanzo_tools.memory.markdown_memory import get_markdown_backend
|
||||
backend = get_markdown_backend()
|
||||
backend._ensure_loaded()
|
||||
file_count = len(backend._file_memories)
|
||||
session_count = len(backend._session_memories)
|
||||
total = file_count + session_count
|
||||
return f"Memory stats:\n Total: {total}\n File-backed: {file_count}\n Session: {session_count}\n Scope: {scope}"
|
||||
|
||||
async def _clear(self, scope: str) -> str:
|
||||
"""Clear all memories in scope."""
|
||||
from hanzo_tools.memory.markdown_memory import get_markdown_backend
|
||||
backend = get_markdown_backend()
|
||||
backend._ensure_loaded()
|
||||
if scope == "session":
|
||||
count = len(backend._session_memories)
|
||||
backend._session_memories.clear()
|
||||
return f"Cleared {count} session memories"
|
||||
return "Clear only supported for session scope. File-backed memories must be deleted individually."
|
||||
|
||||
async def _export(self, scope: str, limit: int) -> str:
|
||||
"""Export memories as JSON."""
|
||||
import json as json_mod
|
||||
from hanzo_tools.memory.markdown_memory import get_markdown_backend
|
||||
backend = get_markdown_backend()
|
||||
backend._ensure_loaded()
|
||||
memories = list(backend._file_memories) + list(backend._session_memories)
|
||||
items = []
|
||||
for mem in memories[:limit]:
|
||||
items.append({
|
||||
"id": mem.memory_id,
|
||||
"content": mem.content,
|
||||
"scope": getattr(mem, "scope", "global"),
|
||||
"source": getattr(mem, "source", "local"),
|
||||
})
|
||||
return json_mod.dumps({"memories": items, "count": len(items)}, indent=2, default=str)
|
||||
|
||||
async def _import(self, content: Optional[str]) -> str:
|
||||
"""Import memories from JSON."""
|
||||
import json as json_mod
|
||||
if not content:
|
||||
return "Error: content (JSON) required for import"
|
||||
try:
|
||||
data = json_mod.loads(content)
|
||||
items = data if isinstance(data, list) else data.get("memories", [])
|
||||
from hanzo_tools.memory.markdown_memory import get_markdown_backend
|
||||
backend = get_markdown_backend()
|
||||
count = 0
|
||||
for item in items:
|
||||
if isinstance(item, dict) and "content" in item:
|
||||
backend.add_memory(content=item["content"], scope=item.get("scope", "session"))
|
||||
count += 1
|
||||
return f"Imported {count} memories"
|
||||
except Exception as e:
|
||||
return f"Import error: {e}"
|
||||
|
||||
async def _merge(self, ids: List[str]) -> str:
|
||||
"""Merge duplicate memories."""
|
||||
if len(ids) < 2:
|
||||
return "Error: at least 2 IDs required for merge"
|
||||
from hanzo_tools.memory.markdown_memory import get_markdown_backend
|
||||
backend = get_markdown_backend()
|
||||
backend._ensure_loaded()
|
||||
contents = []
|
||||
for mid in ids:
|
||||
for mem in list(backend._file_memories) + list(backend._session_memories):
|
||||
if mem.memory_id == mid or mem.memory_id.startswith(mid):
|
||||
contents.append(mem.content)
|
||||
break
|
||||
if len(contents) < 2:
|
||||
return "Error: could not find enough memories to merge"
|
||||
merged = "\n---\n".join(contents)
|
||||
mem = backend.add_memory(content=f"[Merged] {merged}", scope="session")
|
||||
# Delete originals
|
||||
for mid in ids:
|
||||
backend.delete_memory(mid)
|
||||
return f"Merged {len(ids)} memories into {mem.memory_id}"
|
||||
|
||||
async def _tag(self, id: Optional[str], tags: List[str]) -> str:
|
||||
"""Add tags to a memory."""
|
||||
if not id:
|
||||
return "Error: id required for tag"
|
||||
if not tags:
|
||||
return "Error: tags list required"
|
||||
from hanzo_tools.memory.markdown_memory import get_markdown_backend
|
||||
backend = get_markdown_backend()
|
||||
backend._ensure_loaded()
|
||||
for mem in list(backend._file_memories) + list(backend._session_memories):
|
||||
if mem.memory_id == id or mem.memory_id.startswith(id):
|
||||
existing = getattr(mem, 'tags', []) or []
|
||||
mem.tags = list(set(existing + tags))
|
||||
return f"Tagged {id}: {mem.tags}"
|
||||
return f"Memory {id} not found"
|
||||
|
||||
async def _untag(self, id: Optional[str], tags: List[str]) -> str:
|
||||
"""Remove tags from a memory."""
|
||||
if not id:
|
||||
return "Error: id required for untag"
|
||||
if not tags:
|
||||
return "Error: tags list required"
|
||||
from hanzo_tools.memory.markdown_memory import get_markdown_backend
|
||||
backend = get_markdown_backend()
|
||||
backend._ensure_loaded()
|
||||
for mem in list(backend._file_memories) + list(backend._session_memories):
|
||||
if mem.memory_id == id or mem.memory_id.startswith(id):
|
||||
existing = getattr(mem, 'tags', []) or []
|
||||
mem.tags = [t for t in existing if t not in tags]
|
||||
return f"Untagged {id}: removed {tags}"
|
||||
return f"Memory {id} not found"
|
||||
|
||||
async def _namespaces(self) -> str:
|
||||
"""List available namespaces/scopes."""
|
||||
from hanzo_tools.memory.markdown_memory import get_markdown_backend
|
||||
backend = get_markdown_backend()
|
||||
backend._ensure_loaded()
|
||||
scopes = set()
|
||||
for mem in list(backend._file_memories) + list(backend._session_memories):
|
||||
scopes.add(getattr(mem, 'scope', 'global'))
|
||||
return f"Namespaces: {', '.join(sorted(scopes)) or 'none'}"
|
||||
|
||||
async def _history(self, id: Optional[str], limit: int) -> str:
|
||||
"""Get memory change history (limited - returns current state)."""
|
||||
if not id:
|
||||
return "Error: id required for history"
|
||||
from hanzo_tools.memory.markdown_memory import get_markdown_backend
|
||||
backend = get_markdown_backend()
|
||||
backend._ensure_loaded()
|
||||
for mem in list(backend._file_memories) + list(backend._session_memories):
|
||||
if mem.memory_id == id or mem.memory_id.startswith(id):
|
||||
return f"Memory {id}:\n Content: {mem.content[:200]}\n Created: {getattr(mem, 'created_at', 'unknown')}\n (Full history tracking requires cloud backend)"
|
||||
return f"Memory {id} not found"
|
||||
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register with MCP server."""
|
||||
tool_instance = self
|
||||
|
||||
@@ -10,6 +10,7 @@ Effect lattice position: NONDETERMINISTIC_EFFECT
|
||||
Network operations are inherently non-deterministic.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
@@ -462,5 +463,87 @@ Effect: NONDETERMINISTIC_EFFECT (network I/O)
|
||||
|
||||
return result
|
||||
|
||||
@self.action("request", "Full HTTP request with method/headers/body")
|
||||
async def request(
|
||||
ctx: MCPContext,
|
||||
url: str,
|
||||
method: str = "GET",
|
||||
headers: dict | None = None,
|
||||
body: str | None = None,
|
||||
timeout: int = 30,
|
||||
) -> dict:
|
||||
"""HTTP request with full control over method, headers, body.
|
||||
|
||||
Unlike 'fetch' which focuses on text extraction, 'request' returns
|
||||
the raw response with status, headers, and body.
|
||||
|
||||
Effect: NONDETERMINISTIC_EFFECT
|
||||
"""
|
||||
client = await self._get_client()
|
||||
|
||||
try:
|
||||
response = await client.request(
|
||||
method,
|
||||
url,
|
||||
headers=headers,
|
||||
content=body,
|
||||
timeout=timeout,
|
||||
)
|
||||
except Exception as e:
|
||||
raise ToolError(
|
||||
code="INTERNAL_ERROR",
|
||||
message=f"Request failed: {e}",
|
||||
details={"url": url, "method": method},
|
||||
)
|
||||
|
||||
content_type = response.headers.get("content-type", "")
|
||||
resp_headers = dict(response.headers)
|
||||
|
||||
if "json" in content_type:
|
||||
try:
|
||||
body_data = response.json()
|
||||
except Exception:
|
||||
body_data = response.text
|
||||
else:
|
||||
body_data = response.text[:50000] if len(response.text) > 50000 else response.text
|
||||
|
||||
return {
|
||||
"status": response.status_code,
|
||||
"headers": resp_headers,
|
||||
"body": body_data,
|
||||
}
|
||||
|
||||
@self.action("open", "Open URL in browser")
|
||||
async def open_url(
|
||||
ctx: MCPContext,
|
||||
url: str,
|
||||
) -> dict:
|
||||
"""Open URL in the system default browser.
|
||||
|
||||
Effect: NONDETERMINISTIC_EFFECT
|
||||
"""
|
||||
import platform
|
||||
import subprocess
|
||||
|
||||
system = platform.system().lower()
|
||||
if system == "darwin":
|
||||
cmd = ["open", url]
|
||||
elif system == "linux":
|
||||
cmd = ["xdg-open", url]
|
||||
elif system == "windows":
|
||||
cmd = ["start", url]
|
||||
else:
|
||||
cmd = ["xdg-open", url]
|
||||
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
*cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
await proc.communicate()
|
||||
|
||||
return {"url": url, "opened": True}
|
||||
|
||||
|
||||
# Singleton
|
||||
fetch_tool = FetchTool
|
||||
|
||||
@@ -219,12 +219,16 @@ class PlanTool(BaseTool):
|
||||
name: ClassVar[str] = "plan"
|
||||
VERSION: ClassVar[str] = "0.2.0"
|
||||
|
||||
# In-memory tracked plan (matches Rust update_plan behavior)
|
||||
# In-memory tracked plans (supports multiple named plans)
|
||||
_tracked_plan: TrackedPlan | None = None
|
||||
_plans: dict[str, TrackedPlan] = {}
|
||||
_plan_counter: int = 0
|
||||
|
||||
def __init__(self):
|
||||
super().__init__()
|
||||
self._tracked_plan = None
|
||||
self._plans = {}
|
||||
self._plan_counter = 0
|
||||
self._register_plan_actions()
|
||||
|
||||
@property
|
||||
@@ -232,11 +236,26 @@ class PlanTool(BaseTool):
|
||||
return """Unified plan orchestration tool (HIP-0300).
|
||||
|
||||
Actions:
|
||||
- update: Track plan with step status (Rust parity - name, steps with status)
|
||||
- create: Create a new plan with name and steps
|
||||
- show: Show a plan by name
|
||||
- update: Track plan with step status
|
||||
- get: Get current tracked plan
|
||||
- list: List all plans
|
||||
- next: Get next pending step
|
||||
- archive: Archive a plan
|
||||
- add_step: Add a step to existing plan
|
||||
- remove_step: Remove a step from plan
|
||||
- estimate: Estimate plan completion
|
||||
- visualize: Text visualization of plan progress
|
||||
- clone: Clone an existing plan
|
||||
- cancel: Cancel a plan
|
||||
- notes: Add/view plan notes
|
||||
- progress: Get progress percentage
|
||||
- clear: Clear tracked plan
|
||||
- intent: Parse natural language → IntentIR
|
||||
- route: Map IntentIR → Plan (canonical operator chain)
|
||||
- compose: Plan → ExecGraph (execution-ready DAG)
|
||||
- route: Map IntentIR → Plan
|
||||
- compose: Plan → ExecGraph
|
||||
- chains: List canonical chains
|
||||
|
||||
Turns permissive natural language input into strict canonical operator chains.
|
||||
"""
|
||||
@@ -618,14 +637,7 @@ Turns permissive natural language input into strict canonical operator chains.
|
||||
ctx: MCPContext,
|
||||
category: str | None = None,
|
||||
) -> dict:
|
||||
"""List available canonical operator chains.
|
||||
|
||||
Args:
|
||||
category: Filter by category (optional)
|
||||
|
||||
Returns:
|
||||
Dictionary of available chains
|
||||
"""
|
||||
"""List available canonical operator chains."""
|
||||
result = {}
|
||||
for (cat, action), chain in CANONICAL_CHAINS.items():
|
||||
if category and cat != category:
|
||||
@@ -636,10 +648,217 @@ Turns permissive natural language input into strict canonical operator chains.
|
||||
"tools": [s["tool"] for s in chain],
|
||||
"has_policy_gate": any(s.get("policy_gate") for s in chain),
|
||||
}
|
||||
return {"chains": result, "total": len(result)}
|
||||
|
||||
# --- TS parity actions below ---
|
||||
|
||||
def _get_plan(self, name: str | None = None) -> TrackedPlan | None:
|
||||
if name and name in self._plans:
|
||||
return self._plans[name]
|
||||
return self._tracked_plan
|
||||
|
||||
def _save_plan(self, plan: TrackedPlan):
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
plan.updated_at = now
|
||||
if plan.name:
|
||||
self._plans[plan.name] = plan
|
||||
self._tracked_plan = plan
|
||||
|
||||
def _plan_to_dict(self, plan: TrackedPlan) -> dict:
|
||||
return {
|
||||
"chains": result,
|
||||
"total": len(result),
|
||||
"name": plan.name,
|
||||
"steps": [{"step": s.step, "status": s.status.value} for s in plan.steps],
|
||||
"created_at": plan.created_at,
|
||||
"updated_at": plan.updated_at,
|
||||
}
|
||||
|
||||
@self.action("create", "Create a new plan")
|
||||
async def create(
|
||||
ctx: MCPContext,
|
||||
name: str = "",
|
||||
steps: list[str] | None = None,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""Create a new named plan with steps."""
|
||||
if not name:
|
||||
self._plan_counter += 1
|
||||
name = f"plan-{self._plan_counter}"
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
tracked_steps = [TrackedStep(step=s) for s in (steps or [])]
|
||||
plan = TrackedPlan(name=name, steps=tracked_steps, created_at=now, updated_at=now)
|
||||
_save_plan(plan)
|
||||
return {"message": f"Created plan '{name}'", "plan": _plan_to_dict(plan)}
|
||||
|
||||
@self.action("show", "Show a plan by name")
|
||||
async def show(ctx: MCPContext, name: str = "", **kwargs) -> dict:
|
||||
"""Show plan details."""
|
||||
plan = _get_plan(name or None)
|
||||
if not plan:
|
||||
return {"error": f"Plan '{name}' not found" if name else "No active plan"}
|
||||
return {"plan": _plan_to_dict(plan)}
|
||||
|
||||
@self.action("list", "List all plans")
|
||||
async def list_plans(ctx: MCPContext, **kwargs) -> dict:
|
||||
"""List all tracked plans."""
|
||||
plans = []
|
||||
for n, p in self._plans.items():
|
||||
completed = sum(1 for s in p.steps if s.status == StepStatus.COMPLETED)
|
||||
plans.append({"name": n, "steps": len(p.steps), "completed": completed})
|
||||
return {"plans": plans, "total": len(plans)}
|
||||
|
||||
@self.action("next", "Get next pending step")
|
||||
async def next_step(ctx: MCPContext, name: str = "", **kwargs) -> dict:
|
||||
"""Get the next pending step from a plan."""
|
||||
plan = _get_plan(name or None)
|
||||
if not plan:
|
||||
return {"error": "No active plan"}
|
||||
for i, s in enumerate(plan.steps):
|
||||
if s.status == StepStatus.PENDING:
|
||||
return {"index": i, "step": s.step, "status": s.status.value}
|
||||
return {"message": "All steps completed"}
|
||||
|
||||
@self.action("archive", "Archive a plan")
|
||||
async def archive(ctx: MCPContext, name: str = "", **kwargs) -> dict:
|
||||
"""Archive (remove) a plan."""
|
||||
target = name or (self._tracked_plan.name if self._tracked_plan else None)
|
||||
if target and target in self._plans:
|
||||
del self._plans[target]
|
||||
if self._tracked_plan and self._tracked_plan.name == target:
|
||||
self._tracked_plan = None
|
||||
return {"message": f"Archived plan '{target}'"}
|
||||
return {"error": f"Plan '{target}' not found"}
|
||||
|
||||
@self.action("add_step", "Add a step to a plan")
|
||||
async def add_step(
|
||||
ctx: MCPContext,
|
||||
step: str = "",
|
||||
name: str = "",
|
||||
position: int = -1,
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""Add a step to an existing plan."""
|
||||
if not step:
|
||||
return {"error": "step text required"}
|
||||
plan = _get_plan(name or None)
|
||||
if not plan:
|
||||
return {"error": "No active plan"}
|
||||
new_step = TrackedStep(step=step)
|
||||
if position >= 0 and position <= len(plan.steps):
|
||||
plan.steps.insert(position, new_step)
|
||||
else:
|
||||
plan.steps.append(new_step)
|
||||
_save_plan(plan)
|
||||
return {"message": f"Added step to '{plan.name}'", "plan": _plan_to_dict(plan)}
|
||||
|
||||
@self.action("remove_step", "Remove a step from a plan")
|
||||
async def remove_step(
|
||||
ctx: MCPContext,
|
||||
index: int = -1,
|
||||
name: str = "",
|
||||
**kwargs,
|
||||
) -> dict:
|
||||
"""Remove a step by index."""
|
||||
plan = _get_plan(name or None)
|
||||
if not plan:
|
||||
return {"error": "No active plan"}
|
||||
if index < 0 or index >= len(plan.steps):
|
||||
return {"error": f"Invalid index {index}, plan has {len(plan.steps)} steps"}
|
||||
removed = plan.steps.pop(index)
|
||||
_save_plan(plan)
|
||||
return {"message": f"Removed step: {removed.step}", "plan": _plan_to_dict(plan)}
|
||||
|
||||
@self.action("estimate", "Estimate plan completion")
|
||||
async def estimate(ctx: MCPContext, name: str = "", **kwargs) -> dict:
|
||||
"""Estimate plan completion based on step progress."""
|
||||
plan = _get_plan(name or None)
|
||||
if not plan:
|
||||
return {"error": "No active plan"}
|
||||
total = len(plan.steps)
|
||||
if total == 0:
|
||||
return {"progress": 0, "remaining": 0, "total": 0}
|
||||
completed = sum(1 for s in plan.steps if s.status == StepStatus.COMPLETED)
|
||||
in_progress = sum(1 for s in plan.steps if s.status == StepStatus.IN_PROGRESS)
|
||||
pct = round((completed + in_progress * 0.5) / total * 100, 1)
|
||||
return {
|
||||
"total": total,
|
||||
"completed": completed,
|
||||
"in_progress": in_progress,
|
||||
"pending": total - completed - in_progress,
|
||||
"progress_pct": pct,
|
||||
}
|
||||
|
||||
@self.action("visualize", "Text visualization of plan progress")
|
||||
async def visualize(ctx: MCPContext, name: str = "", **kwargs) -> str:
|
||||
"""Render a text progress bar for the plan."""
|
||||
plan = _get_plan(name or None)
|
||||
if not plan:
|
||||
return "No active plan"
|
||||
lines = [f"Plan: {plan.name or '(unnamed)'}"]
|
||||
for i, s in enumerate(plan.steps):
|
||||
icon = {"completed": "[x]", "in_progress": "[~]", "pending": "[ ]"}.get(s.status.value, "[ ]")
|
||||
lines.append(f" {i+1}. {icon} {s.step}")
|
||||
total = len(plan.steps)
|
||||
done = sum(1 for s in plan.steps if s.status == StepStatus.COMPLETED)
|
||||
bar_len = 20
|
||||
filled = int(bar_len * done / total) if total else 0
|
||||
bar = "█" * filled + "░" * (bar_len - filled)
|
||||
lines.append(f"\n [{bar}] {done}/{total}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@self.action("clone", "Clone an existing plan")
|
||||
async def clone_plan(ctx: MCPContext, name: str = "", new_name: str = "", **kwargs) -> dict:
|
||||
"""Clone a plan with a new name, resetting step status."""
|
||||
plan = _get_plan(name or None)
|
||||
if not plan:
|
||||
return {"error": "No active plan to clone"}
|
||||
if not new_name:
|
||||
self._plan_counter += 1
|
||||
new_name = f"{plan.name or 'plan'}-copy-{self._plan_counter}"
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
new_steps = [TrackedStep(step=s.step) for s in plan.steps]
|
||||
new_plan = TrackedPlan(name=new_name, steps=new_steps, created_at=now, updated_at=now)
|
||||
_save_plan(new_plan)
|
||||
return {"message": f"Cloned to '{new_name}'", "plan": _plan_to_dict(new_plan)}
|
||||
|
||||
@self.action("cancel", "Cancel a plan")
|
||||
async def cancel(ctx: MCPContext, name: str = "", **kwargs) -> dict:
|
||||
"""Cancel a plan (archive with cancelled status)."""
|
||||
target = name or (self._tracked_plan.name if self._tracked_plan else None)
|
||||
if target and target in self._plans:
|
||||
del self._plans[target]
|
||||
if self._tracked_plan and self._tracked_plan.name == target:
|
||||
self._tracked_plan = None
|
||||
return {"message": f"Cancelled plan '{target}'"}
|
||||
return {"error": f"Plan '{target}' not found"}
|
||||
|
||||
@self.action("notes", "Add or view plan notes")
|
||||
async def notes(ctx: MCPContext, name: str = "", note: str = "", **kwargs) -> dict:
|
||||
"""Add a note to a plan or view existing notes."""
|
||||
plan = _get_plan(name or None)
|
||||
if not plan:
|
||||
return {"error": "No active plan"}
|
||||
if not hasattr(plan, '_notes'):
|
||||
plan._notes = []
|
||||
if note:
|
||||
plan._notes.append({"text": note, "at": datetime.now(timezone.utc).isoformat()})
|
||||
_save_plan(plan)
|
||||
return {"message": "Note added", "notes": plan._notes}
|
||||
return {"notes": getattr(plan, '_notes', [])}
|
||||
|
||||
@self.action("progress", "Get progress percentage")
|
||||
async def progress(ctx: MCPContext, name: str = "", **kwargs) -> dict:
|
||||
"""Get plan progress as percentage."""
|
||||
plan = _get_plan(name or None)
|
||||
if not plan:
|
||||
return {"error": "No active plan"}
|
||||
total = len(plan.steps)
|
||||
if total == 0:
|
||||
return {"progress": 100, "message": "Empty plan"}
|
||||
completed = sum(1 for s in plan.steps if s.status == StepStatus.COMPLETED)
|
||||
return {
|
||||
"progress": round(completed / total * 100, 1),
|
||||
"completed": completed,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
# Singleton
|
||||
|
||||
@@ -1,139 +1,226 @@
|
||||
"""Thinking tool implementation.
|
||||
"""Unified thinking tool implementation (HIP-0300).
|
||||
|
||||
This module provides the ThinkingTool for Claude to engage in structured thinking.
|
||||
This module provides the ThinkTool with action-based dispatch for
|
||||
all reasoning operations: think, critic, review, consensus, summarize,
|
||||
classify, explain, translate, compare, chain, agent, embed.
|
||||
"""
|
||||
|
||||
from typing import Unpack, Annotated, TypedDict, final, override
|
||||
from typing import ClassVar
|
||||
|
||||
from pydantic import Field
|
||||
from mcp.server import FastMCP
|
||||
from mcp.server.fastmcp import Context as MCPContext
|
||||
|
||||
from hanzo_tools.core import BaseTool, auto_timeout, create_tool_context
|
||||
|
||||
Thought = Annotated[
|
||||
str,
|
||||
Field(
|
||||
description="The detailed thought process to record",
|
||||
min_length=1,
|
||||
),
|
||||
]
|
||||
from hanzo_tools.core import BaseTool
|
||||
|
||||
|
||||
class ThinkingToolParams(TypedDict):
|
||||
"""Parameters for the ThinkingTool.
|
||||
class ThinkTool(BaseTool):
|
||||
"""Unified thinking and reasoning tool (HIP-0300).
|
||||
|
||||
Attributes:
|
||||
thought: The detailed thought process to record
|
||||
Actions:
|
||||
- think: Structured thinking and brainstorming (default)
|
||||
- critic: Critical analysis and devil's advocate
|
||||
- review: Code review and quality analysis
|
||||
- consensus: Multi-perspective consensus reasoning
|
||||
- agent: Agent-style step-by-step reasoning
|
||||
- summarize: Summarize content concisely
|
||||
- classify: Classify content into categories
|
||||
- explain: Explain a concept clearly
|
||||
- translate: Translate between formats/languages
|
||||
- compare: Compare multiple items
|
||||
- chain: Chain-of-thought multi-step reasoning
|
||||
- embed: Generate embedding representation (placeholder)
|
||||
"""
|
||||
|
||||
thought: Thought
|
||||
|
||||
|
||||
@final
|
||||
class ThinkTool(BaseTool):
|
||||
"""Tool for Claude to engage in structured thinking."""
|
||||
|
||||
name = "think"
|
||||
|
||||
@property
|
||||
@override
|
||||
def description(self) -> str:
|
||||
"""Get the tool description.
|
||||
|
||||
Returns:
|
||||
Tool description
|
||||
"""
|
||||
return """Use the tool to think about something. It will not obtain new information or make any changes to the repository, but just log the thought. Use it when complex reasoning or brainstorming is needed.
|
||||
Ensure thinking content is concise and accurate, without needing to include code details
|
||||
|
||||
Common use cases:
|
||||
1. When exploring a repository and discovering the source of a bug, call this tool to brainstorm several unique ways of fixing the bug, and assess which change(s) are likely to be simplest and most effective
|
||||
2. After receiving test results, use this tool to brainstorm ways to fix failing tests
|
||||
3. When planning a complex refactoring, use this tool to outline different approaches and their tradeoffs
|
||||
4. When designing a new feature, use this tool to think through architecture decisions and implementation details
|
||||
5. When debugging a complex issue, use this tool to organize your thoughts and hypotheses
|
||||
6. When considering changes to the plan or shifts in thinking that the user has not previously mentioned, consider whether it is necessary to confirm with the user.
|
||||
|
||||
<think_example>
|
||||
Feature Implementation Planning
|
||||
- New code search feature requirements:
|
||||
* Search for code patterns across multiple files
|
||||
* Identify function usages and references
|
||||
* Analyze import relationships
|
||||
* Generate summary of matching patterns
|
||||
- Implementation considerations:
|
||||
* Need to leverage existing search mechanisms
|
||||
* Should use regex for pattern matching
|
||||
* Results need consistent format with other search methods
|
||||
* Must handle large codebases efficiently
|
||||
- Design approach:
|
||||
1. Create new CodeSearcher class that follows existing search patterns
|
||||
2. Implement core pattern matching algorithm
|
||||
3. Add result formatting methods
|
||||
4. Integrate with file traversal system
|
||||
5. Add caching for performance optimization
|
||||
- Testing strategy:
|
||||
* Unit tests for search accuracy
|
||||
* Integration tests with existing components
|
||||
* Performance tests with large codebases
|
||||
</think_example>"""
|
||||
name: ClassVar[str] = "think"
|
||||
VERSION: ClassVar[str] = "0.3.0"
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Initialize the thinking tool."""
|
||||
pass
|
||||
super().__init__()
|
||||
self._register_think_actions()
|
||||
|
||||
@override
|
||||
@auto_timeout("thinking")
|
||||
async def call(
|
||||
self,
|
||||
ctx: MCPContext,
|
||||
**params: Unpack[ThinkingToolParams],
|
||||
) -> str:
|
||||
"""Execute the tool with the given parameters.
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return """Unified reasoning tool (HIP-0300).
|
||||
|
||||
Args:
|
||||
ctx: MCP context
|
||||
**params: Tool parameters
|
||||
Actions:
|
||||
- think: Structured thinking and brainstorming (default)
|
||||
- critic: Critical analysis, devil's advocate
|
||||
- review: Code review and quality check
|
||||
- consensus: Multi-perspective reasoning
|
||||
- agent: Step-by-step agent reasoning
|
||||
- summarize: Summarize content
|
||||
- classify: Classify into categories
|
||||
- explain: Explain a concept
|
||||
- translate: Translate between formats
|
||||
- compare: Compare items
|
||||
- chain: Chain-of-thought reasoning
|
||||
- embed: Embedding representation (placeholder)
|
||||
"""
|
||||
|
||||
Returns:
|
||||
Tool result
|
||||
"""
|
||||
tool_ctx = create_tool_context(ctx)
|
||||
await tool_ctx.set_tool_info(self.name)
|
||||
def _register_think_actions(self):
|
||||
"""Register all thinking actions."""
|
||||
|
||||
# Extract parameters
|
||||
thought = params.get("thought")
|
||||
@self.action("think", "Structured thinking and brainstorming")
|
||||
async def think(ctx: MCPContext, thought: str = "", **kwargs) -> str:
|
||||
"""Record a structured thought process.
|
||||
|
||||
# Validate required thought parameter
|
||||
if not thought:
|
||||
await tool_ctx.error(
|
||||
"Parameter 'thought' is required but was None or empty"
|
||||
)
|
||||
return "Error: Parameter 'thought' is required but was None or empty"
|
||||
Args:
|
||||
thought: The thought to record
|
||||
"""
|
||||
if not thought or not thought.strip():
|
||||
return "Error: 'thought' parameter is required"
|
||||
return "Thinking recorded. Continue with your next action based on this analysis."
|
||||
|
||||
if thought.strip() == "":
|
||||
await tool_ctx.error("Parameter 'thought' cannot be empty")
|
||||
return "Error: Parameter 'thought' cannot be empty"
|
||||
@self.action("critic", "Critical analysis and devil's advocate")
|
||||
async def critic(ctx: MCPContext, analysis: str = "", thought: str = "", **kwargs) -> str:
|
||||
"""Perform critical analysis.
|
||||
|
||||
# Log the thought but don't take action
|
||||
await tool_ctx.info("Thinking process recorded")
|
||||
Args:
|
||||
analysis: The critical analysis to perform
|
||||
thought: Alternative param name for the analysis
|
||||
"""
|
||||
text = analysis or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'analysis' or 'thought' parameter is required"
|
||||
return """Critical analysis complete. Remember to:
|
||||
1. Address all identified issues before proceeding
|
||||
2. Run comprehensive tests to verify fixes
|
||||
3. Ensure all tests pass with proper coverage
|
||||
4. Document any design decisions or trade-offs
|
||||
5. Consider the analysis points in your implementation"""
|
||||
|
||||
# Return confirmation
|
||||
return "I've recorded your thinking process. You can continue with your next action based on this analysis."
|
||||
@self.action("review", "Code review and quality analysis")
|
||||
async def review(ctx: MCPContext, code: str = "", thought: str = "", **kwargs) -> str:
|
||||
"""Perform code review.
|
||||
|
||||
@override
|
||||
def register(self, mcp_server: FastMCP) -> None:
|
||||
"""Register this thinking tool with the MCP server.
|
||||
Args:
|
||||
code: Code or description to review
|
||||
thought: Alternative param name
|
||||
"""
|
||||
text = code or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'code' or 'thought' parameter is required"
|
||||
return "Code review recorded. Apply findings to improve quality, correctness, and maintainability."
|
||||
|
||||
Creates a wrapper function with explicitly defined parameters that match
|
||||
the tool's parameter schema and registers it with the MCP server.
|
||||
@self.action("consensus", "Multi-perspective consensus reasoning")
|
||||
async def consensus(ctx: MCPContext, topic: str = "", perspectives: int = 3, thought: str = "", **kwargs) -> str:
|
||||
"""Reason from multiple perspectives to reach consensus.
|
||||
|
||||
Args:
|
||||
mcp_server: The FastMCP server instance
|
||||
"""
|
||||
tool_self = self # Create a reference to self for use in the closure
|
||||
Args:
|
||||
topic: The topic to reason about
|
||||
perspectives: Number of perspectives to consider
|
||||
thought: Alternative param name
|
||||
"""
|
||||
text = topic or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'topic' or 'thought' parameter is required"
|
||||
return f"Consensus reasoning recorded ({perspectives} perspectives). Use the synthesized viewpoint to guide decisions."
|
||||
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def think(thought: Thought, ctx: MCPContext) -> str:
|
||||
return await tool_self.call(ctx, thought=thought)
|
||||
@self.action("agent", "Agent-style step-by-step reasoning")
|
||||
async def agent(ctx: MCPContext, goal: str = "", thought: str = "", **kwargs) -> str:
|
||||
"""Perform agent-style reasoning with goal decomposition.
|
||||
|
||||
Args:
|
||||
goal: The goal to reason about
|
||||
thought: Alternative param name
|
||||
"""
|
||||
text = goal or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'goal' or 'thought' parameter is required"
|
||||
return "Agent reasoning recorded. Execute the planned steps sequentially."
|
||||
|
||||
@self.action("summarize", "Summarize content concisely")
|
||||
async def summarize(ctx: MCPContext, content: str = "", thought: str = "", max_length: int = 0, **kwargs) -> str:
|
||||
"""Summarize content.
|
||||
|
||||
Args:
|
||||
content: Content to summarize
|
||||
thought: Alternative param name
|
||||
max_length: Optional max length hint
|
||||
"""
|
||||
text = content or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'content' or 'thought' parameter is required"
|
||||
return "Summary recorded. Use the condensed version for communication or documentation."
|
||||
|
||||
@self.action("classify", "Classify content into categories")
|
||||
async def classify(ctx: MCPContext, content: str = "", categories: str = "", thought: str = "", **kwargs) -> str:
|
||||
"""Classify content into categories.
|
||||
|
||||
Args:
|
||||
content: Content to classify
|
||||
categories: Comma-separated category options
|
||||
thought: Alternative param name
|
||||
"""
|
||||
text = content or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'content' or 'thought' parameter is required"
|
||||
return "Classification recorded. Apply the categorization to guide next steps."
|
||||
|
||||
@self.action("explain", "Explain a concept clearly")
|
||||
async def explain(ctx: MCPContext, concept: str = "", audience: str = "developer", thought: str = "", **kwargs) -> str:
|
||||
"""Explain a concept.
|
||||
|
||||
Args:
|
||||
concept: Concept to explain
|
||||
audience: Target audience (developer, beginner, expert)
|
||||
thought: Alternative param name
|
||||
"""
|
||||
text = concept or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'concept' or 'thought' parameter is required"
|
||||
return f"Explanation recorded (audience: {audience}). Use for documentation or communication."
|
||||
|
||||
@self.action("translate", "Translate between formats or languages")
|
||||
async def translate(ctx: MCPContext, content: str = "", target: str = "", thought: str = "", **kwargs) -> str:
|
||||
"""Translate content between formats or languages.
|
||||
|
||||
Args:
|
||||
content: Content to translate
|
||||
target: Target format or language
|
||||
thought: Alternative param name
|
||||
"""
|
||||
text = content or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'content' or 'thought' parameter is required"
|
||||
target_desc = f" to {target}" if target else ""
|
||||
return f"Translation{target_desc} recorded. Apply the translated version."
|
||||
|
||||
@self.action("compare", "Compare multiple items")
|
||||
async def compare(ctx: MCPContext, items: str = "", criteria: str = "", thought: str = "", **kwargs) -> str:
|
||||
"""Compare items against criteria.
|
||||
|
||||
Args:
|
||||
items: Items to compare (comma-separated or description)
|
||||
criteria: Comparison criteria
|
||||
thought: Alternative param name
|
||||
"""
|
||||
text = items or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'items' or 'thought' parameter is required"
|
||||
return "Comparison recorded. Use the analysis to make an informed decision."
|
||||
|
||||
@self.action("chain", "Chain-of-thought multi-step reasoning")
|
||||
async def chain(ctx: MCPContext, steps: str = "", thought: str = "", **kwargs) -> str:
|
||||
"""Perform chain-of-thought reasoning.
|
||||
|
||||
Args:
|
||||
steps: The reasoning steps
|
||||
thought: Alternative param name
|
||||
"""
|
||||
text = steps or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'steps' or 'thought' parameter is required"
|
||||
return "Chain-of-thought reasoning recorded. Follow the logical progression to reach the conclusion."
|
||||
|
||||
@self.action("embed", "Generate embedding representation (placeholder)")
|
||||
async def embed(ctx: MCPContext, content: str = "", thought: str = "", **kwargs) -> str:
|
||||
"""Placeholder for embedding generation.
|
||||
|
||||
Args:
|
||||
content: Content to embed
|
||||
thought: Alternative param name
|
||||
"""
|
||||
text = content or thought
|
||||
if not text or not text.strip():
|
||||
return "Error: 'content' or 'thought' parameter is required"
|
||||
return "Embedding placeholder recorded. Use a dedicated embedding service for production vectors."
|
||||
|
||||
@@ -133,12 +133,34 @@ todo --filter in_progress
|
||||
return await self._handle_add(params, tool_ctx)
|
||||
elif action == "update":
|
||||
return await self._handle_update(params, tool_ctx)
|
||||
elif action == "remove":
|
||||
elif action in ("remove", "delete"):
|
||||
return await self._handle_remove(params.get("id"), tool_ctx)
|
||||
elif action == "clear":
|
||||
return await self._handle_clear(params.get("filter"), tool_ctx)
|
||||
elif action == "stats":
|
||||
return await self._handle_stats(tool_ctx)
|
||||
elif action == "search":
|
||||
return await self._handle_search(params, tool_ctx)
|
||||
elif action == "batch":
|
||||
return await self._handle_batch(params, tool_ctx)
|
||||
elif action == "archive":
|
||||
return await self._handle_archive(params.get("id"), tool_ctx)
|
||||
elif action == "move":
|
||||
return await self._handle_move(params, tool_ctx)
|
||||
elif action == "prioritize":
|
||||
return await self._handle_prioritize(params, tool_ctx)
|
||||
elif action == "assign":
|
||||
return await self._handle_assign(params, tool_ctx)
|
||||
elif action == "subtasks":
|
||||
return await self._handle_subtasks(params, tool_ctx)
|
||||
elif action == "notes":
|
||||
return await self._handle_notes(params, tool_ctx)
|
||||
elif action == "export":
|
||||
return await self._handle_export(params, tool_ctx)
|
||||
elif action == "import":
|
||||
return await self._handle_import(params, tool_ctx)
|
||||
else:
|
||||
return f"Error: Unknown action '{action}'. Valid actions: list, add, update, remove, clear"
|
||||
return f"Error: Unknown action '{action}'. Valid actions: list, add, update, remove, delete, clear, stats, search, batch, archive, move, prioritize, assign, subtasks, notes, export, import"
|
||||
|
||||
async def _handle_list(self, filter_status: Optional[str], tool_ctx) -> str:
|
||||
"""List todos."""
|
||||
@@ -260,6 +282,235 @@ todo --filter in_progress
|
||||
await tool_ctx.info(f"Removed todo: {removed['content']}")
|
||||
return f"Removed todo [{removed['id'][:8]}]: {removed['content']}"
|
||||
|
||||
async def _handle_stats(self, tool_ctx) -> str:
|
||||
"""Get todo statistics."""
|
||||
todos = self.read_todos()
|
||||
if not todos:
|
||||
return "No todos found."
|
||||
|
||||
by_status = {}
|
||||
by_priority = {}
|
||||
for todo in todos:
|
||||
s = todo.get("status", "pending")
|
||||
p = todo.get("priority", "medium")
|
||||
by_status[s] = by_status.get(s, 0) + 1
|
||||
by_priority[p] = by_priority.get(p, 0) + 1
|
||||
|
||||
lines = [f"Total: {len(todos)}"]
|
||||
lines.append("By status:")
|
||||
for s in ["in_progress", "pending", "completed"]:
|
||||
if s in by_status:
|
||||
lines.append(f" {s}: {by_status[s]}")
|
||||
lines.append("By priority:")
|
||||
for p in ["high", "medium", "low"]:
|
||||
if p in by_priority:
|
||||
lines.append(f" {p}: {by_priority[p]}")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_search(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Search todos by content."""
|
||||
query = params.get("content") or params.get("query") or ""
|
||||
if not query:
|
||||
return "Error: search query required (use content or query param)"
|
||||
todos = self.read_todos()
|
||||
query_lower = query.lower()
|
||||
matches = [t for t in todos if query_lower in t.get("content", "").lower()]
|
||||
if not matches:
|
||||
return f"No todos matching '{query}'"
|
||||
lines = [f"Found {len(matches)} matching todo(s):"]
|
||||
for t in matches:
|
||||
lines.append(f" [{t['id'][:8]}] {t['content']} ({t.get('status', 'pending')})")
|
||||
return "\n".join(lines)
|
||||
|
||||
async def _handle_batch(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Batch update todos by status."""
|
||||
from_status = params.get("filter") or params.get("from_status")
|
||||
to_status = params.get("status") or params.get("to_status")
|
||||
if not from_status or not to_status:
|
||||
return "Error: batch requires filter/from_status and status/to_status"
|
||||
todos = self.read_todos()
|
||||
count = 0
|
||||
for t in todos:
|
||||
if t.get("status") == from_status:
|
||||
t["status"] = to_status
|
||||
t["updated_at"] = datetime.now().isoformat()
|
||||
count += 1
|
||||
self.write_todos(todos)
|
||||
return f"Batch updated {count} todo(s) from '{from_status}' to '{to_status}'"
|
||||
|
||||
async def _handle_archive(self, todo_id: Optional[str], tool_ctx) -> str:
|
||||
"""Archive completed todos or a specific todo."""
|
||||
todos = self.read_todos()
|
||||
if todo_id:
|
||||
for t in todos:
|
||||
if t["id"].startswith(todo_id):
|
||||
t["status"] = "archived"
|
||||
t["updated_at"] = datetime.now().isoformat()
|
||||
self.write_todos(todos)
|
||||
return f"Archived todo [{t['id'][:8]}]"
|
||||
return f"Error: Todo '{todo_id}' not found"
|
||||
# Archive all completed
|
||||
count = 0
|
||||
for t in todos:
|
||||
if t.get("status") == "completed":
|
||||
t["status"] = "archived"
|
||||
t["updated_at"] = datetime.now().isoformat()
|
||||
count += 1
|
||||
self.write_todos(todos)
|
||||
return f"Archived {count} completed todo(s)"
|
||||
|
||||
async def _handle_move(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Move a todo to a different position."""
|
||||
todo_id = params.get("id")
|
||||
position = params.get("position")
|
||||
if not todo_id:
|
||||
return "Error: id required for move"
|
||||
todos = self.read_todos()
|
||||
idx = None
|
||||
for i, t in enumerate(todos):
|
||||
if t["id"].startswith(todo_id):
|
||||
idx = i
|
||||
break
|
||||
if idx is None:
|
||||
return f"Error: Todo '{todo_id}' not found"
|
||||
item = todos.pop(idx)
|
||||
try:
|
||||
pos = int(position) if position is not None else 0
|
||||
except (ValueError, TypeError):
|
||||
pos = 0
|
||||
pos = max(0, min(pos, len(todos)))
|
||||
todos.insert(pos, item)
|
||||
self.write_todos(todos)
|
||||
return f"Moved todo [{item['id'][:8]}] to position {pos}"
|
||||
|
||||
async def _handle_prioritize(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Change todo priority."""
|
||||
todo_id = params.get("id")
|
||||
priority = params.get("priority")
|
||||
if not todo_id or not priority:
|
||||
return "Error: id and priority required"
|
||||
todos = self.read_todos()
|
||||
for t in todos:
|
||||
if t["id"].startswith(todo_id):
|
||||
t["priority"] = priority
|
||||
t["updated_at"] = datetime.now().isoformat()
|
||||
self.write_todos(todos)
|
||||
return f"Set priority of [{t['id'][:8]}] to '{priority}'"
|
||||
return f"Error: Todo '{todo_id}' not found"
|
||||
|
||||
async def _handle_assign(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Assign a todo to someone."""
|
||||
todo_id = params.get("id")
|
||||
assignee = params.get("assignee") or params.get("content")
|
||||
if not todo_id:
|
||||
return "Error: id required for assign"
|
||||
todos = self.read_todos()
|
||||
for t in todos:
|
||||
if t["id"].startswith(todo_id):
|
||||
t["assignee"] = assignee or ""
|
||||
t["updated_at"] = datetime.now().isoformat()
|
||||
self.write_todos(todos)
|
||||
return f"Assigned [{t['id'][:8]}] to '{assignee or 'unassigned'}'"
|
||||
return f"Error: Todo '{todo_id}' not found"
|
||||
|
||||
async def _handle_subtasks(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Manage subtasks of a todo."""
|
||||
todo_id = params.get("id")
|
||||
content = params.get("content")
|
||||
if not todo_id:
|
||||
return "Error: id required for subtasks"
|
||||
todos = self.read_todos()
|
||||
for t in todos:
|
||||
if t["id"].startswith(todo_id):
|
||||
if "subtasks" not in t:
|
||||
t["subtasks"] = []
|
||||
if content:
|
||||
t["subtasks"].append({
|
||||
"id": str(uuid.uuid4()),
|
||||
"content": content,
|
||||
"status": "pending",
|
||||
"created_at": datetime.now().isoformat(),
|
||||
})
|
||||
self.write_todos(todos)
|
||||
return f"Added subtask to [{t['id'][:8]}]: {content}"
|
||||
else:
|
||||
if not t["subtasks"]:
|
||||
return f"No subtasks for [{t['id'][:8]}]"
|
||||
lines = [f"Subtasks for [{t['id'][:8]}]:"]
|
||||
for st in t["subtasks"]:
|
||||
lines.append(f" [{st['id'][:8]}] {st['content']} ({st.get('status', 'pending')})")
|
||||
return "\n".join(lines)
|
||||
return f"Error: Todo '{todo_id}' not found"
|
||||
|
||||
async def _handle_notes(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Add or view notes on a todo."""
|
||||
todo_id = params.get("id")
|
||||
content = params.get("content")
|
||||
if not todo_id:
|
||||
return "Error: id required for notes"
|
||||
todos = self.read_todos()
|
||||
for t in todos:
|
||||
if t["id"].startswith(todo_id):
|
||||
if "notes" not in t:
|
||||
t["notes"] = []
|
||||
if content:
|
||||
t["notes"].append({
|
||||
"text": content,
|
||||
"created_at": datetime.now().isoformat(),
|
||||
})
|
||||
self.write_todos(todos)
|
||||
return f"Added note to [{t['id'][:8]}]"
|
||||
else:
|
||||
if not t["notes"]:
|
||||
return f"No notes for [{t['id'][:8]}]"
|
||||
lines = [f"Notes for [{t['id'][:8]}]:"]
|
||||
for n in t["notes"]:
|
||||
lines.append(f" [{n.get('created_at', '?')}] {n['text']}")
|
||||
return "\n".join(lines)
|
||||
return f"Error: Todo '{todo_id}' not found"
|
||||
|
||||
async def _handle_export(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Export todos as JSON."""
|
||||
import json
|
||||
todos = self.read_todos()
|
||||
fmt = params.get("format") or "json"
|
||||
if fmt == "json":
|
||||
return json.dumps({"todos": todos}, indent=2, default=str)
|
||||
elif fmt == "markdown":
|
||||
lines = ["# Todos"]
|
||||
for t in todos:
|
||||
status_icon = {"completed": "x", "in_progress": "~", "pending": " "}.get(t.get("status", "pending"), " ")
|
||||
lines.append(f"- [{status_icon}] {t['content']}")
|
||||
return "\n".join(lines)
|
||||
return json.dumps({"todos": todos}, indent=2, default=str)
|
||||
|
||||
async def _handle_import(self, params: Dict[str, Any], tool_ctx) -> str:
|
||||
"""Import todos from JSON."""
|
||||
import json
|
||||
content = params.get("content")
|
||||
if not content:
|
||||
return "Error: content (JSON) required for import"
|
||||
try:
|
||||
data = json.loads(content)
|
||||
items = data if isinstance(data, list) else data.get("todos", data.get("items", []))
|
||||
todos = self.read_todos()
|
||||
count = 0
|
||||
for item in items:
|
||||
if isinstance(item, dict) and "content" in item:
|
||||
if "id" not in item:
|
||||
item["id"] = str(uuid.uuid4())
|
||||
if "status" not in item:
|
||||
item["status"] = "pending"
|
||||
if "priority" not in item:
|
||||
item["priority"] = "medium"
|
||||
item["created_at"] = datetime.now().isoformat()
|
||||
todos.append(item)
|
||||
count += 1
|
||||
self.write_todos(todos)
|
||||
return f"Imported {count} todo(s)"
|
||||
except json.JSONDecodeError as e:
|
||||
return f"Error: Invalid JSON: {e}"
|
||||
|
||||
async def _handle_clear(self, filter_status: Optional[str], tool_ctx) -> str:
|
||||
"""Clear todos."""
|
||||
todos = self.read_todos()
|
||||
|
||||
@@ -447,5 +447,269 @@ Outputs diffs in unified patch format for use with fs.apply_patch.
|
||||
"total": len(commits),
|
||||
}
|
||||
|
||||
# ── Advanced actions (TS parity) ──────────────────────────────────
|
||||
|
||||
@self.action("blame", "Show line-by-line authorship")
|
||||
async def blame(ctx: MCPContext, path: str, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
stdout, _, _ = await self._run_git("blame", "--porcelain", path, cwd=work_dir)
|
||||
return {"blame": stdout, "path": path}
|
||||
|
||||
@self.action("show", "Show commit or object")
|
||||
async def show(ctx: MCPContext, ref: str = "HEAD", cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
stdout, _, _ = await self._run_git("show", "--stat", ref, cwd=work_dir)
|
||||
return {"output": stdout, "ref": ref}
|
||||
|
||||
@self.action("stash", "Stash operations")
|
||||
async def stash(ctx: MCPContext, op: str = "list", message: str | None = None, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
if op == "push":
|
||||
args = ["stash", "push"]
|
||||
if message:
|
||||
args.extend(["-m", message])
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
elif op == "pop":
|
||||
stdout, _, _ = await self._run_git("stash", "pop", cwd=work_dir)
|
||||
elif op == "drop":
|
||||
stdout, _, _ = await self._run_git("stash", "drop", cwd=work_dir)
|
||||
else:
|
||||
stdout, _, _ = await self._run_git("stash", "list", cwd=work_dir)
|
||||
return {"output": stdout, "op": op}
|
||||
|
||||
@self.action("tag", "Tag operations")
|
||||
async def tag(ctx: MCPContext, op: str = "list", name: str | None = None, message: str | None = None, ref: str | None = None, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
if op == "create":
|
||||
if not name:
|
||||
raise InvalidParamsError("Tag name required", param="name")
|
||||
args = ["tag"]
|
||||
if message:
|
||||
args.extend(["-a", name, "-m", message])
|
||||
else:
|
||||
args.append(name)
|
||||
if ref:
|
||||
args.append(ref)
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
return {"created": name}
|
||||
elif op == "delete":
|
||||
if not name:
|
||||
raise InvalidParamsError("Tag name required", param="name")
|
||||
stdout, _, _ = await self._run_git("tag", "-d", name, cwd=work_dir)
|
||||
return {"deleted": name}
|
||||
else:
|
||||
stdout, _, _ = await self._run_git("tag", "-l", cwd=work_dir)
|
||||
return {"tags": stdout.splitlines()}
|
||||
|
||||
@self.action("remote", "Remote operations")
|
||||
async def remote(ctx: MCPContext, op: str = "list", name: str | None = None, url: str | None = None, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
if op == "add":
|
||||
if not name or not url:
|
||||
raise InvalidParamsError("name and url required", param="name")
|
||||
stdout, _, _ = await self._run_git("remote", "add", name, url, cwd=work_dir)
|
||||
return {"added": name, "url": url}
|
||||
elif op == "remove":
|
||||
if not name:
|
||||
raise InvalidParamsError("Remote name required", param="name")
|
||||
stdout, _, _ = await self._run_git("remote", "remove", name, cwd=work_dir)
|
||||
return {"removed": name}
|
||||
else:
|
||||
stdout, _, _ = await self._run_git("remote", "-v", cwd=work_dir)
|
||||
return {"remotes": stdout}
|
||||
|
||||
@self.action("merge", "Merge branches")
|
||||
async def merge(ctx: MCPContext, ref: str, message: str | None = None, no_ff: bool = False, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
args = ["merge"]
|
||||
if no_ff:
|
||||
args.append("--no-ff")
|
||||
if message:
|
||||
args.extend(["-m", message])
|
||||
args.append(ref)
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir, check=False)
|
||||
return {"output": stdout, "ref": ref}
|
||||
|
||||
@self.action("rebase", "Rebase commits")
|
||||
async def rebase(ctx: MCPContext, ref: str | None = None, op: str = "start", cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
if op == "abort":
|
||||
stdout, _, _ = await self._run_git("rebase", "--abort", cwd=work_dir)
|
||||
elif op == "continue":
|
||||
stdout, _, _ = await self._run_git("rebase", "--continue", cwd=work_dir)
|
||||
else:
|
||||
if not ref:
|
||||
raise InvalidParamsError("ref required for rebase", param="ref")
|
||||
stdout, _, _ = await self._run_git("rebase", ref, cwd=work_dir)
|
||||
return {"output": stdout, "op": op}
|
||||
|
||||
@self.action("cherry_pick", "Cherry-pick commits")
|
||||
async def cherry_pick(ctx: MCPContext, ref: str, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
stdout, _, _ = await self._run_git("cherry-pick", ref, cwd=work_dir)
|
||||
return {"output": stdout, "ref": ref}
|
||||
|
||||
@self.action("reset", "Reset HEAD")
|
||||
async def reset(ctx: MCPContext, ref: str = "HEAD", mode: str = "mixed", cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
stdout, _, _ = await self._run_git("reset", f"--{mode}", ref, cwd=work_dir)
|
||||
return {"output": stdout, "ref": ref, "mode": mode}
|
||||
|
||||
@self.action("clean", "Remove untracked files")
|
||||
async def clean(ctx: MCPContext, force: bool = False, directories: bool = False, dry_run: bool = True, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
args = ["clean"]
|
||||
if dry_run:
|
||||
args.append("-n")
|
||||
if force:
|
||||
args.append("-f")
|
||||
if directories:
|
||||
args.append("-d")
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
return {"output": stdout, "dry_run": dry_run}
|
||||
|
||||
@self.action("init", "Initialize repository")
|
||||
async def init(ctx: MCPContext, path: str | None = None, bare: bool = False, cwd: str | None = None) -> dict:
|
||||
work_dir = path or cwd or self.cwd
|
||||
args = ["init"]
|
||||
if bare:
|
||||
args.append("--bare")
|
||||
if path:
|
||||
args.append(path)
|
||||
stdout, _, _ = await self._run_git(*args, cwd=cwd or self.cwd)
|
||||
return {"output": stdout}
|
||||
|
||||
@self.action("clone", "Clone repository")
|
||||
async def clone(ctx: MCPContext, url: str, path: str | None = None, depth: int | None = None, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
args = ["clone"]
|
||||
if depth:
|
||||
args.extend(["--depth", str(depth)])
|
||||
args.append(url)
|
||||
if path:
|
||||
args.append(path)
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
return {"output": stdout, "url": url}
|
||||
|
||||
@self.action("fetch", "Fetch from remote")
|
||||
async def fetch(ctx: MCPContext, remote: str = "origin", prune: bool = False, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
args = ["fetch", remote]
|
||||
if prune:
|
||||
args.append("--prune")
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
return {"output": stdout, "remote": remote}
|
||||
|
||||
@self.action("pull", "Pull from remote")
|
||||
async def pull(ctx: MCPContext, remote: str = "origin", branch: str | None = None, rebase: bool = False, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
args = ["pull"]
|
||||
if rebase:
|
||||
args.append("--rebase")
|
||||
args.append(remote)
|
||||
if branch:
|
||||
args.append(branch)
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
return {"output": stdout}
|
||||
|
||||
@self.action("push", "Push to remote")
|
||||
async def push(ctx: MCPContext, remote: str = "origin", branch: str | None = None, force: bool = False, tags: bool = False, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
args = ["push"]
|
||||
if force:
|
||||
args.append("--force-with-lease")
|
||||
if tags:
|
||||
args.append("--tags")
|
||||
args.append(remote)
|
||||
if branch:
|
||||
args.append(branch)
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
return {"output": stdout}
|
||||
|
||||
@self.action("config", "Get/set config")
|
||||
async def config(ctx: MCPContext, key: str | None = None, value: str | None = None, scope: str = "local", cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
if key and value:
|
||||
stdout, _, _ = await self._run_git("config", f"--{scope}", key, value, cwd=work_dir)
|
||||
return {"set": key, "value": value}
|
||||
elif key:
|
||||
stdout, _, _ = await self._run_git("config", "--get", key, cwd=work_dir, check=False)
|
||||
return {"key": key, "value": stdout.strip()}
|
||||
else:
|
||||
stdout, _, _ = await self._run_git("config", "--list", f"--{scope}", cwd=work_dir)
|
||||
return {"config": stdout}
|
||||
|
||||
@self.action("worktree", "Worktree operations")
|
||||
async def worktree(ctx: MCPContext, op: str = "list", path: str | None = None, branch: str | None = None, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
if op == "add":
|
||||
if not path:
|
||||
raise InvalidParamsError("path required", param="path")
|
||||
args = ["worktree", "add", path]
|
||||
if branch:
|
||||
args.extend(["-b", branch])
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
elif op == "remove":
|
||||
if not path:
|
||||
raise InvalidParamsError("path required", param="path")
|
||||
stdout, _, _ = await self._run_git("worktree", "remove", path, cwd=work_dir)
|
||||
else:
|
||||
stdout, _, _ = await self._run_git("worktree", "list", cwd=work_dir)
|
||||
return {"output": stdout, "op": op}
|
||||
|
||||
@self.action("reflog", "Show reflog")
|
||||
async def reflog(ctx: MCPContext, limit: int = 20, ref: str = "HEAD", cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
stdout, _, _ = await self._run_git("reflog", f"-{limit}", ref, cwd=work_dir)
|
||||
return {"output": stdout}
|
||||
|
||||
@self.action("shortlog", "Summarize commits by author")
|
||||
async def shortlog(ctx: MCPContext, ref: str | None = None, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
args = ["shortlog", "-sne"]
|
||||
if ref:
|
||||
args.append(ref)
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
return {"output": stdout}
|
||||
|
||||
@self.action("rev_parse", "Parse revision")
|
||||
async def rev_parse(ctx: MCPContext, ref: str = "HEAD", cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
stdout, _, _ = await self._run_git("rev-parse", ref, cwd=work_dir)
|
||||
return {"sha": stdout.strip(), "ref": ref}
|
||||
|
||||
@self.action("describe", "Describe commit with tags")
|
||||
async def describe(ctx: MCPContext, ref: str | None = None, tags: bool = True, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
args = ["describe"]
|
||||
if tags:
|
||||
args.append("--tags")
|
||||
args.append("--always")
|
||||
if ref:
|
||||
args.append(ref)
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir, check=False)
|
||||
return {"description": stdout.strip()}
|
||||
|
||||
@self.action("bisect", "Binary search for bugs")
|
||||
async def bisect(ctx: MCPContext, op: str = "start", ref: str | None = None, cwd: str | None = None) -> dict:
|
||||
work_dir = cwd or self.cwd
|
||||
if op == "start":
|
||||
stdout, _, _ = await self._run_git("bisect", "start", cwd=work_dir)
|
||||
elif op == "good":
|
||||
args = ["bisect", "good"]
|
||||
if ref:
|
||||
args.append(ref)
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
elif op == "bad":
|
||||
args = ["bisect", "bad"]
|
||||
if ref:
|
||||
args.append(ref)
|
||||
stdout, _, _ = await self._run_git(*args, cwd=work_dir)
|
||||
elif op == "reset":
|
||||
stdout, _, _ = await self._run_git("bisect", "reset", cwd=work_dir)
|
||||
else:
|
||||
stdout, _, _ = await self._run_git("bisect", "log", cwd=work_dir, check=False)
|
||||
return {"output": stdout, "op": op}
|
||||
|
||||
# Backward compatibility
|
||||
git_tool = GitTool
|
||||
|
||||
Reference in New Issue
Block a user