A desktop app starts hanzo-mcp with no environment of its own, and `hanzo login` never writes an apiKey into ~/.hanzo/config.json, so on a fresh machine every Kai and Enso call answered NO_API_KEY. With no key set, hanzo-mcp now asks the hanzo CLI beside it (or at hanzo.sh's install path) for the login's token, `hanzo auth token`, and keeps it for five minutes. Never a hanzo found on PATH: the Hanzo IDE package installs /usr/bin/hanzo, which opens its window on any arguments. Claude-Session: https://claude.ai/code/session_01M8RoubcPN71KgJuZ8LdBUL
40 KiB
LLM.md — hanzoai/mcp
Guidance for AI agents working in this repo.
What this is
The canonical Model Context Protocol server for the Hanzo AI Cloud. Collapses a
260+ tool catalog into 13 HIP-0300 action-routed tools (fs, exec, code,
git, fetch, workspace, ui + optional think, memory, hanzo, plan,
tasks, mode) over MCP stdio / streamable-http. TypeScript today; Rust runtime
for latency-sensitive ops; Go runtime under hanzoai/cloud (HIP-0106 in flight).
tracker_* — work items, and why they are not tasks
tracker_boards, tracker_issues, tracker_create, tracker_update
(src/tools/tracker.ts) are the Hanzo Cloud /v1/tracker surface: the ONE
work-item primitive, the board a human actually looks at. They are how an agent
reports its own progress somewhere visible.
Three planes are easy to braid, and cloud apps/tracker/contract.go is law about
it:
| plane | what it is | here |
|---|---|---|
| tracker Issue | engineering WORK ITEM on a board | tracker_* |
| hanzoai/tasks | durable ASYNC EXECUTION (Temporal fork) | not exposed |
tasks tool |
a private todo file at ~/.hanzo/todos.json |
src/tools/tasks.ts |
So the local tasks tool is NOT the board — it never leaves the machine. Naming
these tasks_* would have collided with it and pointed callers at the one plane
no human can see.
An agent's run is a session (/v1/agents/sessions), never a "mission" —
nothing in the fleet models that word. Pass session to tracker_create /
tracker_update and the tool writes the anchor session:<id> into extRef,
which the tracker contract defines as "a link INTO another plane". hanzo.app's
board renders that session's live status on the row.
tracker_create defaults source to agent, because the board's "an agent's
work" filter (?source=agent) is only true if agents say so.
kai_* — decisions
kai_decide, kai_choice, kai_score, kai_noul (src/tools/kai.ts) ask Kai,
Hanzo's decision model, at POST /v1/decisions on API_URL or
https://api.hanzo.ai, with the bearer from HANZO_API_KEY (else API_KEY,
API_TOKEN, HANZO_TOKEN), as the tracker tools do. kai_decide sends a whole
request (state, named questions, model defaulting to kai) and returns the
decision as served; the other three send one question of their type, named by the
type, and return {answer, id, model, usage}. They check the shape the frozen
wire contract fixes: state is text, an object or an array; instructions is
optional on every question and, when given, the same; 1 to 100 questions; a
choice names at least 2 labels (a repeated list label is one); a score lists at
least 1 level, none null; a noul's sides are true and false only.
/v1/decisions caps no label or level count (a wide choice is narrowed by
retrieval), so neither do the tools; only /v1/systemone caps them, at Jev's 255
and 10. Whether a request fits the token budget is the server's to say (422
state_too_long). A plan refusal is the error result llm returns (below); any
other non-2xx returns <status>: <the server's sentence> (decision's
error.message, the gateway's msg).
The descriptions call instructions recommended and steer a noul to a statement
with both sides described, or to a choice whose labels name the outcomes (not
yes and no), because Kai reads a bare question-form yes/no poorly. They say to act on a score's argmax: score is the
mean level index, and confidence describes the likeliest level. A noul's
confidence is |2p − 1|.
Tools that run a whole decision program arrive with Kai's joint decoder.
The Rust runtime carries kai_decide (rust/src/tools/kai_tool.rs); the other
three, like tracker_*, are TypeScript only, and Python hanzo-mcp carries none.
The Rust runtime's key (rust/src/hanzo_api.rs): HANZO_API_KEY, else
~/.hanzo/config.json apiKey, else the person's hanzo login, read as
hanzo auth token from the hanzo beside the binary or at ~/.local/bin/hanzo
and kept five minutes. Never a hanzo off PATH: Debian's hanzo-ide package
installs a GUI at /usr/bin/hanzo. A desktop app's MCP entry therefore needs no
env block once the person has signed in.
Native search, the exec verdict, and a lean surface (Rust)
Dev, Claude Code and Codex reach one implementation of each, in rust/: Dev links it as a library (a call, no MCP
hop), Claude Code and Codex call the same code over MCP.
Search is rust/trigram (crate hanzo-trigram, re-exported as hanzo_mcp::trigram), the engine behind
fs search_text and code search_symbol. It is tgrep-core (the hanzoai fork of Microsoft's tgrep, MIT,
~/work/hanzoai/tgrep) linked in process and pinned by exact commit, with its docs feature. No rg is spawned.
.gitignore, hidden files and binary extensions are skipped as rg skips them; a file past 16 MiB is not read.
- Hits are ranked by file (matching lines; for a symbol, definitions x 2^32 + references, so a defining file precedes
every using file), then path, then (inside a file) definitions before uses, then line. A page is an offset into that one list, so
cursorreally pages. (Before,rgstopped atlimitand a cursor repeated page one.) - A symbol is an identifier on word boundaries; a hit is
definitionwhen the line reads like a declaration of it (fn x,def x,class x,const x,func (r) x,x = function,#define x), elsereference. This is textual: it can call a comment a reference.code search_symbolreads code files unlesspatternnames others. - A glob that names a document type (
*.pdf,*.docx,*.xlsx,*.zip,*.tar.gz) makessearch_textread documents under the tree; a document named asuriis always read. A hit inside one addsentry(archive members, joined by!) andplace(page 2,slide 3,para 5,sheet Data!B4), and itslinecounts from the start of that part. Plain hits carry neither key. - A
limitbounds the work, not just the list: every file is weighed first (matching lines counted, no hit held), then hits are read from the heaviest files until the limit is met, and the answer is the head of the unlimited list. A pattern that matches 1.28 million lines of a 112 MB tree answerslimit=50in 0.14 s; unlimited it takes 15 s and holds them all, which is why the tools always pass a limit. - The index (
hanzo-mcp index [DIR], ortgrep index, orhanzo_trigram::index::build) writes tgrep's trigram index toDIR/.tgrep(about two thirds the size of the source; add it to your ignore file). With one at or above the searched directory, a search asks it which files can match and reads only those. The answer is the scan's: candidates are matched against their current bytes, and every file whose content or name changed at or after the build is read from disk whether the index lists it or not, found by a walk of names and metadata (no watcher, so no staleness and no free lunch: that walk is what the index costs).Index::Onlyrefuses to scan, which is how the tests prove the index answered; they hold index, scan and default equal over 18 queries, before and after edits, creates, deletes and renames. Measured (112 MB, 9,026 files, cores 0-4, medians):rg43-55 ms including its spawn, scan 75-93 ms, indexed 28-50 ms. On a small tree (60 to 80 files) the library call is 2.5-5.4 ms againstrg's 5.3-6.1, the difference being the spawn. A tgrep server with a watcher would drop the walk; attaching to one is not built. - Names, schemas and answer shapes of
fsandcodeare unchanged. - Dev:
hanzo_trigram::search(&Query::symbol(root, "Parser")). A dependency on it bringslibsqlite3-sys(tgrep's managed-index code), so a crate that links another SQLite patchesrusqliteashanzoai/devdoes ([patch."https://github.com/rusqlite/rusqlite.git"] rusqlite = "=0.39.0"). In this crate it forcesccpast what the tree-sitter grammars allow, so Cargo holdstree-sitter-{c,cpp,javascript,python,rust,typescript}at their previous patch releases. Gatingmanaged/behind a feature in the fork removes both.
exec and the classifier. exec asks a Classify (rust/src/tools/verdict.rs: command and cwd in, a Verdict of
allow | ask | deny with reasons out) before it runs a command, and the classifier is installed: hanzo-shell
(rust/shell, pure Rust, no C, no tree-sitter), behind tools::Shell, which ToolRegistry::with_defaults installs.
- Policy (
refusal(verdict, approved)is the one place): adenyis refused andapprove: truenever lifts it; anaskis refused ("approval required: ; … run the call again withapprove: true") until the call carriesapprove: true, which the client's own permission prompt then shows beside the command; anallowruns. Every answer carriesverdict(approved: trueon an approved ask).approveis a flag the model sets, not a credential: it moves the decision to the client's prompt and nowhere else. - The shell and the environment are part of the command.
execruns only throughsh bash dash zsh ksh ash(any othershellis adeny, since the classifier reads shell), andenvis read beside the text (Classify::classify_env:BASH_ENV,ZDOTDIR,LD_PRELOAD,NODE_OPTIONS=--require …, aPATHwith scratch space in it are anask).git(GitTool::classified) asks the same classifier about every git it runs, refuses adenyand anaskthat makes git run a program, and always runs withcore.fsmonitor=false;ui installno longer passes the component name tosh -c. Other spawners (agent,lspinstalls,browser,fetch) do not route through the seam. - What it reads. A bash grammar (brush-parser 0.4.0, MIT, PEG) gives the AST: pipelines,
&& || ;, subshells,$(…)and backquotes, loops,if/case, functions, here-strings, here-documents (a body with no end runs to the end of input), arithmetic, process substitution, brace expansion,$'…',${IFS}, parameter defaults and patterns that hold commands. Variables are followed (a name set on one path only is, on the other, empty:rm -rf $d/),cdmoves the directory,$!is a job this script started. Bodies run by an interpreter are read in turn:bash -c,shfed by a here-document, a here-string or a pipe whose text is known (echo '…' | sh),eval,trap,xargs,find -exec,ssh host '…'(as a remote, with no workspace), a script the command wrote and runs (cat > s.sh <<EOF … EOF; bash s.sh), and a script on disk (Files). Python (python -c,-, heredoc, a.py) is parsed by rustpython-parser 0.4.0 (MIT, pure Rust, pinned; it fails closed on PEP 701 f-strings and 3.14 syntax):subprocess/os.systemwith a known command are classified as shell,open(…, 'w'),shutil.rmtree,Path.write_text/unlink, sockets andeval/execby their path or reach.node -e,perl -e,ruby -e,php -r,lua -eget a token scan (inline.rs): fine only when none of the words that reach out of the process appears. - The tiers.
allow: reads, and builds and tests in the workspace (the directory the command runs in, plus--project-dirandHANZO_MCP_ROOTS;/tmpand friends are scratch).ask: writes outside the workspace, network writes (curl -d,git push,kubectl apply), publishing (npm/cargo publish,docker push), code it cannot read (curl … | sh,node -ethat useschild_process), unknown commands.deny:rm -routside the workspace or of/,~,.,*;dd/mkfson devices;git reset --hard,git clean -f,git checkout .;terraform apply/destroy;kubectl/helm delete;aws … rm/delete/terminate;gh pr merge;sudoand kin (also behindenv,timeout,nice,nohup,flock,chroot,xargs,watch); reads of.env*, ssh/gpg keys,~/.aws,~/.kube, KMS secret values; sending any of them anywhere; a force push to a protected ref.find -deleteandfind -exec rmask inside the workspace and deny outside it. - Measured on 408,231 Bash commands of the owner's transcripts (
~/scratch/decision/done/traces, 3,959 sessions; each run with its own recorded cwd), against kai-gatewaysrc/safety.tson the same commands: safety.ts flags 20.69% (15.27%unparsed); the classifier answers allow 74.1% / ask 22.9% / deny 3.0% with the workspace the cwd alone, 88.2% / 9.6% / 2.2% with~/work,~/.cacheand~/scratchas roots, and cannot read 0.22% (0.018% are shell that does not parse; the rest are Python it cannot read and runtime-built commands). Parse and classify: p50 63 µs, p99 0.6 ms, p99.9 1.6 ms, max 25 ms; the cache (Classifier, keyed by the whole command and context, never by a hash) answers a repeat without parsing, and a command that read a script from disk is not cached. - wasm.
cargo build -p hanzo-shell --target wasm32-unknown-unknown --release --features wasm, thenwasm-bindgen --target nodejs|web --out-dir pkg target/wasm32-unknown-unknown/release/hanzo_shell.wasm(3.9 MB);classify(command, '{"cwd","home","roots","temp","remote"}')returns{"risk","findings":[{"risk","kind","reason"}]};rust/shell/tests/wasm.mjsis the smoke test. kai-gateway can replacesafety.tswith it; its categories map fromkind(Kind::category). - Does not read: symlinks (paths are read as text; the workspace root is canonicalized), what an installed program does beyond its
name and arguments, JavaScript/other-language files (judged by where they are), values that only the running shell knows,
zsh-only syntax beyond glob qualifiers and parameter flags (asked about).HANZO_MCP_FORCE_SHELLis the server's own choice.
The surface a client is charged for. Every schema in tools/list is paid for each turn. The whole Rust list is 34
tools and 38 KB (about 9.5K tokens); --tools fs,code,exec lists and serves only those, 4.6 KB (about 1.1K tokens); with kai 6.5 KB (about 1.6K).
A name the server does not carry fails at startup; a call outside the list answers Unknown tool. The list is in one
order on every start (built-ins in fixed order, registered tools by name; it was HashMap order, different in every
process, which no prompt cache survives). rust/tests/test_tool_surface.rs holds the order and keeps fs, code, exec and
kai_decide under 16 KB.
Turning it on.
cargo build --release --locked --manifest-path rust/Cargo.toml --bin hanzo-mcp # or the release artifact
claude mcp add hanzo -- hanzo __mcp -- hanzo-mcp --tools fs,code,exec,kai
codex mcp add hanzo -- hanzo __mcp -- hanzo-mcp --tools fs,code,exec,kai
hanzo __mcp -- <server> [args] is the launcher that answers the handshake at once; hanzo-mcp --tools ... alone works
the same without it. Dev links crates instead: hanzo-trigram for search, hanzo-actions for Kai Actions, hanzo-mcp for ExecTool.
kai — actions, and why the executor is Rust
kai (schema schema/kai.json, the same text in rust/schema/kai.json and src/tools/kai-action.ts;
test/tools/kai-action.test.ts and the Rust test fail if the three differ) runs a request as a saved, typed, verified
action. The executor is the Rust crate hanzo-actions (rust/actions, re-exported as hanzo_mcp::actions; tool
rust/src/tools/actions_tool.rs, CLI hanzo-mcp kai run|plan|list|triggers). It is its own light crate so Dev can link
it without this crate's browser, desktop and parser dependencies; Claude Code reaches it over MCP, and the
TypeScript tool only carries the schema and runs the native binary named by HANZO_MCP_NATIVE. One implementation,
three doors. A workflow is a plan of several actions; the cache holds proofs and reusable results.
- Kai never writes a command.
performmatches a request to the actions and workflows whose triggers fit (bind::triggered), binds their inputs from the request and from values read out of the repository (bind::plan), and asks Kai (ask::Kai, the Decisions API; anyask::Chooser) only when several actions or values remain. Kai's choice includes anabstainoption; a pick is taken when Kai's abstain mass is at mostmax_abstainand its lead over the next real option at leastmin_margin, and it must be grounded in the request. Exactly one action with every input named or single-valued runs with no Kai request (source: rule). - Declined means the caller's own agent. No match, a part of the request no action serves, a missing or ambiguous
input, a failed precondition, an action without a proof here, a workspace that moved, an option-like value (starts with a
dash), a request that asks for something to be written (
bind::authoring), approval missing, Kai needed and not reachable. - Authorization is not a match.
Effectis read, write, outside, remote or destroy. The last three needapprove: trueon the call (run::Grant; the defaultrun::Refusedeclines with the reason), so the client's own approval flow decides, and each record says whether one was needed and given. - Cache. A proof (action, version, tools) must be recorded in
cachebefore an action runs on its own (prove: truein a proving workspace); a memo of a pure action is keyed by what it declares it reads, uncommitted edits included, and recomputed just before running. Records carry a stable id, both revisions, the preconditions as checked, the authorization, the asks to Kai, the steps with milliseconds and the verifier's verdict; credentials are redacted (actions::redact) and a child process gets no variable named KEY, TOKEN, SECRET, PASSWORD, CREDENTIAL or AUTH. - Shipped. find, slice, touched, ahead, changed, worktree, publish, sync, suite, rebuild, wait, listen, stop, pairs,
note, stage; workflows isolate (worktree, then suite there) and ship (suite, then publish only if none failed).
hanzoai/kaibench/choresmeasures it against native Claude Code on the same tasks. - The module began as a Dev crate (
hanzoai/devbranchactions, commits c8707d8087a to 73683458037) and moved here;reqwestgained itsblockingfeature, so the tool runs the executor underspawn_blocking.
llm — models, limits, who paid
llm (src/tools/llm.ts, Rust rust/src/tools/llm_tool.rs) is the AI plane
by action over api.hanzo.ai, with the same bearer as kai_*. Both runtimes
answer the four actions below the same way; Rust also keeps consensus and
list, which TypeScript serves from think:
| action | route | answers |
|---|---|---|
query |
POST /v1/chat/completions |
the completion; model (X-Routed-Model, else the body's), served (X-Hanzo-Served), paid_by (X-Hanzo-Paid-By), usage_state, usage_class, fallback, fallback_reason; a header not sent is null |
models |
GET /v1/models |
id, family, class, outputs, context_window, supports, per-million prices, variable; search, class, family, capability narrow |
limits |
GET /v1/ai/limits |
plan, state, period, per-class percent/state/paying/resets_at, session and day windows, limited, paused, actions, upgrade, credits_after_allowance |
feedback |
POST /v1/ai/feedback |
what Enso recorded |
model picks; default enso-auto, auto is Enso's pick. fallback: true sends
X-Hanzo-Fallback: allow, so a refused model's fallback answers instead and the
result names it. capability is tools, vision, reasoning (the catalog's
supports_*) or a modality the model takes or makes (image, embeddings,
decision, ...). variable marks a router SKU billed at the answering model's
cost, whose listed price is not what a call costs.
Why llm and not hanzo: hanzo is generated from the catalog and holds no
hand-written route. Limits are per model class and say which models are paused
and what answers for them, so they sit beside models and query: pick a model,
see where the plan stands, ask, see who paid. Repositories add nothing to the
fleet's own operation, so they stay on hanzo: resource=sync action=get_sync
lists the links, action=post_sync_by_id_run args={id} syncs one now.
Decisions stay with kai_decide. A decide action on llm would be a second
way in to /v1/decisions.
No figures. limits copies named fields only (percent, state, paying,
resets_at, actions), so an amount, count or cap in an answer never reaches a
result. PUT /v1/ai/limits is not offered here.
Refusals (src/tools/refusal.ts, Rust hanzo_api::refusal; shared by llm
and kai_*): a 402 or 429
whose code is plan_allowance_used, paid_plan_required, free_plan_cap,
model_cap, usage_cap_exceeded or insufficient_balance is the error result
{"error": {status, code, message, class?, model?, fallback?, window?, resets_at?, actions}}. Clients switch on the code (hanzoai/ai
object.LimitHit). The message is the server's own sentence, which names no
figure. Actions keep kind, label, url, plan and model. limit, the name of the
spent window, is passed on as window. The gate's {error: {...}} and a
controller's {status: "error", msg, code} read the same. Any other non-2xx
stays <status>: <sentence> (in Rust, the UPSTREAM envelope).
research — one door, one mode
research (Rust rust/src/tools/cloud_web.rs) is POST /v1/ask with
mode: "research" and Accept: text/event-stream. Deep research is a MODE of
that one endpoint, never a second route, and the plan → search → read → rank →
synthesize → cite loop runs server-side where it is bounded and billed — so the
tool reads a stream and folds it, it does not re-implement the loop.
The frames are the @hanzo/ai SearchEvent union verbatim —
status | sources | text | follow_ups | done | error, data-only JSON that
self-describes via type. Two rules the wire depends on:
- the terminal
data: [DONE]is an OpenAI-convention marker, NOT an event deepisresearch's retired name; the server resolves it, and so do we
web_search, web_read and research are the same web capability at three
depths (a snippet, a page, a report), so ToolsConfig::web_search governs all
three — research is not a second flag to toggle.
Contracts: cloud apps/answer/{mode,stream}.go, SDK hanzo-js/ai/src/search.ts.
lsp — one tool, two planes
lsp (Rust rust/src/tools/lsp_tool.rs, Python
python-sdk/pkg/hanzo-tools-lsp) answers the same questions from either a
language server on the local tree or the indexed corpus behind
/v1/code/lsp. file names the file; repo (a git.hanzo.ai slug, with
optional rev) is what picks the plane — cloud when present, local
otherwise. It is one tool, not two: a local server cannot see a dependency it
has no source for, and the cloud index cannot edit your working tree.
Actions map onto /v1/code/lsp ops. locate is one op carrying a relation,
because "where is X" is one question with four answers, not four routes:
| action | op | relation |
|---|---|---|
definition |
/v1/code/lsp/locate |
definition |
references |
/v1/code/lsp/locate |
reference |
type |
/v1/code/lsp/locate |
type |
implementation |
/v1/code/lsp/locate |
implementation |
hover |
/v1/code/lsp/hover |
— |
symbols |
/v1/code/lsp/symbols |
— |
diagnostics |
/v1/code/lsp/diagnostics |
— |
completion |
/v1/code/lsp/complete |
— |
Body: {repo, rev?, path, line, character, relation?}. The wire is LSP's own
frame — 0-based line, 0-based UTF-16 character — while the tool's line
stays 1-based for callers, so both planes shift it at the same boundary.
rename, code_action, organize_imports and status need a working tree
and say so rather than calling out; type, implementation and symbols are
the index's to answer and say so rather than spawning a server.
LSP lives UNDER /v1/code beside search, context, ask, index — one
home for code intelligence.
iam — how identity is addressed
IAM's CRUD lives under /v1/iam/ and nowhere else. A row is a path, not a query:
/v1/iam/{plural}/{owner}/{name} for users, organizations, roles, applications,
providers, permissions, invitations and tokens; sessions carry the application
too (/v1/iam/sessions/{owner}/{name}/{application}). Audit rows are
/v1/iam/audit-logs.
A list GET answers one object keyed by the plural ({"users":[…],"total":N}); an
item GET answers the bare record, except providers, which wrap
({"provider":{…}}). A refusal is RFC 9457 problem+json and absence is 404 —
so a non-2xx answer is an error to raise, never a payload to read. The
{status,msg,data} envelope survives on /v1/iam/account, /v1/iam/memberships,
/v1/iam/keys/{principal,org} and POST /v1/iam/delete-membership. Write bodies
are the flat row, except users: {"user":{…},"password":"…"}.
owner is scope, not a constant. Send it only when the caller names one and let
the server resolve the rest from the credential — a hardcoded org answers 403 to
everyone outside it, and IAM's capability allowlists decide the rest.
/v1/iam/applications is the one list that requires owner; ask for it.
Two callers: src/tools/cloud.ts (the generated iam subsystem tool, which auth folded into) and
rust/src/tools/hanzo_tool.rs (the hanzo router's iam service). Identity
questions belong to iam alone — platform answers for deployments.
The cloud surface is GENERATED, and it is not written here
src/tools/cloud.ts offers the fleet the way the fleet offers itself: one tool
per subsystem carrying that subsystem's operation names in an enum, plus
describe, which answers one operation's prose and schema. The names come from
src/tools/catalog.json, generated out of cloud's own typed operations
(plugin/gen-mcp-catalog). Nothing in it is hand-written, so this client cannot
come to disagree with the API about what exists.
It replaced 1,051 hand-written lines offering seven resources — iam, kms,
paas, commerce, storage, auth, api — against a fleet serving 114
subsystems. Two of those names had already moved (paas is platform, storage
is s3) and auth had folded into iam, which alone carries 97 operations
where the hand-written tool carried a handful. It also dialled four hosts
(api. / iam. / kms. / platform.) for one API; every call now goes to
api.hanzo.ai.
It is a catalog and not a fetch because a tool list is assembled synchronously, before any request has been made, and a client that needs the network to say what it can do has nothing to say when the network is what failed.
Refresh it FROM CLOUD, which is where the operations are declared:
make -f mk/fleet.mk mcp in hanzoai/cloud writes fleet/mcp.json and every
sibling checkout's copy in one pass — this file and python-sdk's. A sibling that
is not checked out beside cloud is skipped, never created. It REFUSES to write a
smaller catalog than it replaces without --shrink: a fleet answering partially
and a fleet that lost capabilities look identical, and the quiet direction of that
mistake is a client that stops offering operations the API still serves.
There was a pnpm sync:catalog here doing the same job in JavaScript. Two
generators for one projection is how they come to disagree about the rule, and
the rule is cloud's — so the script is deleted and the Go one writes every copy.
The withholding rule is applied ONCE, in cloud. The endpoint keeps an
operation off the agent surface when its name discloses a bearer secret at any
verb, or when a mutating verb acts on identity or authority — 124 of 1,540 here.
The generator asks fleet.Withheld, the same predicate the endpoint asks, rather
than carrying a second copy of the words: a client deriving its set from the raw
catalog offers what the fleet refuses, so the policy would hold on one transport
and not on the other, and the half left unenforced is the one where an agent is
already holding the tool. get_iam_users survives and post_iam_users does not,
which is the rule's own distinction — knowing who holds a role is not granting
one.
The fleet does not grow the default surface. getConfiguredTools({}) is 26 tools;
the fleet sits behind hanzo, whose resource enum is derived from the catalog
rather than listed, so a subsystem the fleet gains is reachable the day it is
generated. The 114 individual tools appear only on the legacy branch.
A tools/call answer IS a tool result and is returned as one. Re-wrapping it
buries the fleet's own isError inside a body that reads as a success — a
refusal a client cannot see is worse than no answer, because it is acted on.
The Rust runtime reads the SAME file (rust/src/catalog.rs,
include_str!("../../src/tools/catalog.json")), which is what makes "the runtimes
mirror one-to-one" true rather than aspirational. Its hanzo tool went from 1,017
lines to 460: nine hand-written service routers over four hosts became one
dispatch to api.hanzo.ai/v1/mcp, and its SERVICES list became a read of the
catalog. Two things that list had were worse than stale — it named paas and
commerce, which the fleet no longer serves, and base_url("commerce") was
https://api.hanzo.ai/api/v1, an /api/ prefix this estate does not serve.
The aliases are gone, not renamed. platform -> paas, identity -> iam,
payments -> billing, store -> commerce pointed at the OLD names, so three of
the four mapped a caller off the surface the fleet serves. resolve_service
normalises spelling and renames nothing; one thing has one name.
browser and cdp — the user's browser over ZAP
The browser is reached the way Python hanzo-mcp reaches it: through this
login's ZAP router (zapd, HIP-0069). An fcntl lock on
$XDG_RUNTIME_DIR/zap/zapd.lock elects one process as router; it owns
zapd.sock and the loopback door (9998, else 21000-21007) that admits the
Hanzo extension's Blink origin without pairing. The extension registers as
browser/<host>/<engine>-<id>; a command is one ROUTE (method + string
params, the extension's decodeCmd body) answered by one RESPONSE (JSON, or
ERR:<why>).
- Rust (
rust/src/zap.rs) linkszapd1.1.9 from crates.io, which runs on Windows too: there the door for local nodes is a named pipe and the electionLockFileEx(zapd'ssrc/os/windows.rs).maincallszap::seat(): it stands for router and joins asmcp/hanzo-<pid>, as Python does.hanzo-mcp pair [--reset]prints the door's code for a browser that must pair. - Native host (
rust/src/native.rs): the same binary run ashanzo-zap-hostis Chrome/Firefox native messaging hostai.hanzo.zap, relaying{"z": base64 envelope}messages to the socket, as Python'snative_host.pydoes. Each start registers it (a symlink at~/.hanzo/zap/hanzo-zap-host) with every installed browser, but never over a manifest whose program exists: Python's host serves the same extension, and two runtimes rewriting one file would trade it forever. On Windows the host is a hard linkhanzo-zap-host.exe, each browser finds its manifest through itsHKCU\Software\…\NativeMessagingHosts\ai.hanzo.zapkey (set withreg.exe), and the relay opens the router's pipe and refuses it unless its server iszapd::holder(). That Windows path is compiled in CI, not run: zapd's own election runs on a Windows runner in its CI. - TypeScript (
src/zap.ts) is a consumer only: zapd has no JS or wasm build, so it joins whichever process holds the lock and never stands for router. With no router it says so (NO_ROUTER). Node cannot read a unix socket's peer credentials, so unlike zapd's own node it does not check the peer is the lock holder; it trusts the 0700 runtime dir, which it refuses otherwise. browser(rust/src/tools/browser_tool.rs,src/tools/browser.ts) carries Python'sACTIONStable verbatim: names, topics, usage, extension method,hanzo.actop. Core parameters are typed; the rest ride inargs, and an unknownargskey is refused. An action with a method goes to the browser; with none registered it falls back to headless Playwright, except a ref (@e2),annotate, or an explicitBROWSER_BACKEND, which error instead. Rust's fallback is the full driver; TypeScript's covers core, navigation and tabs. A capture is saved under~/.hanzo/screenshotsand returned as an MCP image block.cdpsends a raw CDP method verbatim to the same browser; no fallback.- Tests never touch the owner's router:
rust/tests/test_browser_tools.rsembeds the router on a privateXDG_RUNTIME_DIR/XDG_STATE_HOME/HOMEwith a pairing pinned to a random port;test/tools/browser.test.tsruns PyPIzapdas the router in another process. Both use a fake extension.
Parity with hanzo-mcp (Python)
Python hanzo-mcp (python-sdk/pkg/hanzo-tools-*) is the reference. Counted
from code, one row per registered Python tool: 72 tools. TypeScript: 17 yes,
23 partial, 32 missing. Rust: 28 yes, 19 partial, 25 missing. † not a
hanzo-mcp dependency (extras); ‡ shipped but disabled when hanzo is on.
| Python tool (pkg) | TS | Rust |
|---|---|---|
| agent (agent) | partial src/tools/think.ts (action=agent only) |
yes rust/src/tools/agent_tool.rs |
| zen (agent) | missing | yes agent_tool.rs (agent action=zen) |
| review (agent) | missing | yes agent_tool.rs |
| hanzo (api) | yes src/tools/unified/hanzo.ts |
yes rust/src/tools/hanzo_tool.rs |
| api (api) ‡ | missing | missing |
| auth (auth) ‡ | missing | partial hanzo_tool.rs (no login) |
| billing, commerce, iam, ingress, kms, team, s3 (†) | partial hanzo.ts (resource=…, catalog ops) |
partial hanzo_tool.rs (resource=…) |
| paas (paas) † | partial hanzo.ts (resource=platform) |
partial hanzo_tool.rs (resource=platform) |
| mpc (mpc) ‡ | missing | missing |
| browser (browser) | partial src/tools/browser.ts (all 88 names; Playwright fallback core/navigation/tabs) |
yes rust/src/tools/browser_tool.rs (no Firefox BiDi fast path) |
| cdp (browser) | yes src/tools/browser.ts |
yes rust/src/tools/cdp_tool.rs |
| playwright (browser) | partial (browser with BROWSER_BACKEND=playwright) |
partial (same) |
| code (code) | partial src/tools/unified/code.ts (search/context/ask/index are code_*) |
partial rust/src/tools/code_tool.rs |
| computer (computer) | partial src/autogui/ (opt-in; no touch/record/regions) |
partial rust/src/tools/computer_tool/ (no touch, record, locate, pixel, regions) |
| config (config) | missing | yes rust/src/tools/config_tool.rs |
| mode (config) | partial src/tools/mode-preset.ts (no activate, show) |
yes rust/src/tools/mode_tool.rs |
| workspace (config) | yes src/tools/unified/workspace.ts |
yes rust/src/tools/workspace_tool.rs |
| sql_query, sql_search, sql_stats, graph_add/remove/query/search/stats (database) † | missing | missing |
| devserver (devserver) | missing | missing |
| neovim_edit, neovim_command, neovim_session (editor) † | missing | missing |
| fs (fs) | yes src/tools/unified/fs.ts |
yes rust/src/tools/fs_tool.rs (search_text on rust/trigram: ranked, paged, documents) |
| gimp (gimp) † | yes src/tools/gimp.ts |
missing |
| ide (ide) † | missing | missing |
| jupyter (jupyter) † | missing | missing |
| llm (llm) | partial src/tools/llm.ts (query, models, limits, feedback; consensus is think's) |
partial rust/src/tools/llm_tool.rs (no enable, disable, test) |
| consensus (llm) | yes think.ts (action=consensus) |
yes llm_tool.rs (action=consensus) |
| lsp (lsp) | missing | yes rust/src/tools/lsp_tool.rs |
| mcp, mcp_add, mcp_remove, mcp_stats, proxy (mcp) † | missing | missing |
| memory (memory) | partial src/tools/memory.ts (no create, kb) |
yes rust/src/tools/memory_tool.rs |
| fetch (net) | partial src/tools/unified/fetch.ts (no web_read, research) |
partial rust/src/tools/fetch_tool.rs (web_read/research are tools) |
| vision (net) | missing | partial rust/src/tools/vision_tool.rs (ask only) |
| plan (plan) † | partial src/tools/plan.ts (no get, clear, intent, route, compose, chains) |
partial rust/src/tools/plan_tool.rs (no intent, route, compose, chains) |
| think (reasoning) | partial think.ts (no review) |
yes rust/src/tools/think_tool.rs |
| critic (reasoning) | yes think.ts (action=critic) |
yes think_tool.rs (action=critic) |
| refactor (refactor) | partial src/tools/refactor.ts (no rename_batch) |
partial rust/src/tools/refactor_tool.rs (no rename_batch, find_references) |
| repl (repl) † | missing | missing |
| zsh, exec, ps (shell) | yes src/tools/unified/exec.ts |
yes rust/src/tools/exec_tool.rs |
| open, curl, wget (shell) | yes fetch.ts (open, request, download) |
yes fetch_tool.rs (same) |
| npx, uvx, jq (shell) | yes src/tools/packages.ts |
yes rust/src/tools/runner.rs, rust/src/tools/jq_tool.rs |
| test (test) † | missing | missing |
| tasks (todo) | partial src/tools/tasks.ts (no clear, remove) |
yes rust/src/tools/tasks_tool.rs |
| ui (ui) | partial src/tools/unified-ui.ts (no ask, semantic_search, index) |
partial rust/src/tools/ui_tool.rs (7 of 18 actions) |
| git (vcs) | yes src/tools/git.ts |
yes rust/src/tools/git_tool.rs |
| vector (vector) † | partial src/tools/vector-search.ts (legacy; no embed) |
missing |
| version, stats (system) | missing | yes rust/src/tools/system_tool.rs |
| tool (system) | missing | partial system_tool.rs (no install, upgrade, reload, self_update) |
| kai (Kai Actions) — not in Python | schema and delegation src/tools/kai-action.ts |
yes rust/src/tools/actions_tool.rs, executor rust/actions |
A grouped row counts once per Python tool it names. npx and uvx are
exec of the argv they build, with no shell and a two-minute auto-background,
so a package server lands in exec ps / exec logs; in Rust a backgrounded
exec now keeps draining its output to a log and records its exit, where
before its pipes were dropped with the wait. Not in Python: TS
code_*, tracker_*, kai_*; Rust code_*, web_search, web_read,
research, system. Rust's search alias of fs is gone: fs action=search_text
is the one search. Recount after closing a gap and update both numbers.
Canonical role
Part of the AI/agents SDK line. This TS package (@hanzo/mcp) is canonical; the
Python hanzo-mcp (PyPI) and Rust hanzo-mcp::brain mirror the same tool surface
1-to-1 — tool names and action schemas identical across runtimes — except
tracker_* and kai_choice/kai_score/kai_noul, which are TypeScript only. Where they do not yet, the
parity table above says so. DRY: one impl
per tool in its canonical home; do not duplicate tool logic across runtimes beyond
the shared schema. Full model: ~/work/hanzo/SDK-ARCHITECTURE.md.
Install / run
npm install -g @hanzo/mcp && hanzo-mcp serve # or: pip install hanzo-mcp
Publishing
This repo is the only place @hanzo/mcp publishes from. hanzoai/extension
vendors a copy under packages/mcp as a build input; it is marked private so
a workspace-wide publish there can never ship a fork over this line. Leave it
private.
.github/workflows/publish.yml runs on push to main, on the hanzoai org
runners on github.com (linux-amd64). git.hanzo.ai holds this repo as a pull
mirror of GitHub with actions off, so a workflow under .hanzo/workflows runs
nowhere; every pipeline here lives in .github/workflows. It asks the registry
whether the tree is ahead, typechecks, bundles (dist/ is gitignored, so this is
also what fills the tarball), reads NPM_TOKEN from KMS, then npm publish. A
version already on the registry is skipped, so re-runs are safe. Same shape as
@hanzo/logo.
The KMS read is the flat form, answering {name, env, value}:
GET https://kms.hanzo.ai/v1/kms/secrets/NPM_TOKEN?env=prod -> .value
The org is not a path segment — the read is scoped by the token's owner claim,
and NPM_TOKEN sits at the org root. The only credential in GitHub is the
machine identity (KMS_CLIENT_ID/KMS_CLIENT_SECRET, org-level secrets on
hanzoai). /hanzo.yml stays a test gate; it publishes nothing.
Verify a release with npm view @hanzo/mcp version, never with a green run.
Key entry points
src/tools/unified/— HIP-0300 action-routed tools (fs, exec, code, fetch, workspace, hanzo)src/tools/— individual/legacy tools (git, think, memory, tasks, plan, mode)rust/src/tools/— Rust native tools (exec, git, fetch, code, computer)scripts/smoke-mcp.mjs— protocol smoke test run in CI
Brand rules (hard — enforce in all docs/code)
- Hanzo is a full AI SDK / AI cloud, never an "LLM gateway" or proxy; never position against LiteLLM.
- Zen models are our own family — never name upstream models.
- Paths are
/v1/only — never an/api/prefix. - Voice: "Hanzo — the Open AI Cloud." Modern, crisp, developer-first.