feat(mcp): consolidate memory surface and restore local tool compatibility

This commit is contained in:
2026-03-04 15:21:57 -08:00
parent 2ec9db354d
commit 0de73f60d8
12 changed files with 458 additions and 138 deletions
+18 -5
View File
@@ -63,20 +63,22 @@ jobs:
python -c "
# Packages with exact expected counts
exact_packages = [
('hanzo_tools.fs', 7), # read, write, edit, tree, find, search, ast
('hanzo_tools.shell', 11), # dag, ps, zsh, bash, shell, npx, uvx, open, curl, jq, wget
('hanzo_tools.fs', 1), # unified fs tool
('hanzo_tools.browser', 1),
('hanzo_tools.memory', 1), # unified memory tool with actions
('hanzo_tools.todo', 1),
('hanzo_tools.reasoning', 2), # think, critic
('hanzo_tools.lsp', 1),
('hanzo_tools.refactor', 1),
('hanzo_tools.database', 8),
('hanzo_tools.database', 9),
('hanzo_tools.jupyter', 1),
('hanzo_tools.editor', 3),
('hanzo_tools.agent', 3), # agent, iching, review
]
# Shell tool count depends on detected shell, but core tools are fixed.
shell_required = {'ps', 'npx', 'uvx', 'open', 'curl', 'jq', 'wget'}
total = 0
import importlib
@@ -90,8 +92,19 @@ jobs:
assert actual == expected, f'{pkg_name}: expected {expected}, got {actual}'
total += actual
# Check shell package (detected-shell mode)
shell_pkg = importlib.import_module('hanzo_tools.shell')
shell_tools = getattr(shell_pkg, 'TOOLS', [])
shell_names = {t.name for t in shell_tools}
print(f'✓ hanzo_tools.shell: {len(shell_tools)} tools (detected-shell mode)')
assert len(shell_tools) >= 8, f'hanzo_tools.shell: expected >=8, got {len(shell_tools)}'
assert shell_required.issubset(shell_names), (
f'hanzo_tools.shell: missing required tools: {shell_required - shell_names}'
)
total += len(shell_tools)
print(f'\\nTotal: {total} tools')
# Total: 7+11+1+1+1+2+1+1+8+1+3+3 = 40
assert total == 40, f'Expected 40 tools, got {total}'
# Minimum total: exact packages (23) + shell minimum (8) = 31
assert total >= 31, f'Expected at least 31 tools, got {total}'
print('✓ All tool packages verified')
"
@@ -0,0 +1,86 @@
"""Backward-compatible batch tool implementation.
This module restores ``hanzo_mcp.tools.common.batch_tool.BatchTool`` for
existing callers and tests that still import it directly.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
from typing import Any
@dataclass
class _BatchResult:
index: int
tool_name: str
output: str
class BatchTool:
"""Execute multiple tool invocations concurrently.
The current server uses entry-point tools, but legacy code still imports
this class directly. Keep behavior simple and deterministic:
- preserves invocation order in output
- executes in parallel with a concurrency cap
- always returns a human-readable string
"""
name = "batch"
description = "Run multiple tools in parallel"
def __init__(self, tools: dict[str, Any], max_concurrency: int = 8):
self.tools = tools
self.max_concurrency = max(1, max_concurrency)
async def call(
self,
ctx: Any,
description: str,
invocations: list[dict[str, Any]],
max_concurrency: int | None = None,
**_: Any,
) -> str:
if not invocations:
return "Error: invocations cannot be empty"
semaphore = asyncio.Semaphore(max_concurrency or self.max_concurrency)
async def _run(index: int, invocation: dict[str, Any]) -> _BatchResult:
tool_name = str(invocation.get("tool_name", "")).strip()
payload = invocation.get("input", {})
if not isinstance(payload, dict):
payload = {} if payload is None else {"input": payload}
if not tool_name:
return _BatchResult(index, "<missing>", "Error: tool_name is required")
tool = self.tools.get(tool_name)
if tool is None:
return _BatchResult(index, tool_name, f"Error: Tool '{tool_name}' not found")
async with semaphore:
try:
# Support both call(ctx, **kwargs) and call(ctx=ctx, **kwargs).
try:
result = await tool.call(ctx=ctx, **payload)
except TypeError:
result = await tool.call(ctx, **payload)
except Exception as exc: # noqa: BLE001 - tool errors should be captured
return _BatchResult(index, tool_name, f"Error: {exc}")
return _BatchResult(index, tool_name, str(result))
tasks = [_run(i, invocation) for i, invocation in enumerate(invocations, start=1)]
results = await asyncio.gather(*tasks)
results.sort(key=lambda item: item.index)
lines = [f"Batch: {description}", "results:"]
for item in results:
lines.append(f"Result {item.index}: {item.tool_name}")
lines.append(item.output)
return "\n".join(lines)
+3 -3
View File
@@ -30,7 +30,7 @@ dependencies = [
"hanzo-tools-fs>=0.1.0",
"hanzo-tools-shell>=0.6.1",
"hanzo-tools-browser[playwright]>=0.3.0", # Extension-first routing, Playwright fallback
"hanzo-tools-memory>=0.1.0",
"hanzo-tools-memory>=0.2.2",
"hanzo-tools-todo>=0.1.0",
"hanzo-tools-reasoning>=0.1.0",
"hanzo-tools-lsp>=0.1.0",
@@ -72,7 +72,7 @@ dependencies = [
# Memory tools with full backend support
memory = [
"hanzo-tools-memory[full]>=0.2.0",
"hanzo-tools-memory[full]>=0.2.2",
"sqlite-vec>=0.1.0",
"fastembed>=0.4.0",
]
@@ -136,7 +136,7 @@ include = ["hanzo_mcp"]
[tool.pytest.ini_options]
# Configuration for pytest
addopts = "--no-header --no-summary"
addopts = "--no-header --no-summary -p pytest_asyncio.plugin"
asyncio_mode = "auto"
asyncio_default_fixture_loop_scope = "function"
# Note: --cov-fail-under=96.3 will be added after achieving target coverage
+40 -22
View File
@@ -3,6 +3,7 @@
import asyncio
import json
import re
import subprocess
import sys
import tempfile
@@ -11,14 +12,29 @@ from pathlib import Path
# Add the package to path for local testing
sys.path.insert(0, str(Path(__file__).parent.parent))
import pytest
from hanzo_mcp import __version__
from hanzo_mcp.server import create_server
def _tool_names(server) -> list[str]:
"""Extract registered tool names from FastMCP internals."""
if (
hasattr(server, "mcp")
and hasattr(server.mcp, "_tool_manager")
and hasattr(server.mcp._tool_manager, "_tools")
):
return list(server.mcp._tool_manager._tools.keys())
raise AssertionError("Cannot access tools from server")
def test_basic_import():
"""Test that we can import hanzo-mcp."""
print(f"✓ Successfully imported hanzo-mcp version {__version__}")
assert __version__ == "0.7.7"
assert re.match(r"^\d+\.\d+\.\d+", __version__), (
f"Unexpected version format: {__version__}"
)
def test_server_creation():
@@ -30,22 +46,13 @@ def test_server_creation():
print(f"✓ Created server: {server.__class__.__name__}")
# List tools
# Get tools from the MCP instance
if (
hasattr(server, "mcp")
and hasattr(server.mcp, "_tool_manager")
and hasattr(server.mcp._tool_manager, "_tools")
):
tools = server.mcp._tool_manager._tools
print(f"✓ Found {len(tools)} tools")
tool_names = list(tools.keys())
else:
print("✗ Cannot access tools from server")
raise AssertionError("Cannot access tools from server")
essential_tools = ["read", "write", "edit", "search", "bash"]
tool_names = _tool_names(server)
print(f"✓ Found {len(tool_names)} tools")
# Core tools that must exist in current builds.
essential_tools = ["read", "write", "edit", "search"]
missing_tools = []
for tool in essential_tools:
if tool in tool_names:
print(f" ✓ {tool} tool available")
@@ -53,12 +60,18 @@ def test_server_creation():
print(f" ✗ {tool} tool missing")
missing_tools.append(tool)
# Shell tool name can vary by distribution/version.
shell_aliases = {"bash", "zsh", "run_command", "shell"}
has_shell_tool = any(name in tool_names for name in shell_aliases)
if has_shell_tool:
print(" ✓ shell tool available")
else:
print(f" ✗ none of shell aliases found: {sorted(shell_aliases)}")
missing_tools.append("shell tool alias")
assert len(missing_tools) == 0, f"Missing tools: {missing_tools}"
import pytest
@pytest.mark.asyncio
async def test_file_operations():
"""Test basic file operations."""
@@ -117,7 +130,7 @@ async def test_search_functionality():
name="test-server", allowed_paths=[tmpdir], enable_all_tools=True
)
# Create test files
# Create test files before server startup so index-based search backends see them
for i in range(3):
test_file = Path(tmpdir) / f"file{i}.py"
test_file.write_text(f'''
@@ -144,7 +157,7 @@ def function_{i}():
# Verify search found all TODOs
assert "TODO" in result_text
assert "Total results: 3" in result_text
assert ("Found 3 matches" in result_text) or ("Total results: 3" in result_text)
# Verify each file was found
for i in range(3):
@@ -170,7 +183,8 @@ def test_cli_invocation():
[sys.executable, "-m", "hanzo_mcp", "--version"], capture_output=True, text=True
)
if result.returncode == 0 and "0.7" in result.stdout:
normalized_version = __version__.split("+")[0]
if result.returncode == 0 and normalized_version in result.stdout:
print(f" ✓ CLI version works: {result.stdout.strip()}")
else:
print(f" ✗ CLI version failed: {result.stderr}")
@@ -178,12 +192,16 @@ def test_cli_invocation():
@pytest.mark.asyncio
async def test_notebook_operations():
"""Test notebook operations."""
"""Test notebook operations when jupyter tool is available."""
with tempfile.TemporaryDirectory() as tmpdir:
server = create_server(
name="test-server", allowed_paths=[tmpdir], enable_all_tools=True
)
tool_names = _tool_names(server)
if "jupyter" not in tool_names:
pytest.skip("jupyter tool is not available in this build")
notebook_path = Path(tmpdir) / "test.ipynb"
# Create a notebook structure
+6 -1
View File
@@ -27,8 +27,13 @@ import aiofiles
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
# Compatibility: some older hanzo-tools.core builds don't re-export Paging.
try:
from hanzo_tools.core import Paging
except ImportError:
from hanzo_tools.core.unified import Paging
from hanzo_tools.core import (
Paging,
BaseTool,
ConflictError,
NotFoundError,
+16 -9
View File
@@ -10,8 +10,8 @@ pip install hanzo-tools-memory
## Tools
### memory - Unified Memory Management
Single tool with multiple actions for memory operations.
### memory - Memory Management
Single tool surface with action-based operations.
**Recall memories:**
```python
@@ -21,13 +21,12 @@ memory(action="recall", queries=["user preferences", "coding standards"])
**Create memories:**
```python
memory(action="create", data={"content": "User prefers dark mode"})
memory(action="create", statements=["Fact 1", "Fact 2"])
memory(action="create", content="User prefers dark mode")
```
**Update memories:**
```python
memory(action="update", id="mem_123", statement="Updated fact")
memory(action="update", id="mem_123", content="Updated fact")
```
**Delete memories:**
@@ -37,9 +36,9 @@ memory(action="delete", ids=["mem_123", "mem_456"])
**Knowledge bases:**
```python
memory(action="facts", query="API endpoints", kb_name="api_docs")
memory(action="store", facts=["Rate limit: 100/hour"], kb_name="api_docs")
memory(action="kb", action_type="list") # List knowledge bases
memory(action="facts", query="API endpoints", kb="api_docs")
memory(action="store", facts=["Rate limit: 100/hour"], kb="api_docs")
memory(action="kb", kb_action="list") # List knowledge bases
```
**Summarize:**
@@ -47,10 +46,18 @@ memory(action="kb", action_type="list") # List knowledge bases
memory(
action="summarize",
content="Long discussion about API design...",
topic="API Design Decisions"
tags=["api", "design"]
)
```
**Backend selection (optional):**
```python
memory(action="recall", query="project auth flow", backend="auto") # local-first (default)
memory(action="recall", query="project auth flow", backend="local") # markdown only
memory(action="recall", query="project auth flow", backend="cloud") # cloud/vector only
memory(action="recall", query="project auth flow", backend="hybrid") # local + cloud
```
## Scopes
- `session` - Current session only
@@ -1,55 +1,20 @@
"""Memory and knowledge tools for Hanzo MCP.
"""Memory tools for Hanzo MCP.
Works out of the box with NO backend — reads from local .md files
(MEMORY.md, LLM.md, CLAUDE.md, etc.) and stores memories to MEMORY.md.
Install hanzo-memory for full vector search and persistent storage:
pip install hanzo-tools-memory[full]
Tool list (registered individually as MCP tools):
recall_memories, create_memories, update_memories, delete_memories, manage_memories
recall_facts, store_facts, summarize_to_memory, manage_knowledge_bases
External MCP surface is intentionally one tool: `memory`.
Default backend is local markdown context + session persistence, with optional
hanzo-memory backends for cloud/vector/graph storage.
"""
from mcp.server import FastMCP
from hanzo_tools.core import BaseTool, ToolRegistry
from hanzo_tools.memory.memory_tools import (
MEMORY_TOOL_CLASSES,
CreateMemoriesTool,
DeleteMemoriesTool,
ManageMemoriesTool,
RecallMemoriesTool,
UpdateMemoriesTool,
)
from hanzo_tools.memory.knowledge_tools import (
KNOWLEDGE_TOOL_CLASSES,
StoreFactsTool,
RecallFactsTool,
SummarizeToMemoryTool,
ManageKnowledgeBasesTool,
)
from hanzo_tools.memory.unified_memory_tool import UnifiedMemoryTool
ALL_TOOL_CLASSES = MEMORY_TOOL_CLASSES + KNOWLEDGE_TOOL_CLASSES
from hanzo_tools.memory.memory_tool import MemoryTool
__all__ = [
# Individual tools
"RecallMemoriesTool",
"CreateMemoriesTool",
"UpdateMemoriesTool",
"DeleteMemoriesTool",
"ManageMemoriesTool",
"RecallFactsTool",
"StoreFactsTool",
"SummarizeToMemoryTool",
"ManageKnowledgeBasesTool",
# Unified tool
"UnifiedMemoryTool",
"MemoryTool",
# Registration helpers
"get_memory_tools",
"register_memory_tools",
"ALL_TOOL_CLASSES",
"TOOLS",
]
@@ -59,8 +24,8 @@ def get_memory_tools(
project_id: str = "default",
**kwargs,
) -> list[BaseTool]:
"""Create all memory tool instances."""
return [cls(user_id=user_id, project_id=project_id, **kwargs) for cls in ALL_TOOL_CLASSES]
"""Create memory tool instances (single-tool surface)."""
return [MemoryTool(user_id=user_id, project_id=project_id, **kwargs)]
def register_memory_tools(
@@ -69,11 +34,11 @@ def register_memory_tools(
project_id: str = "default",
**kwargs,
) -> list[BaseTool]:
"""Register all memory tools with the MCP server."""
"""Register memory tools with the MCP server."""
tools = get_memory_tools(user_id=user_id, project_id=project_id, **kwargs)
ToolRegistry.register_tools(mcp_server, tools)
return tools
# TOOLS list for entry point discovery — single unified memory tool
TOOLS = [UnifiedMemoryTool]
# TOOLS list for entry point discovery — single `memory` tool surface
TOOLS = [MemoryTool]
@@ -8,28 +8,42 @@ This is the default backend when hanzo-memory is not installed.
from __future__ import annotations
import os
import re
import json
from uuid import uuid4
from typing import Dict, List, Optional
from pathlib import Path
from datetime import datetime
from dataclasses import field, dataclass
# Files to scan for context
CONTEXT_MD_FILES = [
"MEMORY.md",
"LLM.md",
"CLAUDE.md",
# Priority instruction files (highest to lowest precedence within each directory)
PRIMARY_CONTEXT_FILES = [
"AGENTS.md",
"CLAUDE.md",
"GEMINI.md",
"LLM.md",
]
# Additional markdown context files
ADDITIONAL_CONTEXT_FILES = [
"MEMORY.md",
"QWEN.md",
"AI.md",
"CONTEXT.md",
"INSTRUCTIONS.md",
]
# Editor/assistant rule files
STATIC_RULE_FILES = [
".cursorrules",
".github/copilot-instructions.md",
]
RULE_GLOBS = [
".cursor/rules/*.md",
".cursor/rules/*.mdc",
".github/instructions/*.instructions.md",
]
# Per-scope memory files we write to
SCOPE_MEMORY_FILES = {
"global": Path.home() / ".claude" / "MEMORY.md",
@@ -64,7 +78,8 @@ class MarkdownFact:
class MarkdownMemoryBackend:
"""Lightweight memory backend using local markdown files.
- Reads context from LLM.md, CLAUDE.md, MEMORY.md, etc.
- Reads local-first context from AGENTS.md, CLAUDE.md, GEMINI.md, LLM.md, etc.
- Detects Cursor/Copilot rule files (.cursorrules, .cursor/rules/*, .github/copilot-instructions.md)
- Stores new memories in MEMORY.md files (per scope)
- Session memories are in-process only
- No embedding search — uses simple keyword matching
@@ -83,27 +98,60 @@ class MarkdownMemoryBackend:
self._loaded = True
self._file_memories = []
# Scan current directory chain for context files
# Scan current directory chain for context files (local-first)
scan_dirs = self._get_scan_dirs()
for d in scan_dirs:
for fname in CONTEXT_MD_FILES:
fpath = d / fname
if fpath.exists() and fpath.stat().st_size > 0:
try:
content = fpath.read_text(encoding="utf-8", errors="ignore")
# Split into sections/chunks
chunks = self._chunk_markdown(content)
for chunk in chunks:
if chunk.strip():
self._file_memories.append(MarkdownMemory(
for fpath in self._iter_context_files(d):
try:
content = fpath.read_text(encoding="utf-8", errors="ignore")
# Split into sections/chunks
chunks = self._chunk_markdown(content)
for chunk in chunks:
if chunk.strip():
self._file_memories.append(
MarkdownMemory(
memory_id=str(uuid4()),
content=chunk.strip(),
scope="global",
created_at=datetime.now().isoformat(),
source=str(fpath),
))
except Exception:
pass
)
)
except Exception:
pass
def _iter_context_files(self, directory: Path) -> List[Path]:
"""Collect context and rule files in deterministic priority order."""
ordered: List[Path] = []
seen: set[Path] = set()
def add_if_valid(path: Path) -> None:
if path in seen:
return
if not path.exists() or not path.is_file():
return
try:
if path.stat().st_size <= 0:
return
except OSError:
return
seen.add(path)
ordered.append(path)
for fname in PRIMARY_CONTEXT_FILES:
add_if_valid(directory / fname)
for fname in ADDITIONAL_CONTEXT_FILES:
add_if_valid(directory / fname)
for fname in STATIC_RULE_FILES:
add_if_valid(directory / fname)
for pattern in RULE_GLOBS:
for rule_path in sorted(directory.glob(pattern)):
add_if_valid(rule_path)
return ordered
def _get_scan_dirs(self) -> List[Path]:
"""Get directories to scan for context files."""
@@ -1,6 +1,6 @@
"""Unified memory tool - consolidates all memory operations.
"""Memory tool for Hanzo MCP.
Single tool with action parameter replaces 9 separate memory tools.
Single `memory` tool with action parameter for all memory operations.
"""
from typing import (
@@ -70,14 +70,22 @@ Action = Annotated[
Field(description="Memory action to perform"),
]
BackendMode = Annotated[
Literal["auto", "local", "cloud", "hybrid"],
Field(
description=(
"Backend mode: auto/local-first (default), local-only, "
"cloud-only, or hybrid(local+cloud)"
)
),
]
@final
class UnifiedMemoryTool(BaseTool):
"""Unified memory tool for all memory operations.
class MemoryTool(BaseTool):
"""Memory tool for all memory operations.
Consolidates: recall_memories, create_memories, update_memories,
delete_memories, manage_memories, recall_facts, store_facts,
summarize_to_memory, manage_knowledge_bases
Consolidates legacy memory/facts/knowledge tool variants behind one surface.
"""
name = "memory"
@@ -90,7 +98,7 @@ class UnifiedMemoryTool(BaseTool):
@property
@override
def description(self) -> str:
return """Unified memory management tool.
return """Memory management tool.
Actions:
- recall: Search and retrieve memories
@@ -103,6 +111,7 @@ Actions:
- summarize: Summarize information and store
- kb: Create/list/manage knowledge bases
- list: List all memories or knowledge bases
- backend: Defaults to local-first markdown (`auto`), with optional cloud/vector backend
Examples:
memory recall --query "user preferences"
@@ -122,6 +131,7 @@ Examples:
ctx: MCPContext,
action: str = "list",
# Common params
backend: str = "auto",
query: Optional[str] = None,
queries: Optional[List[str]] = None,
content: Optional[str] = None,
@@ -150,15 +160,13 @@ Examples:
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
from hanzo_tools.memory.markdown_memory import get_markdown_backend
try:
if action == "recall":
return await self._recall(
queries or ([query] if query else []), scope, limit
queries or ([query] if query else []), scope, limit, backend
)
elif action == "create":
return await self._create(content, tags, metadata, scope)
return await self._create(content, tags, metadata, scope, backend)
elif action == "update":
return await self._update(id, content, tags, metadata)
elif action == "delete":
@@ -187,23 +195,82 @@ Examples:
except Exception as e:
return f"Memory operation failed: {e}"
async def _recall(self, queries: List[str], scope: str, limit: int) -> str:
async def _recall(
self,
queries: List[str],
scope: str,
limit: int,
backend_mode: str,
) -> str:
"""Search and retrieve memories."""
if not queries:
return "Error: query or queries required for recall"
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
results = backend.search_memories(queries=queries, limit=limit, scope=scope)
if not results:
mode = (backend_mode or "auto").lower()
use_local = mode in ("auto", "local", "hybrid")
use_cloud = mode in ("auto", "cloud", "hybrid") and _check_memory_available()
combined: List[tuple[str, str, str, str]] = []
seen_ids: set[str] = set()
seen_content: set[str] = set()
if use_local:
local_backend = get_markdown_backend()
local_results = local_backend.search_memories(
queries=queries, limit=limit, scope=scope
)
for mem in local_results:
mem_id = str(getattr(mem, "memory_id", ""))
content = str(getattr(mem, "content", ""))
source = str(getattr(mem, "source", "local"))
if mem_id and mem_id in seen_ids:
continue
if content and content in seen_content:
continue
if mem_id:
seen_ids.add(mem_id)
if content:
seen_content.add(content)
combined.append(("local", mem_id, content, source))
if use_cloud:
cloud_backend = _get_lazy_memory_service()
if cloud_backend is not None:
for q in queries:
cloud_results = cloud_backend.search_memories(
user_id=self.user_id,
query=q,
project_id=self.project_id,
limit=limit,
)
for mem in cloud_results:
mem_id = str(getattr(mem, "memory_id", ""))
content = str(getattr(mem, "content", ""))
if mem_id and mem_id in seen_ids:
continue
if content and content in seen_content:
continue
if mem_id:
seen_ids.add(mem_id)
if content:
seen_content.add(content)
score = getattr(mem, "similarity_score", None)
score_text = f" score={score:.3f}" if isinstance(score, (int, float)) else ""
combined.append(("cloud", mem_id, content, f"vector{score_text}"))
if not combined:
if mode in ("cloud", "hybrid") and not _check_memory_available():
return "No memories found. Cloud backend unavailable; using local markdown only."
return "No memories found matching the query."
lines = [f"Found {len(results)} memories:"]
for mem in results[:limit]:
src = getattr(mem, "source", "")
src_note = f" [{src}]" if src and src != "user" else ""
lines.append(f" [{mem.memory_id[:8]}] {mem.content[:100]}{src_note}")
lines = [f"Found {min(len(combined), limit)} memories (mode={mode}):"]
for source_kind, mem_id, content, source in combined[:limit]:
tag = f"{source_kind}:{source}" if source else source_kind
id_text = mem_id[:8] if mem_id else "no-id"
preview = content[:100] if content else "(empty)"
lines.append(f" [{id_text}] {preview} [{tag}]")
return "\n".join(lines)
async def _create(
@@ -212,15 +279,42 @@ Examples:
tags: Optional[List[str]],
metadata: Optional[Dict],
scope: str,
backend_mode: str,
) -> str:
"""Store new memory."""
if not content:
return "Error: content required for create"
mode = (backend_mode or "auto").lower()
use_local = mode in ("auto", "local", "hybrid")
use_cloud = mode in ("auto", "cloud", "hybrid") and _check_memory_available()
results: List[str] = []
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
mem = backend.add_memory(content=content, scope=scope)
return f"Created memory: {mem.memory_id}"
if use_local:
local_backend = get_markdown_backend()
mem = local_backend.add_memory(content=content, scope=scope)
results.append(f"local:{mem.memory_id}")
if use_cloud:
cloud_backend = _get_lazy_memory_service()
if cloud_backend is not None:
cloud_memory = cloud_backend.create_memory(
user_id=self.user_id,
project_id=self.project_id,
content=content,
metadata=metadata or {"scope": scope, "source": "memory_tool"},
)
results.append(f"cloud:{cloud_memory.memory_id}")
if not results:
if mode in ("cloud", "hybrid"):
return "Memory not created: cloud backend unavailable."
return "Memory not created."
return f"Created memory ({mode}): " + ", ".join(results)
async def _update(
self,
@@ -389,13 +483,16 @@ Examples:
from hanzo_tools.memory.markdown_memory import get_markdown_backend
backend = get_markdown_backend()
memories = backend.search_memories(queries=[""], limit=limit, scope=scope)
backend._ensure_loaded()
memories = list(backend._file_memories) + list(backend._session_memories)
if scope != "global":
memories = [m for m in memories if getattr(m, "scope", "global") == scope]
if not memories:
return "No memories found."
lines = [f"Memories ({len(memories)}):"]
for mem in memories:
lines = [f"Memories ({min(len(memories), limit)} of {len(memories)}):"]
for mem in memories[:limit]:
lines.append(f" [{mem.memory_id[:8]}] {mem.content[:60]}...")
return "\n".join(lines)
@@ -406,6 +503,7 @@ Examples:
@mcp_server.tool()
async def memory(
action: Action = "list",
backend: BackendMode = "auto",
query: Annotated[Optional[str], Field(description="Search query")] = None,
queries: Annotated[
Optional[List[str]], Field(description="Multiple queries")
@@ -448,10 +546,11 @@ Examples:
] = "memories",
ctx: MCPContext = None,
) -> str:
"""Unified memory management: recall, create, update, delete, facts, kb."""
"""Memory management: recall, create, update, delete, facts, kb."""
return await tool_instance.call(
ctx,
action=action,
backend=backend,
query=query,
queries=queries,
content=content,
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "hanzo-tools-memory"
version = "0.2.1"
version = "0.2.2"
description = "Memory and knowledge base tools for Hanzo MCP"
readme = "README.md"
requires-python = ">=3.12"
@@ -0,0 +1,74 @@
"""Tests for memory tool surface and local context discovery."""
from pathlib import Path
from hanzo_tools.memory import TOOLS
from hanzo_tools.memory.markdown_memory import MarkdownMemoryBackend
def _write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def test_memory_surface_is_single_tool() -> None:
"""Entry-point surface should expose only one MCP tool: memory."""
assert len(TOOLS) == 1
tool = TOOLS[0]()
assert tool.name == "memory"
def test_context_file_priority_and_rule_detection(tmp_path: Path, monkeypatch) -> None:
"""Discover AGENTS->CLAUDE->GEMINI->LLM first and detect editor rule files."""
_write(tmp_path / "AGENTS.md", "# agents")
_write(tmp_path / "CLAUDE.md", "# claude")
_write(tmp_path / "GEMINI.md", "# gemini")
_write(tmp_path / "LLM.md", "# llm")
_write(tmp_path / "MEMORY.md", "# memory")
_write(tmp_path / ".cursorrules", "cursor rules")
_write(tmp_path / ".cursor" / "rules" / "team.mdc", "cursor team rules")
_write(
tmp_path / ".github" / "copilot-instructions.md",
"# copilot instructions",
)
_write(
tmp_path / ".github" / "instructions" / "python.instructions.md",
"# python instructions",
)
monkeypatch.chdir(tmp_path)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
backend = MarkdownMemoryBackend()
files = backend._iter_context_files(tmp_path)
rel_paths = [str(p.relative_to(tmp_path)) for p in files]
assert rel_paths[:4] == ["AGENTS.md", "CLAUDE.md", "GEMINI.md", "LLM.md"]
assert ".cursorrules" in rel_paths
assert ".cursor/rules/team.mdc" in rel_paths
assert ".github/copilot-instructions.md" in rel_paths
assert ".github/instructions/python.instructions.md" in rel_paths
def test_scan_is_local_first_then_parent(tmp_path: Path, monkeypatch) -> None:
"""Current directory files should be loaded before parent directory files."""
repo_root = tmp_path / "repo"
project_dir = repo_root / "services" / "api"
_write(repo_root / "AGENTS.md", "# root agents")
_write(project_dir / "AGENTS.md", "# local agents")
monkeypatch.chdir(project_dir)
monkeypatch.setenv("HOME", str(tmp_path / "home"))
backend = MarkdownMemoryBackend()
backend._ensure_loaded()
sources = [
Path(m.source)
for m in backend._file_memories
if m.source.endswith("AGENTS.md")
]
assert sources
assert sources[0] == project_dir / "AGENTS.md"
assert repo_root / "AGENTS.md" in sources
@@ -21,8 +21,13 @@ from datetime import datetime
from mcp.server import FastMCP
from mcp.server.fastmcp import Context as MCPContext
# Compatibility: some older hanzo-tools.core builds don't re-export Paging.
try:
from hanzo_tools.core import Paging
except ImportError:
from hanzo_tools.core.unified import Paging
from hanzo_tools.core import (
Paging,
BaseTool,
ToolError,
NotFoundError,