Files
openapi/tools.py
T
zeekay 0b132a8881 Name the thing instead of calling it a door
One word stood for several: the MCP server, one address that accepts requests,
the single way in, and the boundary a credential is validated at. Each site now
names its own sense — endpoint, entry point, the MCP server, the edge, address,
relay — or, where none of those fit, takes the word the surrounding code already
uses.

Prose and comments only, 1:1 line for line. Identifiers keep their names: CSS
classes, DOM ids, struct fields, enum variants, config values and the doc
comments that must open with the identifier they document.
2026-08-23 13:49:39 -07:00

285 lines
12 KiB
Python

#!/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 MCP server already answers that way — every tool `POST /v1/mcp` lists is
verbatim an id hanzo-inc/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 MCP server: 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 hanzo-inc/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 MCP server 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 MCP server 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 MCP server 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 hanzo-inc/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 MCP server 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())