tools: a tool name is the id the door answers to, not codegen's rename

`publish.py` rule 6 suffixes an operationId when two collide under the identity
a GENERATOR uses — punctuation stripped, case folded — so that Go does not
declare one request type twice. `deleteSession` (POST /v1/iam/sessions/delete)
and `DeleteSession` (DELETE /v1/o11y/sessions) are that collision, and the
second is published as `DeleteSession_2`.

An operationId is not only a codegen identifier. It is the WIRE NAME of an MCP
tool, and `POST /v1/mcp` answers to no `DeleteSession_2` — measured against the
live door, which lists `DeleteSession` and `deleteSession` and nothing else. The
suffix shipped verbatim in the tool catalogue of three client distributions:
npm @hanzo/mcp, PyPI hanzo-mcp, crates.io hanzo-mcp, all three byte-identical at
d6d49a17, 2279 tools. A catalogue that names a tool nothing can be told to run is
the one defect the catalogue exists to remove.

So rule 6 records what it renamed. `x-id` is the id hanzoai/cloud emitted,
written only where the projection had to change it — one operation in 2279, so
its meaning is its presence — and `tools.py`'s `wire()` is the ONE place a tool
takes its name. Nothing else moves: same rules, same drops, same count.

MEASURED, catalogue against the live door's 1323 tools:
  before  1299/1323
  after   1300/1323   and the remaining 23 are exactly the compat-tagged IAM
                      operations rule 2 drops on purpose, each of which has a
                      canonical twin in the published document
                      (add-user -> POST /v1/iam/users, and so on for all 23).

test_tools.py pins it from the document alone, offline: where `x-id` is present
the catalogue must carry it and must NOT carry the codegen name. hanzo.yml runs
the file, because a gate that is not in the `test:` block does not run.

openapi-generator 7.14.0 is indifferent to the new key — python, typescript,
java and kotlin each emit a full client tree from the document that carries it.

Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
2026-08-05 18:31:39 -07:00
co-authored by Hanzo Dev
parent f777658887
commit 353b50d2b6
5 changed files with 504 additions and 3 deletions
+12 -1
View File
@@ -127,7 +127,18 @@ same, against hanzo.yaml → 0 errors; api
| 3 | two tags → the first | 23 | a generator emits one class per tag, so a two-tag operation is emitted twice under one identifier (`ApiPricingGetFullPricingRequest redeclared`). |
| 4 | no tag → `x-app`, else the `/v1/<product>` segment | 42 | untagged is not ungrouped, it is `DefaultApi`. |
| 5 | no `responses` → a `default` that says so | 986 | **the 1012.** A `default` with no content states exactly what is known: the route answers, and its shape is not declared at the source. Inventing a schema is the one thing this must not do. |
| 6 | operationIds unique under `genid` | 1 | unique as STRINGS is what OpenAPI asks and it is not enough — every generator strips punctuation and camel-cases, so `deleteSession` and `DeleteSession` become one name and the client declares one request type twice. |
| 6 | operationIds unique under `genid`, original kept as `x-id` | 1 | unique as STRINGS is what OpenAPI asks and it is not enough — every generator strips punctuation and camel-cases, so `deleteSession` and `DeleteSession` become one name and the client declares one request type twice. |
Rule 6 is the one rule that CHANGES a value rather than dropping or adding one,
and an operationId is not only a codegen identifier — it is the **wire name of an
MCP tool**. `DELETE /v1/o11y/sessions` is published as `DeleteSession_2` and
served as `DeleteSession`, and the suffix went out verbatim in the tool catalogue
of three client distributions, naming a tool `POST /v1/mcp` answers to no such
name. So the rename now records what it renamed: `x-id` is the id hanzoai/cloud
emitted, written only where the two differ, and `tools.py`'s `wire()` is the one
place a tool takes its name. Measured against the live door: 1299/1323 before,
1300/1323 after. The collision itself belongs upstream — two operations whose ids
differ only in case are one name to every generator there is.
Plus one addition that is not a rule about operations: cloud's emission declares
**no security scheme**, so a client generated from it sends no `Authorization`
+1 -1
View File
@@ -45,7 +45,7 @@ test:
UV="$HOME/.local/bin/uv"
fi
"$UV" run --no-project --python 3.12 --with pyyaml --with pytest \
python -m pytest test_publish.py test_flows.py test_skills.py -q
python -m pytest test_publish.py test_flows.py test_skills.py test_tools.py -q
- name: served
# Every operation this repo PUBLISHES must be one api.hanzo.ai answers for.
+16 -1
View File
@@ -246,7 +246,12 @@ def project(doc, groups):
6. operationIds are made unique under `genid` (1 collision today,
`deleteSession` vs `DeleteSession`). The later one by path order takes a
suffix, which is what a generator does for the duplicates it can see.
suffix, which is what a generator does for the duplicates it can see —
and it keeps the id cloud emitted as `x-id`, because an operationId is
ALSO the wire name of an MCP tool. The suffix exists for Go; the door
does not know it. Whoever needs the name a caller can use reads `x-id`
first. The collision itself belongs upstream: two operations whose ids
differ only in case are one name to every generator on earth.
Nothing else. No operation is added, no served operation is removed, no
prose is written — every summary and description below came out of a handler
@@ -294,6 +299,16 @@ def project(doc, groups):
renamed += oid != base
taken.add(genid(oid))
op["operationId"] = oid
if oid != base:
# The id hanzoai/cloud emitted, kept because the rename above is
# a fact about GENERATORS and the id is also the WIRE NAME: it is
# what `POST /v1/mcp` answers to. Renaming it silently put
# `DeleteSession_2` in the MCP catalogue of three client
# distributions, and the door knows no such tool — a suffix
# invented here for Go's benefit is not a thing anything serves.
# Present only where the two differ, so its meaning is its
# presence. `tools.py` names the tool by this.
op["x-id"] = base
used = []
for item in paths.values():
+191
View File
@@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""Golden test for tools.py — the MCP tool catalogue generator.
Runnable standalone (`python3 test_tools.py`, non-zero exit on failure) AND under
pytest (functions named `test_*`). It reads the COMMITTED `hanzo.yaml` and
asserts the properties the catalogue promises, WITHOUT depending on any committed
output (dist/ is generated, never committed):
• name IS the id the operation is SERVED under — verbatim, every tool, no
exception. This is the whole contract. A generator that synthesises names
from paths is a THIRD naming rule, and a third rule is the disease this file
exists to prevent — as is publishing `publish.py` rule 6's codegen suffix,
which the door does not answer to;
• one name, one tool — a duplicate id makes the second tool unreachable;
• callable verbs only — OPTIONS/TRACE are transport machinery, never tools;
• self-contained schemas — every `$ref` a tool emits resolves inside that
tool's OWN `$defs`. A pointer into `#/components` would leave the catalogue
depending on a document the client does not ship;
• total — every named operation in the document becomes a
tool. A projection that silently drops operations is how a client ends up
with 25 tools while the API has thousands;
• deterministic — two generations are byte-identical, which is what
the `--check` drift gate rests on.
"""
import json
import os
import tempfile
import yaml
import tools
DOC = yaml.safe_load(open(tools.DOCUMENT))
CATALOGUE = tools.catalogue()
def _ids():
"""(served id, METHOD path) for every callable operation in the document."""
for path, item in (DOC.get("paths") or {}).items():
if not isinstance(item, dict):
continue
for method in tools.VERBS:
op = item.get(method)
if isinstance(op, dict) and tools.wire(op):
yield tools.wire(op), f"{method.upper()} {path}"
def test_name_is_the_operation_id():
# Every tool name is an id the document actually publishes — verbatim.
published = {oid for oid, _ in _ids()}
for t in CATALOGUE:
assert t["name"] in published, \
f"tool {t['name']!r} is not an operationId in the document — a name was invented"
def test_no_codegen_suffix_becomes_a_tool_name():
"""Rule 6's suffix is a fact about Go, and the door does not answer to it.
`publish.py` renames an operationId when two collide under the identity a
generator uses, so `DELETE /v1/o11y/sessions` is published as
`DeleteSession_2` and served as `DeleteSession`. Shipped verbatim, that put
a name in three client distributions — npm `@hanzo/mcp`, PyPI `hanzo-mcp`,
crates.io `hanzo-mcp` — that nothing can be told to run, which is exactly the
defect the catalogue exists to remove and the one shape of it a static
artifact CAN refuse: the document itself records what it renamed.
"""
got = {t["name"] for t in CATALOGUE}
wrong = []
for path, item in (DOC.get("paths") or {}).items():
if not isinstance(item, dict):
continue
for method in tools.VERBS:
op = item.get(method)
if not isinstance(op, dict) or not op.get("x-id"):
continue
if op["x-id"] not in got:
wrong.append(f"{method.upper()} {path}: served as {op['x-id']!r}, no tool")
if op["operationId"] in got:
wrong.append(f"{method.upper()} {path}: published the codegen name "
f"{op['operationId']!r} as a tool")
assert not wrong, "\n".join(wrong)
def test_names_unique_and_sorted():
names = [t["name"] for t in CATALOGUE]
assert len(names) == len(set(names)), "duplicate tool names"
assert names == sorted(names), "catalogue is not sorted by name"
def test_total():
# Nothing named in the document is dropped on the floor.
published = {oid for oid, _ in _ids()}
got = {t["name"] for t in CATALOGUE}
missing = published - got
assert not missing, f"{len(missing)} operations produced no tool: {sorted(missing)[:10]}"
def test_no_transport_verbs():
# OPTIONS/TRACE exist in the document and must never become tools.
got = {t["name"] for t in CATALOGUE}
leaked = []
for path, item in (DOC.get("paths") or {}).items():
if not isinstance(item, dict):
continue
for method in ("options", "trace", "head"):
op = item.get(method)
if isinstance(op, dict) and tools.wire(op) in got:
leaked.append(f"{method.upper()} {path}")
assert not leaked, f"transport verbs became tools: {leaked[:10]}"
def test_schemas_are_self_contained():
# Every $ref resolves inside the tool's own $defs. Nothing points at
# #/components, which the client does not ship.
def refs(node):
if isinstance(node, dict):
r = node.get("$ref")
if isinstance(r, str):
yield r
for v in node.values():
yield from refs(v)
elif isinstance(node, list):
for v in node:
yield from refs(v)
dangling, foreign = [], []
for t in CATALOGUE:
s = t["inputSchema"]
have = set(s.get("$defs") or {})
for r in refs(s):
if not r.startswith("#/$defs/"):
foreign.append((t["name"], r))
elif r[len("#/$defs/"):] not in have:
dangling.append((t["name"], r))
assert not foreign, f"{len(foreign)} refs escape the tool: {foreign[:5]}"
assert not dangling, f"{len(dangling)} refs resolve to nothing: {dangling[:5]}"
def test_input_schema_shape():
for t in CATALOGUE:
s = t["inputSchema"]
assert isinstance(s, dict), t["name"]
assert s.get("type") == "object", f"{t['name']}: inputSchema is not an object schema"
assert isinstance(s.get("properties"), dict), f"{t['name']}: no properties map"
for k in (s.get("required") or []):
assert k in s["properties"], f"{t['name']}: required {k!r} is not a property"
def test_deterministic():
a = tools.render(tools.catalogue())
b = tools.render(tools.catalogue())
assert a == b, "two generations differ"
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "tools.json")
n = tools.generate(p)
assert n == len(CATALOGUE), f"generate() reported {n}, catalogue has {len(CATALOGUE)}"
assert open(p).read() == a, "written bytes differ from render()"
assert json.load(open(p))["count"] == len(CATALOGUE)
def test_duplicate_id_is_refused():
# The one thing that must fail loudly rather than silently shadow a tool.
doc = {"paths": {"/a": {"get": {"operationId": "dup"}},
"/b": {"get": {"operationId": "dup"}}}}
with tempfile.TemporaryDirectory() as d:
p = os.path.join(d, "doc.yaml")
open(p, "w").write(yaml.safe_dump(doc))
try:
tools.catalogue(p)
except SystemExit as e:
assert "dup" in str(e)
return
raise AssertionError("a duplicate operationId was accepted")
def main():
tests = [v for k, v in sorted(globals().items()) if k.startswith("test_") and callable(v)]
failed = 0
for t in tests:
try:
t()
print(f"ok {t.__name__}")
except AssertionError as e:
failed += 1
print(f"FAIL {t.__name__}: {e}")
print(f"\n{len(tests) - failed}/{len(tests)} passed")
return 1 if failed else 0
if __name__ == "__main__":
raise SystemExit(main())
+284
View File
@@ -0,0 +1,284 @@
#!/usr/bin/env python3
"""Project the published document into the MCP tool catalogue.
python3 tools.py # write dist/tools.json
python3 tools.py --check # drift gate: regenerate and diff against dist/
python3 tools.py --stat # counts only, write nothing
ONE source, and the source is the DOCUMENT. A tool name is an operation id:
not derived from the path, not looked up in a table, not invented here. The
fleet's own door already answers that way — every tool `POST /v1/mcp` lists is
verbatim an id hanzoai/cloud emitted — so a catalogue built any other way would
name tools the API cannot be told to run. Which is measurable, and was measured
against the door: the id, not the published `operationId`. `publish.py` rule 6
suffixes an id when two collide under a generator's identity, and that suffix is
a fact about Go rather than about the API — see `wire()`, which is the only
place a tool gets its name.
That is the whole defect this file removes: three client distributions
(npm `@hanzo/mcp`, PyPI `hanzo-mcp`, crates.io `hanzo-mcp`) each hand-kept
their own idea of what the fleet exposes, and hand-kept sets do not agree.
Why a client needs this at all, when hanzoai/cloud deliberately does NOT commit
one: the server can ASK its subsystems at the moment it is asked (package
fleet), so a checked-in catalogue there would be a second artifact free to go
stale beside the first — which is exactly how plugin/<app>/mcp.json went 353
operations stale and why it was deleted. A CLIENT has no such luxury. It is
installed from a registry with no fleet in reach, so it ships a list; the only
honest list is a projection of the contract, regenerated and diffed.
The projection is total and mechanical:
• every operation becomes exactly one tool, keyed by the id it is served
under (`x-id` where the projection had to rename, `operationId` otherwise);
• OPTIONS and TRACE never do — CORS preflight and request echo are transport
machinery, not something an agent calls;
• `inputSchema` is the operation's parameters and request body as one flat
JSON Schema object, component `$ref`s carried along into `$defs` — the same
shape the live door serves, so a tool generated here and a tool listed
there are callable the same way.
WHICH operations a given deployment will actually answer for is not a fact
about the document — it is a fact about what that deployment mounts and what
the caller's key reaches. The door answers that at runtime. This file answers
the other question, the only one a static artifact can: what the contract says
exists. Filtering here would bake one deployment's answer into every client.
Determinism is a hard requirement (`--check` rests on it): sorted tools, sorted
keys, fixed separators, trailing newline, and no clock, host or random value
ever enters the file.
"""
import argparse
import json
import os
import sys
import yaml
ROOT = os.path.dirname(os.path.abspath(__file__))
DOCUMENT = os.path.join(ROOT, "hanzo.yaml")
OUT = os.path.join(ROOT, "dist", "tools.json")
SCHEMA_ID = "hanzo.mcp-tools/v1"
# The verbs an agent can be told to call. OPTIONS/TRACE are transport
# machinery and are never tools; the live door omits them too.
VERBS = ("get", "post", "put", "patch", "delete")
REFS = "#/components/schemas/"
def wire(op: dict):
"""The name a caller can actually use — the id hanzoai/cloud emitted.
`publish.py` rule 6 suffixes an operationId when two of them collide under
the identity a GENERATOR uses (punctuation stripped, case folded), so that
Go does not declare one request type twice. That suffix is a fact about
codegen: `DELETE /v1/o11y/sessions` is `DeleteSession_2` in the published
document and `DeleteSession` on the wire, and the door answers to exactly
one of those. Publishing the other named a tool in three client
distributions that nothing can be told to run. Where the projection had to
change the name it records the original as `x-id`, so this is the whole
rule: `x-id` when it is there, `operationId` when it is not.
"""
return op.get("x-id") or op.get("operationId")
def prose(op: dict) -> str:
"""The tool's description: the operation's summary, then its longer prose.
Both, when both exist — a summary alone is often a single clause, and the
description carries the conditions an agent needs to choose correctly."""
head = (op.get("summary") or "").strip()
body = (op.get("description") or "").strip()
if head and body and not body.startswith(head):
return f"{head}\n\n{body}"
return body or head
def deref(doc: dict, ref: str):
"""Resolve one local `#/...` pointer against the document. None when off-doc."""
if not isinstance(ref, str) or not ref.startswith("#/"):
return None
node = doc
for part in ref[2:].split("/"):
if not isinstance(node, dict) or part not in node:
return None
node = node[part]
return node
def rewrite(node, need: set):
"""Copy a schema, repointing component `$ref`s at `$defs` and recording which.
The catalogue is one self-contained object per tool, so a pointer into
`#/components/schemas` — which exists only in the document — has to become a
pointer into the tool's own `$defs`. `need` collects the names so the caller
can carry them, transitively, without walking the tree a second time."""
if isinstance(node, dict):
ref = node.get("$ref")
if isinstance(ref, str) and ref.startswith(REFS):
name = ref[len(REFS):]
need.add(name)
rest = {k: rewrite(v, need) for k, v in node.items() if k != "$ref"}
return {"$ref": f"#/$defs/{name}", **rest}
return {k: rewrite(v, need) for k, v in node.items()}
if isinstance(node, list):
return [rewrite(v, need) for v in node]
return node
def defs(doc: dict, need: set) -> dict:
"""Every component schema the tool reaches, transitively, keyed by bare name."""
out, seen = {}, set()
queue = sorted(need)
while queue:
name = queue.pop(0)
if name in seen:
continue
seen.add(name)
target = deref(doc, REFS + name)
if target is None:
continue
more = set()
out[name] = rewrite(target, more)
queue.extend(sorted(more - seen))
return out
def schema(doc: dict, path: dict, op: dict) -> dict:
"""One flat JSON Schema object: parameters and request body together.
Path, query and header parameters become top-level properties; the request
body's own properties are folded in beside them, because a tool call is one
flat argument object and an agent has no other way to say "this one goes in
the URL". A body that is not an object (an array, a scalar) cannot be folded
and is carried under `body`, which is the only name that could collide and
the only case where anything is invented at all."""
props, required, need = {}, [], set()
for p in (path.get("parameters") or []) + (op.get("parameters") or []):
if isinstance(p, dict) and "$ref" in p:
p = deref(doc, p["$ref"]) or {}
if not isinstance(p, dict) or p.get("in") == "cookie":
continue
name = p.get("name")
if not name:
continue
sub = rewrite(p.get("schema") or {"type": "string"}, need)
if p.get("description"):
sub = {**sub, "description": p["description"]}
props[name] = sub
if p.get("required"):
required.append(name)
content = ((op.get("requestBody") or {}).get("content") or {})
media = content.get("application/json") or next(iter(content.values()), None)
if isinstance(media, dict):
body = media.get("schema") or {}
if isinstance(body, dict) and "$ref" in body:
resolved = deref(doc, body["$ref"])
if resolved is not None:
# Fold the named body's own properties in, but keep whatever it
# refers to reachable — the $defs walk below sees `need`.
body = resolved
body = rewrite(body, need) if isinstance(body, dict) else {}
if body.get("type") == "object" or "properties" in body:
for k, v in (body.get("properties") or {}).items():
props.setdefault(k, v)
for k in (body.get("required") or []):
if k not in required:
required.append(k)
elif body:
props.setdefault("body", body)
out = {"type": "object", "properties": props}
if required:
out["required"] = sorted(required)
reach = defs(doc, need)
if reach:
out["$defs"] = reach
return out
def catalogue(document: str = DOCUMENT) -> list:
"""Every operation in the document, as one sorted list of MCP tools."""
doc = yaml.safe_load(open(document)) or {}
out, seen = [], {}
for path, item in sorted((doc.get("paths") or {}).items()):
if not isinstance(item, dict):
continue
for method in VERBS:
op = item.get(method)
if not isinstance(op, dict):
continue
name = wire(op)
if not name:
# A tool has no name to be called by. Nothing to emit, and
# nothing to invent — the document is where that is fixed.
continue
if name in seen:
raise SystemExit(
f"tools: operationId {name!r} is claimed twice — "
f"{seen[name]} and {method.upper()} {path}. A tool name is an "
f"operation id, so two operations with one id are two tools "
f"with one name and the second is unreachable.")
seen[name] = f"{method.upper()} {path}"
out.append({
"name": name,
"description": prose(op),
"inputSchema": schema(doc, item, op),
})
out.sort(key=lambda t: t["name"])
return out
def render(tools: list) -> str:
"""The catalogue as the exact bytes that get written. Deterministic."""
return json.dumps(
{"schema": SCHEMA_ID, "count": len(tools), "tools": tools},
sort_keys=True, indent=1, separators=(",", ": "),
) + "\n"
def generate(out: str = OUT, document: str = DOCUMENT) -> int:
tools = catalogue(document)
os.makedirs(os.path.dirname(out), exist_ok=True)
with open(out, "w") as fh:
fh.write(render(tools))
return len(tools)
def main():
ap = argparse.ArgumentParser(description="Generate the MCP tool catalogue.")
ap.add_argument("--out", default=OUT, help=f"output file (default: {OUT})")
ap.add_argument("--document", default=DOCUMENT, help="source document")
ap.add_argument("--check", action="store_true",
help="drift gate: regenerate and diff against --out")
ap.add_argument("--stat", action="store_true", help="counts only, write nothing")
args = ap.parse_args()
if args.stat:
tools = catalogue(args.document)
print(f"{len(tools)} tools from {os.path.basename(args.document)}")
return 0
if args.check:
if not os.path.isfile(args.out):
sys.exit(f"tools --check: {args.out} does not exist (run tools.py first)")
want = render(catalogue(args.document))
got = open(args.out).read()
if want != got:
print(f"tools --check: DRIFT — regenerate with `python3 tools.py`\n"
f" {args.out}: {len(got)} bytes on disk, {len(want)} bytes fresh",
file=sys.stderr)
sys.exit(1)
print(f"tools --check: OK — {args.out} matches a fresh generation")
return 0
n = generate(args.out, args.document)
print(f"generated {n} tools → {args.out}")
return 0
if __name__ == "__main__":
sys.exit(main())