hanzo-mcp: search, ranked code spans from a local index

One call answers a question or a failing test's output with the spans
of the tree most likely to hold the cause, as path:start-end and their
lines, so an agent stops chaining grep and read.

Python is chunked by ast, other text in overlapping 40-line windows.
BM25 over code tokens is fused by reciprocal rank with the spans a
traceback's path:line frames name; when the query names a test file
the index holds, the code under test ranks first. Every search stats
the tree and re-chunks only what moved.

A dense stage (Qwen3-Embedding form, vectors cached by text hash) is
on only when HANZO_SEARCH_MODEL names a model: on the 42 dev tasks it
lifts span MRR .385 to .508, but a CPU query embedding takes seconds.

`python -m hanzo_search.server --root DIR` serves the tool alone over
stdio with the standard library, outside hanzo_mcp, whose import is
seconds long.

The `hanzo` mode lists it, so the default surface carries it; another
mode, `tool disable search` or HANZO_MCP_ENABLED_TOOLS decide it as they
decide every other tool.
This commit is contained in:
2026-10-12 08:51:36 -07:00
parent 7374804405
commit aa6aa95656
9 changed files with 950 additions and 2 deletions
+26
View File
@@ -416,6 +416,32 @@ runs them against `conformance/fake.py`, and `uv run python tests/live_conforman
against api.hanzo.ai and prints pass or fail per rule. Release: tag `hanzo-kai-v<version>`, and
`https://pypi.org/pypi/hanzo-kai/json` is the proof, not a green run.
## search — ranked code spans from a local index
`hanzo_search` ships in the hanzo-mcp distribution as its own top-level package, because
`import hanzo_mcp` costs seconds and the standalone server must answer in under one. hanzo-mcp
registers `search(query, root="", k=5)` in `_register_system_tools`; `python -m
hanzo_search.server --root DIR` serves it alone over stdio (newline JSON-RPC, standard library
only, ~0.2 s to the first answer, index included, on a 10K-line tree).
- **Index** (`index.py`): Python chunked by `ast` (each function and method, each class header,
code between them in windows), other text in 40-line windows 30 apart; nothing over 1 MB,
binaries, hidden dirs, `node_modules`, venvs or build output. Every search stats the tree
first: (size, mtime, ctime, inode) moved → read; bytes changed → re-chunked; gone → dropped.
- **Rank**: BM25 over code tokens (an identifier whole, then its dotted, snake and camel parts),
fused by reciprocal rank with the spans a traceback's `path:line` frames name, deepest first.
When the query names a test file the index holds, test files rank after the code under test.
- **Dense** (`dense.py`), off unless `HANZO_SEARCH_MODEL` names a local model directory
(extra `search`: torch, transformers): Qwen3-Embedding form, CPU, fp32; chunk vectors by the
SHA-256 of their text in `HANZO_SEARCH_CACHE` (default `~/.cache/hanzo/search`), one SQLite
file per model. On dev it lifts span MRR .385 → .508 (traceback) and .327 → .472 (short),
but a query embedding took 2.5–7 s on dgx's CPU, so the default is BM25 and frames.
- Dev (42 kai-day code tasks, `~/.cache/kai-day/search.dev.json`), failing-test output as the
query: file r@1 .714, r@5 1.0; span r@1 .19, r@5 .667. Warm search on a 72K-line tree p50
11 ms, p95 43 ms.
- Tests: `tests/test_hanzo_search.py` (tokens, chunks, ranking, freshness, caps, schema size,
dense cache with a fake embedder, stdio round trip).
## Plan usage — `hanzoai.usage`
`read_usage(headers)` → `Usage(usage, usage_class, paid_by, fallback, served, reason)` from
+12
View File
@@ -273,6 +273,18 @@ def register_all_tools(
else:
logger.warning("No tool packages discovered via entry points")
# search ships in this distribution (hanzo_search), so the mode decides it here, as it does a package's
if "search" in asked:
search_on = asked["search"]
elif asked.get("all") or not (mode and mode.tools):
search_on = True
else:
search_on = bool(tool_config.get("search", False))
if search_on:
from hanzo_search import register as register_search
register_search(mcp_server)
# Register system tools that are always available
_register_system_tools(mcp_server, all_tools)
@@ -142,7 +142,7 @@ ESSENTIAL_TOOLS = [
# (`tool enable`, HANZO_MCP_ENABLED_TOOLS).
DEFAULT_TOOLS = [
"fs", "exec", "zsh", "git", "curl", "code", "lsp", "browser", "playwright", "agent", "fetch",
"llm", "kai_decide", "repo", "hanzo", "mode", "tool",
"llm", "kai_decide", "repo", "hanzo", "mode", "tool", "search",
]
# Process and package helpers the wider personalities keep.
+33
View File
@@ -0,0 +1,33 @@
"""`search`: ranked code spans for a natural-language or error-text query, from a local index."""
from __future__ import annotations
from typing import Any
DESCRIPTION = (
"Find code: the spans of a source tree most relevant to a question or to error text "
"(a traceback or failing test output works as is), ranked, each as path:start-end and its lines. "
"Indexes root (default: working directory) once and follows edits. Use before grep/read."
)
SCHEMA: dict[str, Any] = {
"type": "object",
"properties": {
"query": {"type": "string"},
"root": {"type": "string"},
"k": {"type": "integer", "default": 5},
},
"required": ["query"],
}
def register(mcp: Any) -> None:
"""Add `search` to a FastMCP server; the index loads on the first call."""
@mcp.tool(name="search", description=DESCRIPTION)
async def search(query: str, root: str = "", k: int = 5) -> str:
import asyncio
from hanzo_search.index import search as run
return await asyncio.to_thread(run, query, root, k)
+125
View File
@@ -0,0 +1,125 @@
"""The optional dense stage: a local embedding model, on only when HANZO_SEARCH_MODEL names one.
The model is read in the Qwen3-Embedding form (last-token pooled, unit length, a query under
an instruction), on the CPU, offline. Each chunk is embedded once: its vector is kept under the
SHA-256 of its text in HANZO_SEARCH_CACHE (default ~/.cache/hanzo/search), one SQLite file per
model, so another process, or another checkout of the same files, embeds nothing again.
Needs torch and transformers, which the base install does not carry.
"""
from __future__ import annotations
import hashlib
import os
import sqlite3
import threading
from typing import TYPE_CHECKING, Any, Callable
if TYPE_CHECKING:
from hanzo_search.index import Chunk, Index
PREFIX = "Instruct: Given a failing test's output or a question about code, retrieve the code it concerns\nQuery:"
LENGTH = 512 # tokens read of a chunk or a query (a query keeps its end)
BATCH = 16
def text(c: "Chunk") -> str:
return f"{c.path} {c.name}\n{c.text}"
def cache() -> str:
return os.environ.get("HANZO_SEARCH_CACHE") or os.path.join(os.path.expanduser("~"), ".cache", "hanzo", "search")
class Dense:
"""Chunk vectors by text hash (memory, then the SQLite file, then embed), ranked by cosine."""
def __init__(self, embed: Callable[[list[str]], Any], db: str):
os.makedirs(os.path.dirname(db), exist_ok=True)
self.embed = embed
self.db = sqlite3.connect(db, check_same_thread=False)
self.db.execute("create table if not exists v (k blob primary key, x blob)")
self.lock = threading.Lock()
self.held: dict[bytes, Any] = {}
self.matrix: dict[int, tuple[tuple[bytes, ...], Any]] = {}
self.embedded = 0
def vectors(self, ix: "Index") -> tuple[list[int], Any]:
import numpy as np
ids = sorted(ix.chunks)
keys = [hashlib.sha256(text(ix.chunks[c]).encode()).digest() for c in ids]
want = list({k for k in keys if k not in self.held})
for i in range(0, len(want), 500):
part = want[i : i + 500]
for k, x in self.db.execute("select k, x from v where k in (%s)" % ",".join("?" * len(part)), part):
self.held[k] = np.frombuffer(x, dtype=np.float16).astype(np.float32)
todo = [k for k in want if k not in self.held]
if todo:
byk = {k: c for c, k in zip(ids, keys)}
new = self.embed([text(ix.chunks[byk[k]]) for k in todo])
self.embedded += len(todo)
self.db.executemany("insert or replace into v values (?, ?)", [(k, v.astype(np.float16).tobytes()) for k, v in zip(todo, new)])
self.db.commit()
for k, v in zip(todo, new):
self.held[k] = np.asarray(v, dtype=np.float16).astype(np.float32)
tag = tuple(keys)
m = self.matrix.get(id(ix))
if m is None or m[0] != tag:
m = (tag, np.stack([self.held[k] for k in keys]) if keys else np.zeros((0, 1), np.float32))
self.matrix[id(ix)] = m
return ids, m[1]
def rank(self, ix: "Index", query: str) -> list[int]:
with self.lock:
ids, m = self.vectors(ix)
if not ids:
return []
s = m @ self.embed([PREFIX + query])[0]
return [ids[i] for i in (-s).argsort()]
def model(path: str, device: str = "cpu") -> tuple[Callable[[list[str]], Any], str]:
"""An embed function over a local model directory, and the model's identity."""
try:
import torch
from transformers import AutoModel, AutoTokenizer
except ImportError as e:
raise RuntimeError(f"HANZO_SEARCH_MODEL is set but {e.name} is not installed") from e
tok = AutoTokenizer.from_pretrained(path, padding_side="left", truncation_side="left", local_files_only=True)
dtype = torch.float32 if device == "cpu" else torch.bfloat16
net = AutoModel.from_pretrained(path, dtype=dtype, local_files_only=True).to(device).eval()
def embed(texts: list[str]) -> Any:
import numpy as np
out = []
for i in range(0, len(texts), BATCH):
b = tok(texts[i : i + BATCH], padding=True, truncation=True, max_length=LENGTH, return_tensors="pt").to(device)
with torch.inference_mode():
h = net(**b).last_hidden_state[:, -1].float()
out.append(torch.nn.functional.normalize(h, dim=-1).cpu().numpy())
return np.concatenate(out)
ident = hashlib.sha256(open(os.path.join(path, "config.json"), "rb").read())
for name in sorted(os.listdir(path)):
if name.endswith(".safetensors"):
ident.update(f"{name}:{os.path.getsize(os.path.join(path, name))}".encode())
return embed, ident.hexdigest()[:16]
_dense: Dense | None = None
_guard = threading.Lock()
def load() -> Dense | None:
"""The process's dense stage, None unless HANZO_SEARCH_MODEL names a model directory."""
global _dense
path = os.environ.get("HANZO_SEARCH_MODEL")
if not path:
return None
with _guard:
if _dense is None:
embed, ident = model(path)
_dense = Dense(embed, os.path.join(cache(), ident + ".sqlite"))
return _dense
+366
View File
@@ -0,0 +1,366 @@
"""An index of a source tree that ranks code spans for a query and follows the tree.
Every search stats the tree first: a file whose size, mtime, ctime or inode moved is read
again, re-chunked only when its bytes changed, and a file gone from the tree drops out.
Ranking fuses, by reciprocal rank, BM25 over code-aware tokens, the spans a traceback in
the query names by path and line, and (when a model is configured) dense similarity.
"""
from __future__ import annotations
import ast
import hashlib
import math
import os
import re
import threading
import warnings
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Callable
if TYPE_CHECKING:
from hanzo_search.dense import Dense
SKIP = {"node_modules", "__pycache__", "venv", "env", "build", "dist", "target", "site-packages"}
NOISE = (".lock", ".min.js", ".map", ".svg", ".pyc", ".pyo", ".so", ".o", ".a", ".dylib", ".dll", ".exe", ".whl", ".gz", ".zip", ".tar", ".png", ".jpg", ".jpeg", ".gif", ".ico", ".pdf", ".woff", ".woff2", ".ttf", ".npy", ".npz", ".pkl", ".sqlite", ".db")
LIMIT = 1 << 20 # bytes: a larger file is not indexed
SPAN = 40 # lines a chunk holds at most
STRIDE = 30 # a window's step, so windows overlap by SPAN - STRIDE
WIDTH = 300 # characters a shown line keeps
K1, B = 1.2, 0.75
RRF = 60
WORD = re.compile(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)*")
PART = re.compile(r"[A-Z]+(?![a-z])|[A-Z]?[a-z]+|[0-9]+")
PATH = re.compile(r"(?:[\w.-]+/)*[\w-]+\.\w+")
FRAME = re.compile(r"""(?:File "([^"]+)", line (\d+))|((?:[\w.-]+/)*[\w.-]+\.\w+):(\d+)""")
def tokens(text: str) -> list[str]:
"""Lowercased identifiers, each whole, then its dotted segments and their snake/camel parts."""
out: list[str] = []
for m in WORD.finditer(text):
word = m.group()
if "." in word:
out.append(word.lower())
for seg in word.split("."):
low = seg.lower()
if len(low) > 1:
out.append(low)
parts = PART.findall(seg)
if len(parts) > 1:
out.extend(p.lower() for p in parts if len(p) > 1)
return out
@dataclass
class Chunk:
path: str
start: int # 1-based, inclusive
end: int
name: str
text: str
tf: dict[str, int] = field(default_factory=dict)
length: int = 0
def windows(start: int, end: int) -> list[tuple[int, int]]:
"""[start, end] as windows of at most SPAN lines, STRIDE apart, the last flush with end."""
if end - start + 1 <= SPAN:
return [(start, end)]
out = []
s = start
while True:
out.append((s, min(s + SPAN - 1, end)))
if s + SPAN - 1 >= end:
return out
s += STRIDE
def outline(text: str) -> list[tuple[int, int, str]]:
"""Python spans: each function and method, each class's header, and the code between them."""
with warnings.catch_warnings():
warnings.simplefilter("ignore")
tree = ast.parse(text)
spans: list[tuple[int, int, str]] = []
defs = (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
def first(node: ast.AST) -> int:
return min([node.lineno] + [d.lineno for d in node.decorator_list]) # type: ignore[attr-defined]
def visit(body: list[ast.stmt], prefix: str) -> None:
for node in body:
if not isinstance(node, defs):
continue
name = prefix + node.name
inner = [n for n in node.body if isinstance(n, defs)] if isinstance(node, ast.ClassDef) else []
if inner:
spans.append((first(node), min(first(n) for n in inner) - 1, name))
visit(node.body, name + ".")
else:
spans.append((first(node), node.end_lineno or node.lineno, name))
visit(tree.body, "")
return spans
def chunk(path: str, text: str) -> list[Chunk]:
"""A file's chunks: Python by its outline (code outside any def in gap windows), else windows."""
lines = text.splitlines()
n = len(lines)
spans: list[tuple[int, int, str]] = []
if path.endswith(".py"):
try:
spans = outline(text)
except (SyntaxError, ValueError):
spans = []
covered = bytearray(n + 2)
out: list[tuple[int, int, str]] = []
for s, e, name in spans:
for a, b in windows(s, e):
out.append((a, b, name))
for i in range(s, e + 1):
covered[i] = 1
i = 1
while i <= n:
if covered[i]:
i += 1
continue
j = i
while j + 1 <= n and not covered[j + 1]:
j += 1
out.extend((a, b, "") for a, b in windows(i, j))
i = j + 1
chunks = []
for s, e, name in sorted(out):
body = "\n".join(lines[s - 1 : e])
if not body.strip():
continue
c = Chunk(path, s, e, name, body)
toks = tokens(f"{path} {name}\n{body}")
for t in toks:
c.tf[t] = c.tf.get(t, 0) + 1
c.length = len(toks)
chunks.append(c)
return chunks
def walk(root: str) -> dict[str, os.stat_result]:
"""Indexable files under root, by path relative to it."""
out: dict[str, os.stat_result] = {}
stack = [""]
while stack:
rel = stack.pop()
try:
it = os.scandir(os.path.join(root, rel) if rel else root)
except OSError:
continue
with it:
for e in it:
name = e.name
if name.startswith(".") or name in SKIP or name.endswith(".egg-info"):
continue
path = f"{rel}/{name}" if rel else name
try:
if e.is_dir(follow_symlinks=False):
stack.append(path)
elif e.is_file(follow_symlinks=False) and not name.endswith(NOISE):
st = e.stat(follow_symlinks=False)
if st.st_size <= LIMIT:
out[path] = st
except OSError:
continue
return out
def tested(path: str) -> bool:
"""A test file, by the names pytest and unittest collect."""
parts = path.split("/")
base = parts[-1]
return base.startswith("test_") or base.endswith("_test.py") or base == "conftest.py" or any(p in ("test", "tests") for p in parts[:-1])
@dataclass
class Hit:
chunk: Chunk
score: float
class Index:
"""Chunks of one tree with BM25 postings, kept current by refresh()."""
def __init__(self, root: str, dense: "Dense | None" = None):
self.root = os.path.realpath(root)
self.dense = dense
self.files: dict[str, tuple[tuple[int, int, int, int], str, list[int]]] = {}
self.chunks: dict[int, Chunk] = {}
self.post: dict[str, dict[int, int]] = {}
self.total = 0
self.serial = 0
self.lock = threading.Lock()
def drop(self, path: str) -> None:
for cid in self.files.pop(path)[2]:
c = self.chunks.pop(cid)
self.total -= c.length
for t in c.tf:
p = self.post[t]
del p[cid]
if not p:
del self.post[t]
def add(self, path: str, key: tuple[int, int, int, int], digest: str, text: str) -> None:
ids = []
for c in chunk(path, text):
cid = self.serial
self.serial += 1
self.chunks[cid] = c
self.total += c.length
for t, n in c.tf.items():
self.post.setdefault(t, {})[cid] = n
ids.append(cid)
self.files[path] = (key, digest, ids)
def refresh(self) -> int:
"""Bring the index to the tree as it is now; the number of files read again."""
seen = walk(self.root)
for path in [p for p in self.files if p not in seen]:
self.drop(path)
read = 0
for path, st in seen.items():
key = (st.st_size, st.st_mtime_ns, st.st_ctime_ns, st.st_ino)
old = self.files.get(path)
if old and old[0] == key:
continue
try:
data = open(os.path.join(self.root, path), "rb").read()
except OSError:
if old:
self.drop(path)
continue
read += 1
if b"\0" in data[:8192]: # binary: kept by its stat alone, so it is not read again
if old:
self.drop(path)
self.files[path] = (key, "", [])
continue
digest = hashlib.sha256(data).hexdigest()
if old and old[1] == digest:
self.files[path] = (key, digest, old[2])
continue
if old:
self.drop(path)
self.add(path, key, digest, data.decode("utf-8", "replace"))
return read
def bm25(self, query: str) -> list[int]:
n = len(self.chunks)
if not n:
return []
avg = self.total / n
scores: dict[int, float] = {}
for t in set(tokens(query)):
p = self.post.get(t)
if not p:
continue
idf = math.log(1 + (n - len(p) + 0.5) / (len(p) + 0.5))
for cid, f in p.items():
norm = K1 * (1 - B + B * self.chunks[cid].length / avg)
scores[cid] = scores.get(cid, 0.0) + idf * f * (K1 + 1) / (f + norm)
return sorted(scores, key=lambda c: -scores[c])
def resolver(self) -> Callable[[str], str | None]:
"""A map from a path as a traceback or a message spells it to the one indexed file it names."""
names: dict[str, list[str]] = {}
for path in self.files:
names.setdefault(path.rsplit("/", 1)[-1], []).append(path)
def resolve(name: str) -> str | None:
name = name.replace("\\", "/")
if name.startswith("./"):
name = name[2:]
fits = [p for p in names.get(name.rsplit("/", 1)[-1], ()) if p == name or p.endswith("/" + name) or name.endswith("/" + p)]
return fits[0] if len(fits) == 1 else None
return resolve
def frames(self, query: str) -> list[int]:
"""Chunks holding the lines a traceback in the query names, the deepest frame first."""
resolve = self.resolver()
order: list[int] = []
for m in reversed(list(FRAME.finditer(query))):
name, line = (m.group(1), m.group(2)) if m.group(1) else (m.group(3), m.group(4))
path = resolve(name)
if path is None:
continue
ln = int(line)
for cid in self.files[path][2]:
c = self.chunks[cid]
if c.start <= ln <= c.end and cid not in order:
order.append(cid)
return order
def rank(self, query: str) -> list[Hit]:
"""Fused ranks; when the query names a test file it already holds, the code under test first."""
lists = [self.bm25(query), self.frames(query)]
if self.dense is not None:
lists.append(self.dense.rank(self, query))
fused: dict[int, float] = {}
for ranked in lists:
for r, cid in enumerate(ranked):
fused[cid] = fused.get(cid, 0.0) + 1.0 / (RRF + r + 1)
order = sorted(fused, key=lambda c: -fused[c])
resolve = self.resolver()
if any(tested(p) for p in filter(None, map(resolve, PATH.findall(query)))):
order = [c for c in order if not tested(self.chunks[c].path)] + [c for c in order if tested(self.chunks[c].path)]
return [Hit(self.chunks[c], fused[c]) for c in order]
def search(self, query: str, k: int = 5) -> list[Hit]:
with self.lock:
self.refresh()
return self.rank(query)[: max(1, k)]
def render(hits: list[Hit], cap: int = 6000) -> str:
"""Each hit as `path:start-end name` and its lines, all within cap characters."""
blocks: list[str] = []
used = 0
for h in hits:
c = h.chunk
lines = [ln if len(ln) <= WIDTH else ln[:WIDTH] + "…" for ln in c.text.splitlines()[:SPAN]]
block = f"{c.path}:{c.start}-{c.end}" + (f" {c.name}" if c.name else "") + "\n" + "\n".join(lines)
room = cap - used - 2 * len(blocks)
if room <= 0:
break
if len(block) > room:
if blocks and room < 400:
break
block = block[:room]
blocks.append(block)
used += len(block)
return "\n\n".join(blocks) if blocks else "no match"
_open: dict[str, Index] = {}
_guard = threading.Lock()
def index(root: str) -> Index:
"""The process's index of root, made on first use."""
real = os.path.realpath(root)
with _guard:
if real not in _open:
from hanzo_search.dense import load
_open[real] = Index(real, load())
return _open[real]
def search(query: str, root: str = "", k: int = 5) -> str:
"""Rendered top-k spans of root (default: the working directory) for query."""
if not query.strip():
return "empty query"
base = root or os.getcwd()
if not os.path.isdir(base):
return f"not a directory: {base}"
return render(index(base).search(query, k))
+76
View File
@@ -0,0 +1,76 @@
"""A stdio MCP server exposing `search` alone: `python -m hanzo_search.server --root DIR`.
Newline-delimited JSON-RPC 2.0 on stdin/stdout, standard library only, so it answers
`initialize` within a fraction of a second of starting; the index is built on the first call.
"""
from __future__ import annotations
import argparse
import json
import os
import sys
from hanzo_search import DESCRIPTION, SCHEMA
def answer(msg: dict, root: str) -> dict | None:
"""The response to one message, None for a notification."""
method = msg.get("method")
if "id" not in msg:
return None
rid = msg["id"]
params = msg.get("params") or {}
if method == "initialize":
result = {
"protocolVersion": params.get("protocolVersion", "2025-06-18"),
"capabilities": {"tools": {"listChanged": False}},
"serverInfo": {"name": "hanzo-search", "version": "1"},
}
elif method == "ping":
result = {}
elif method == "tools/list":
result = {"tools": [{"name": "search", "description": DESCRIPTION, "inputSchema": SCHEMA}]}
elif method == "tools/call":
if params.get("name") != "search":
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32602, "message": f"unknown tool: {params.get('name')}"}}
args = params.get("arguments") or {}
query = args.get("query")
if not isinstance(query, str):
return {"jsonrpc": "2.0", "id": rid, "result": {"content": [{"type": "text", "text": "query must be a string"}], "isError": True}}
base = args.get("root") or root
if not os.path.isabs(base):
base = os.path.join(root, base)
try:
from hanzo_search.index import search
text, failed = search(query, base, int(args.get("k") or 5)), False
except Exception as e: # a failed call is the tool's answer, never the server's exit
text, failed = f"{type(e).__name__}: {e}", True
result = {"content": [{"type": "text", "text": text}], "isError": failed}
else:
return {"jsonrpc": "2.0", "id": rid, "error": {"code": -32601, "message": f"method not found: {method}"}}
return {"jsonrpc": "2.0", "id": rid, "result": result}
def main() -> None:
ap = argparse.ArgumentParser(prog="python -m hanzo_search.server")
ap.add_argument("--root", default=os.getcwd(), help="tree to search (default: the working directory)")
root = os.path.realpath(ap.parse_args().root)
out = sys.stdout
for line in sys.stdin:
if not line.strip():
continue
try:
msg = json.loads(line)
except json.JSONDecodeError as e:
reply: dict | None = {"jsonrpc": "2.0", "id": None, "error": {"code": -32700, "message": str(e)}}
else:
reply = answer(msg, root) if isinstance(msg, dict) else {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "not a request"}}
if reply is not None:
out.write(json.dumps(reply) + "\n")
out.flush()
if __name__ == "__main__":
main()
+7 -1
View File
@@ -81,6 +81,12 @@ dependencies = [
# shell = ["hanzo-tools-shell>=0.1.0"]
# etc.
# Dense stage of the `search` tool (on when HANZO_SEARCH_MODEL names a local model)
search = [
"torch>=2.4",
"transformers>=4.51",
]
# Memory tools with full backend support
memory = [
"hanzo-tools-memory[full]>=0.2.2",
@@ -137,7 +143,7 @@ hanzo-mcp-dev = "hanzo_mcp.dev_server:run_dev_server"
[tool.setuptools.packages.find]
where = ["."]
include = ["hanzo_mcp*"]
include = ["hanzo_mcp*", "hanzo_search*"]
[tool.setuptools.package-data]
hanzo_mcp = ["py.typed"]
+304
View File
@@ -0,0 +1,304 @@
"""The `search` tool: tokens, chunks, ranking, freshness, rendering, schema, dense cache, stdio."""
import json
import os
import shutil
import subprocess
import sys
import time
import pytest
from hanzo_search import DESCRIPTION, SCHEMA
from hanzo_search import index as X
from hanzo_search.dense import Dense
PKG = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PRICE = '''"""Prices."""
import math
RATE = 0.2
def apply_discount(total, percent):
"""Take percent off total."""
return total * (1 - percent / 100)
class Invoice:
"""An invoice."""
currency = "usd"
def subtotal(self, lines):
return sum(q * p for q, p in lines)
@property
def taxRate(self):
return RATE
'''
PARSE = '''def parse_header(line):
key, _, value = line.partition(":")
return key.strip().lower(), value.strip()
def read_body(stream):
return stream.read()
'''
TEST = '''from shop.price import apply_discount
def test_apply_discount():
assert apply_discount(100, 10) == 90
'''
@pytest.fixture
def repo(tmp_path):
(tmp_path / "shop").mkdir()
(tmp_path / "shop" / "price.py").write_text(PRICE)
(tmp_path / "shop" / "parse.py").write_text(PARSE)
(tmp_path / "tests").mkdir()
(tmp_path / "tests" / "test_price.py").write_text(TEST)
(tmp_path / "README.md").write_text("# Shop\n\nPrices and invoices for the shop.\n")
(tmp_path / ".git").mkdir()
(tmp_path / ".git" / "config").write_text("apply_discount apply_discount")
(tmp_path / "node_modules").mkdir()
(tmp_path / "node_modules" / "x.js").write_text("apply_discount()")
(tmp_path / "blob.bin").write_bytes(b"apply_discount\0\0\0")
return tmp_path
def top(ix, query):
return [(h.chunk.path, h.chunk.name) for h in ix.search(query, 5)]
def test_tokens_split_identifiers_and_keep_them_whole():
t = X.tokens("parse_header(HTTPServer.getValue) x 42")
for want in ("parse_header", "parse", "header", "httpserver.getvalue", "httpserver", "http", "server", "getvalue", "get", "value"):
assert want in t, want
assert "x" not in t and "42" not in t
def test_python_chunks_follow_definitions():
chunks = X.chunk("shop/price.py", PRICE)
names = [(c.name, c.start, c.end) for c in chunks]
assert ("apply_discount", 7, 9) in names
assert ("Invoice.subtotal", 17, 18) in names
assert ("Invoice.taxRate", 20, 22) in names # the decorator starts the span
assert any(n == "Invoice" and s == 12 for n, s, _ in names) # the class header
assert any(n == "" and s == 1 for n, s, _ in names) # module code between definitions
def test_long_spans_and_other_files_are_overlapping_windows():
body = "\n".join(f"line {i}" for i in range(1, 101))
spans = [(c.start, c.end) for c in X.chunk("notes.txt", body)]
assert spans == [(1, 40), (31, 70), (61, 100)]
fn = "def big():\n" + "\n".join(f" x{i} = {i}" for i in range(60)) + "\n"
parts = [c for c in X.chunk("big.py", fn) if c.name == "big"]
assert [(c.start, c.end) for c in parts] == [(1, 40), (31, 61)]
assert all(c.end - c.start + 1 <= X.SPAN for c in parts)
def test_broken_python_still_indexes():
chunks = X.chunk("bad.py", "def f(:\n return 1\n")
assert len(chunks) == 1 and chunks[0].start == 1
def test_walk_skips_vcs_vendor_and_binaries(repo):
ix = X.Index(str(repo))
ix.refresh()
assert set(ix.files) == {"shop/price.py", "shop/parse.py", "tests/test_price.py", "README.md", "blob.bin"}
assert ix.files["blob.bin"][2] == []
def test_bm25_ranks_the_named_definition_first(repo):
ix = X.Index(str(repo))
assert top(ix, "where is the header of a line parsed")[0] == ("shop/parse.py", "parse_header")
assert top(ix, "subtotal of the invoice lines")[0] == ("shop/price.py", "Invoice.subtotal")
def test_a_traceback_names_its_span_and_the_code_under_test_comes_first(repo):
ix = X.Index(str(repo))
out = (
"tests/test_price.py:5: in test_apply_discount\n assert apply_discount(100, 10) == 90\n"
"shop/price.py:9: in apply_discount\nE AssertionError\n"
"FAILED tests/test_price.py::test_apply_discount - AssertionError"
)
assert top(ix, out)[0] == ("shop/price.py", "apply_discount")
ranked = [h.chunk for h in ix.rank(out)]
first = max(i for i, c in enumerate(ranked) if not X.tested(c.path))
assert all(X.tested(c.path) for c in ranked[first + 1 :]) and X.tested(ranked[-1].path)
# a question that names no test file ranks tests by relevance alone
assert top(ix, "test apply discount 100 10 90")[0] == ("tests/test_price.py", "test_apply_discount")
def test_freshness_create_modify_delete_and_replace(repo):
ix = X.Index(str(repo))
assert all(p != "shop/new.py" for p, _ in top(ix, "frobnicate widget"))
(repo / "shop" / "new.py").write_text("def frobnicate_widget():\n return 1\n")
assert top(ix, "frobnicate widget")[0] == ("shop/new.py", "frobnicate_widget")
# rewritten: same size, mtime put back; the ctime still moves
p = repo / "shop" / "parse.py"
st = p.stat()
p.write_text(PARSE.replace("read_body", "zapx_body"))
os.utime(p, ns=(st.st_atime_ns, st.st_mtime_ns))
assert top(ix, "zapx body")[0] == ("shop/parse.py", "zapx_body")
assert all(c.name != "read_body" for c in ix.chunks.values())
(repo / "shop" / "new.py").unlink()
ix.search("x", 1)
assert "shop/new.py" not in ix.files and all(c.path != "shop/new.py" for c in ix.chunks.values())
# a whole directory swapped for another with the same names and new contents
shutil.rmtree(repo / "shop")
(repo / "shop").mkdir()
(repo / "shop" / "price.py").write_text("def quux_total():\n return 2\n")
hits = top(ix, "quux total")
assert hits[0] == ("shop/price.py", "quux_total")
assert set(ix.files) == {"shop/price.py", "tests/test_price.py", "README.md", "blob.bin"}
assert not any(t in ix.post for t in ("subtotal", "parse_header", "zapx_body"))
assert sum(c.length for c in ix.chunks.values()) == ix.total
def test_unchanged_files_are_not_read_again(repo):
ix = X.Index(str(repo))
assert ix.refresh() == 5 # blob.bin is read once, found binary, and kept by its stat
assert ix.refresh() == 0
os.utime(repo / "README.md") # touched, same bytes: read, not re-chunked
before = set(ix.chunks)
assert ix.refresh() == 1 and set(ix.chunks) == before
def test_render_caps_hits_lines_and_characters(tmp_path):
for i in range(12):
body = "\n".join(f" v{j} = 'needle {i} {j}' + '{'w' * 400}'" for j in range(80))
(tmp_path / f"m{i}.py").write_text(f"def needle_{i}():\n{body}\n")
out = X.search("needle", str(tmp_path), k=50)
assert len(out) <= 6000
blocks = out.split("\n\n")
assert all(len(b.splitlines()) <= X.SPAN + 1 for b in blocks)
assert all(len(line) <= X.WIDTH + 1 for b in blocks for line in b.splitlines()[1:])
head = blocks[0].splitlines()[0]
path, _, rest = head.partition(":")
assert path.startswith("m") and rest.split()[0].count("-") == 1
assert len(X.search("needle", str(tmp_path), k=3).split("\n\n")) <= 3
assert X.search("zzzqqq", str(tmp_path)) == "no match"
assert X.search(" ", str(tmp_path)) == "empty query"
assert X.search("x", str(tmp_path / "missing")).startswith("not a directory")
def test_schema_and_description_stay_small():
tokens = len(json.dumps({"name": "search", "description": DESCRIPTION, "inputSchema": SCHEMA})) / 4
assert tokens < 250, tokens
def test_fastmcp_registration_is_the_same_small_tool():
from mcp.server.fastmcp import FastMCP
from hanzo_search import register
mcp = FastMCP("t")
register(mcp)
tool = mcp._tool_manager.get_tool("search")
assert tool.description == DESCRIPTION
assert set(tool.parameters["properties"]) == {"query", "root", "k"}
assert tool.parameters["required"] == ["query"]
assert len(json.dumps({"d": tool.description, "s": tool.parameters})) / 4 < 250
def test_dense_vectors_are_kept_by_content_across_trees(repo, tmp_path_factory):
import numpy as np
calls = []
def embed(texts):
calls.append(len(texts))
out = np.zeros((len(texts), 8), np.float32)
for i, t in enumerate(texts):
out[i, hash(t) % 8] = 1.0
return out
db = str(tmp_path_factory.mktemp("cache") / "m.sqlite")
ix = X.Index(str(repo), Dense(embed, db))
ix.search("invoice", 3)
first = sum(calls) - 1 # less the query
assert first == len(ix.chunks)
copy = tmp_path_factory.mktemp("copy") / "t"
shutil.copytree(repo, copy)
calls.clear()
other = X.Index(str(copy), Dense(embed, db)) # a fresh process: memory empty, the file kept
other.search("invoice", 3)
assert calls == [1] # the query alone
(copy / "shop" / "parse.py").write_text(PARSE + "\n\ndef added():\n return 3\n")
calls.clear()
other.search("invoice", 3)
assert sum(calls) == 1 + 1 # the one new chunk, then the query
def rpc(proc, msg):
proc.stdin.write(json.dumps(msg) + "\n")
proc.stdin.flush()
if "id" not in msg:
return None
return json.loads(proc.stdout.readline())
def test_stdio_server_round_trip(repo, tmp_path):
home = tmp_path / "home"
home.mkdir()
env = {"PATH": os.environ["PATH"], "HOME": str(home), "PYTHONPATH": PKG}
t = time.perf_counter()
proc = subprocess.Popen(
[sys.executable, "-m", "hanzo_search.server", "--root", str(repo)],
stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True, cwd=str(repo.parent), env=env,
)
try:
init = rpc(proc, {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"protocolVersion": "2025-06-18", "capabilities": {}, "clientInfo": {"name": "t", "version": "1"}}})
assert time.perf_counter() - t < 1.0
assert init["result"]["protocolVersion"] == "2025-06-18" and "tools" in init["result"]["capabilities"]
rpc(proc, {"jsonrpc": "2.0", "method": "notifications/initialized"})
tools = rpc(proc, {"jsonrpc": "2.0", "id": 2, "method": "tools/list"})["result"]["tools"]
assert [x["name"] for x in tools] == ["search"]
call = rpc(proc, {"jsonrpc": "2.0", "id": 3, "method": "tools/call", "params": {"name": "search", "arguments": {"query": "parse a header line", "k": 2}}})
text = call["result"]["content"][0]["text"]
assert call["result"]["isError"] is False and text.startswith("shop/parse.py:1-3 parse_header")
bad = rpc(proc, {"jsonrpc": "2.0", "id": 4, "method": "tools/call", "params": {"name": "search", "arguments": {}}})
assert bad["result"]["isError"] is True
assert rpc(proc, {"jsonrpc": "2.0", "id": 5, "method": "nope"})["error"]["code"] == -32601
assert rpc(proc, {"jsonrpc": "2.0", "id": 6, "method": "ping"})["result"] == {}
finally:
proc.stdin.close()
proc.wait(timeout=5)
assert proc.returncode == 0
def test_the_default_mode_carries_search_and_the_user_can_turn_it_off(monkeypatch, tmp_path):
import asyncio
from mcp.server.fastmcp import FastMCP
from hanzo_mcp.tools import register_all_tools
from hanzo_mcp.tools.common.mode_loader import ModeLoader
from hanzo_mcp.tools.common.permissions import PermissionManager
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.delenv("HANZO_MCP_ENABLED_TOOLS", raising=False)
monkeypatch.delenv("HANZO_MCP_MODE", raising=False)
ModeLoader.initialize_modes()
def names():
server = FastMCP("s")
register_all_tools(server, PermissionManager())
return [t.name for t in asyncio.run(server.list_tools())]
assert names().count("search") == 1
(tmp_path / ".hanzo" / "mcp").mkdir(parents=True)
(tmp_path / ".hanzo" / "mcp" / "tool_states.json").write_text('{"search": false}')
assert "search" not in names()