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.
285 lines
12 KiB
Python
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())
|