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:
@@ -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`
|
||||
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user