mirror of
https://github.com/hanzoai/mcp.git
synced 2026-10-15 12:30:12 +00:00
A pure-Rust classifier for the `Classify` seam. brush-parser (MIT) gives the bash AST and rustpython-parser (MIT) the Python one; every simple command, redirection, substitution and nested script (bash -c, here-documents, a script the command wrote and runs, python -c, ssh host '...') is read in the order the shell runs them, and the policy over paths decides what each costs. rust/shell is its own crate with a wasm-bindgen build, so kai-gateway can replace safety.ts with it. exec: an ask is refused until the call carries approve: true (never lifts a deny); the interpreter must be a shell the classifier reads; the env is read beside the command; the git tool asks the same classifier and runs with core.fsmonitor=false; ui install no longer hands the component name to sh -c. On 408,231 Bash commands: safety.ts flags 20.69% (15.27% unparsed); this answers allow 88.2 / ask 9.6 / deny 2.2 (roots set), cannot read 0.22%, p99 0.6 ms.
529 lines
36 KiB
Markdown
529 lines
36 KiB
Markdown
# 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.
|
||
|
||
## 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 `cursor` really pages. (Before,
|
||
`rg` stopped at `limit` and a cursor repeated page one.)
|
||
- A symbol is an identifier on word boundaries; a hit is `definition` when the line reads like a declaration of it
|
||
(`fn x`, `def x`, `class x`, `const x`, `func (r) x`, `x = function`, `#define x`), else `reference`. This is textual:
|
||
it can call a comment a reference. `code search_symbol` reads code files unless `pattern` names others.
|
||
- A glob that names a document type (`*.pdf`, `*.docx`, `*.xlsx`, `*.zip`, `*.tar.gz`) makes `search_text` read
|
||
documents under the tree; a document named as `uri` is always read. A hit inside one adds `entry` (archive members,
|
||
joined by `!`) and `place` (`page 2`, `slide 3`, `para 5`, `sheet Data!B4`), and its `line` counts from the start of
|
||
that part. Plain hits carry neither key.
|
||
- A `limit` bounds 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 answers `limit=50` in 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]`, or `tgrep index`, or `hanzo_trigram::index::build`) writes tgrep's trigram
|
||
index to `DIR/.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::Only` refuses 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): `rg` 43-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 against `rg`'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 `fs` and `code` are unchanged.
|
||
- Dev: `hanzo_trigram::search(&Query::symbol(root, "Parser"))`. A dependency on it brings `libsqlite3-sys` (tgrep's
|
||
managed-index code), so a crate that links another SQLite patches `rusqlite` as `hanzoai/dev` does
|
||
(`[patch."https://github.com/rusqlite/rusqlite.git"] rusqlite = "=0.39.0"`). In this crate it forces `cc` past what
|
||
the tree-sitter grammars allow, so Cargo holds `tree-sitter-{c,cpp,javascript,python,rust,typescript}` at their
|
||
previous patch releases. Gating `managed/` 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): a `deny` is refused and `approve: true` never lifts it; an `ask`
|
||
is refused ("approval required: <reasons>; … run the call again with `approve: true`") until the call carries `approve: true`,
|
||
which the client's own permission prompt then shows beside the command; an `allow` runs. Every answer carries `verdict`
|
||
(`approved: true` on an approved ask). `approve` is 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.** `exec` runs only through `sh bash dash zsh ksh ash` (any other
|
||
`shell` is a `deny`, since the classifier reads shell), and `env` is read beside the text (`Classify::classify_env`:
|
||
`BASH_ENV`, `ZDOTDIR`, `LD_PRELOAD`, `NODE_OPTIONS=--require …`, a `PATH` with scratch space in it are an `ask`).
|
||
`git` (`GitTool::classified`) asks the same classifier about every git it runs, refuses a `deny` and an `ask` that makes git
|
||
run a program, and always runs with `core.fsmonitor=false`; `ui install` no longer passes the component name to `sh -c`.
|
||
Other spawners (`agent`, `lsp` installs, `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/`), `cd` moves the directory, `$!` is a job this script started.
|
||
Bodies run by an interpreter are read in turn: `bash -c`, `sh` fed 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.system` with a known command are classified as shell, `open(…, 'w')`, `shutil.rmtree`, `Path.write_text/unlink`,
|
||
sockets and `eval`/`exec` by their path or reach. `node -e`, `perl -e`, `ruby -e`, `php -r`, `lua -e` get 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-dir`
|
||
and `HANZO_MCP_ROOTS`; `/tmp` and 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 -e` that uses
|
||
`child_process`), unknown commands. `deny`: `rm -r` outside the workspace or of `/`, `~`, `.`, `*`; `dd`/`mkfs` on devices; `git reset --hard`,
|
||
`git clean -f`, `git checkout .`; `terraform apply/destroy`; `kubectl/helm delete`; `aws … rm/delete/terminate`; `gh pr merge`; `sudo`
|
||
and kin (also behind `env`, `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 -delete` and `find -exec rm` ask
|
||
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-gateway `src/safety.ts` on 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`,
|
||
`~/.cache` and `~/scratch` as 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`, then
|
||
`wasm-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.mjs` is the smoke
|
||
test. kai-gateway can replace `safety.ts` with it; its categories map from `kind` (`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_SHELL` is 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).
|
||
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
|
||
codex mcp add hanzo -- hanzo __mcp -- hanzo-mcp --tools fs,code,exec
|
||
|
||
`hanzo __mcp -- <server> [args]` is the launcher that answers the handshake at once; `hanzo-mcp --tools ...` alone works
|
||
the same without it. Dev links the crate instead: `hanzo-trigram` for search, `hanzo-mcp` for `ExecTool`.
|
||
|
||
## `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
|
||
- `deep` is `research`'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`) links `zapd` v1.1.5 by git tag (crates.io has
|
||
only 1.1.2, which predates the unpaired door). `main` calls `zap::seat()`:
|
||
it stands for router and joins as `mcp/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 as
|
||
`hanzo-zap-host` is Chrome/Firefox native messaging host `ai.hanzo.zap`,
|
||
relaying `{"z": base64 envelope}` messages to the socket, as Python's
|
||
`native_host.py` does. 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.
|
||
- **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's `ACTIONS` table verbatim: names, topics, usage, extension method,
|
||
`hanzo.act` op. Core parameters are typed; the rest ride in `args`, and an
|
||
unknown `args` key 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 explicit `BROWSER_BACKEND`, which error instead.
|
||
Rust's fallback is the full driver; TypeScript's covers core, navigation and
|
||
tabs. A capture is saved under `~/.hanzo/screenshots` and returned as an MCP
|
||
image block.
|
||
- `cdp` sends a raw CDP method verbatim to the same browser; no fallback.
|
||
- Tests never touch the owner's router: `rust/tests/test_browser_tools.rs`
|
||
embeds the router on a private `XDG_RUNTIME_DIR`/`XDG_STATE_HOME`/`HOME` with
|
||
a pairing pinned to a random port; `test/tools/browser.test.ts` runs PyPI
|
||
`zapd` as 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) |
|
||
|
||
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
|
||
```bash
|
||
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.
|