Files
zeekayandHanzo Dev d0c125f571 tools: generate the hanzo surface from cloud's OpenAPI registry; delete the vector fake
The `hanzo` dispatcher hand-listed 10 services in SERVICE_TOOL_PATHS, so the
other ~150 cloud products were unreachable until someone cut a Python release.
Project the surface from cloud's /v1/openapi.json instead: cloud tags every
operation with its product (the first path segment after /v1/), so that document
already IS a service/action catalog. 166 services are now reachable, and a newly
mounted app is callable the moment cloud serves it.

Still exactly one MCP tool — agents degrade badly with hundreds — now shaped
hanzo(service, action, params, method), with `services` listing the catalog and
an empty action listing a service's actions.

Delete the local Infinity vector store (3,546 lines). It shipped no embedder, so
its only possible output was random vectors: it ranked "Bananas are yellow" above
an auth document for the query "authentication oauth jwt". A previous fix put the
mock behind HANZO_VECTOR_ALLOW_MOCK, but a fake that a flag can re-enable is
still a fake, and a tool that lies to an agent is worse than a missing one. The
package keeps only the cloud-backed VectorTool, and a test now fails if any
module in it imports `random`.

Make the legacy-tool gate honest: hide the per-service tools only once the
unified tool genuinely imports. It does not import against released hanzo-tools
(HanzoCloud is newer), and disabling them unconditionally would have left no
cloud tools at all.

- spec.py: catalog projection, cache keyed per spec source, stale cache beats
  failing a call. HANZO_OPENAPI_URL decouples catalog from target so a partial
  local host can be driven with the full registry.
- HanzoCloud.call: one generic seam for the verbs the spec names (PUT/PATCH/
  DELETE); auth also resolves the hanzo CLI's IAM session, memoized.
- 40 new tests covering action naming, template binding, method inference,
  param split, and cache fallback.

Co-authored-by: Hanzo Dev <[email protected]>
2026-07-27 20:25:54 -07:00

291 lines
9.9 KiB
Python

"""Service catalog projected from cloud's live OpenAPI registry.
Cloud generates ``/v1/openapi.json`` from the router it actually serves, and
tags every operation with its product — the first path segment after ``/v1/``.
That document therefore already IS a service/action catalog, so we project the
MCP surface from it instead of hand-listing services in Python: a new app is
reachable the moment cloud serves it, with no Python release. Hand-listing is
precisely what lets a tool surface drift out of date with the platform.
The catalog is a pure function of the spec. Fetching/caching is separate (see
``load``) so the mapping can be tested without a network.
"""
from __future__ import annotations
import json
import os
import time
import urllib.error
import urllib.request
from collections.abc import Iterable
from pathlib import Path
from typing import Any, NamedTuple
DEFAULT_BASE = "https://api.hanzo.ai"
SPEC_PATH = "/v1/openapi.json"
CACHE_TTL = 24 * 3600
METHODS = ("get", "post", "put", "patch", "delete", "head", "options")
# Methods that carry input as a JSON body; everything else takes a query string.
BODY_METHODS = frozenset({"post", "put", "patch"})
def api_base() -> str:
"""Resolve the cloud base URL (no trailing slash)."""
base = (
os.environ.get("HANZO_API_BASE")
or os.environ.get("HANZO_BASE_URL")
or DEFAULT_BASE
)
return base.rstrip("/")
class Route(NamedTuple):
"""One concrete call: a method, a path with params already bound."""
method: str
path: str
# Params consumed by the path template, so the caller does not resend them.
bound: dict[str, str]
class Operation(NamedTuple):
method: str
path: str
action: str
typed: bool
def _segments(path: str) -> list[str]:
return [s for s in path.strip("/").split("/") if s]
def action_of(path: str, tag: str) -> str:
"""The action name for a path within its service.
``/v1/billing/plans`` under tag ``billing`` is the action ``plans``. The
version and the service segment are dropped because they are already
implied by the service, leaving the shortest thing a caller can name.
"""
segs = _segments(path)
if segs and segs[0] == "v1":
segs = segs[1:]
if segs and segs[0] == tag:
segs = segs[1:]
return "/".join(segs)
def _bind(want: str, have: str) -> dict[str, str] | None:
"""Match a requested action against a (possibly templated) action.
Returns the path-param bindings, or None if it does not match. A literal
action wins over a template because an exact match binds nothing.
"""
w, h = _segments(want), _segments(have)
if len(w) != len(h):
return None
bound: dict[str, str] = {}
for got, pat in zip(w, h, strict=True): # lengths checked above
if len(pat) > 2 and pat[0] == "{" and pat[-1] == "}":
bound[pat[1:-1]] = got
elif got != pat:
return None
return bound
class Catalog:
"""Services and actions derived from an OpenAPI document."""
def __init__(self, spec: dict[str, Any]):
self.spec = spec
self._ops: dict[str, list[Operation]] = {}
for path, item in (spec.get("paths") or {}).items():
if not isinstance(item, dict):
continue
for method, op in item.items():
if method not in METHODS or not isinstance(op, dict):
continue
# An op is typed when cloud folded a real schema in; untyped ops
# are router-shape only and take params on trust.
typed = bool(op.get("requestBody") or op.get("responses"))
for tag in op.get("tags") or ["_untagged"]:
self._ops.setdefault(tag, []).append(
Operation(method, path, action_of(path, tag), typed)
)
@property
def services(self) -> list[str]:
return sorted(self._ops)
def operations(self, service: str) -> list[Operation]:
return sorted(self._ops.get(service, []), key=lambda o: (o.action, o.method))
def summary(self) -> dict[str, Any]:
"""Compact catalog: every service with its operation and typed counts."""
return {
svc: {
"actions": len({o.action for o in ops}),
"operations": len(ops),
"typed": sum(1 for o in ops if o.typed),
}
for svc, ops in sorted(self._ops.items())
}
def describe(self, service: str) -> dict[str, Any]:
ops = self.operations(service)
if not ops:
raise KeyError(service)
actions: dict[str, dict[str, Any]] = {}
for o in ops:
entry = actions.setdefault(
o.action, {"methods": [], "path": o.path, "typed": False}
)
entry["methods"].append(o.method.upper())
entry["typed"] = entry["typed"] or o.typed
return {"service": service, "actions": actions}
def resolve(
self,
service: str,
action: str,
method: str | None = None,
has_params: bool = False,
) -> Route:
"""Pick the one route named by (service, action).
Exact action matches beat templated ones. When several methods share an
action and the caller did not name one, params imply a write (POST) and
their absence implies a read (GET) — the intent the caller expressed.
"""
ops = self._ops.get(service)
if not ops:
raise KeyError(f"unknown service '{service}'")
want = (action or "").strip("/")
exact: list[tuple[Operation, dict[str, str]]] = []
templated: list[tuple[Operation, dict[str, str]]] = []
for o in ops:
bound = _bind(want, o.action)
if bound is None:
continue
(exact if not bound else templated).append((o, bound))
candidates = exact or templated
if not candidates:
known = sorted({o.action for o in ops})[:20]
raise KeyError(
f"unknown action '{action}' for service '{service}'. "
f"Known actions: {', '.join(known) or '(none)'}"
)
if method:
m = method.lower()
picked = [c for c in candidates if c[0].method == m]
if not picked:
have = sorted({c[0].method.upper() for c in candidates})
raise KeyError(
f"{service}/{action} does not accept {m.upper()}; "
f"available: {', '.join(have)}"
)
elif len(candidates) == 1:
picked = candidates
else:
prefer = "post" if has_params else "get"
picked = [c for c in candidates if c[0].method == prefer] or candidates
op, bound = picked[0]
path = op.path
for name, value in bound.items():
path = path.replace("{" + name + "}", value)
return Route(op.method.upper(), path, bound)
def cache_file(base: str) -> Path:
"""Cache location for a base URL. Distinct hosts must not share a file."""
slug = base.split("://", 1)[-1].replace("/", "_").replace(":", "_")
return Path.home() / ".hanzo" / "cache" / f"openapi-{slug}.json"
def spec_url(base: str) -> str:
"""Where to read the registry from.
Normally the target itself publishes it. ``HANZO_OPENAPI_URL`` separates the
two so a catalog can be read from one cloud while calls go to another — the
case when driving a partial local host, which mounts only some apps and so
cannot serve the whole registry.
"""
return os.environ.get("HANZO_OPENAPI_URL") or (base.rstrip("/") + SPEC_PATH)
def fetch(base: str, timeout: float = 60.0) -> dict[str, Any]:
"""Fetch the spec straight from the running cloud."""
url = spec_url(base)
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.loads(resp.read().decode("utf-8"))
except (urllib.error.URLError, TimeoutError, ValueError, OSError) as e:
raise RuntimeError(f"cannot fetch OpenAPI spec from {url}: {e}") from e
def load(
base: str | None = None,
refresh: bool = False,
ttl: float = CACHE_TTL,
) -> tuple[dict[str, Any], str]:
"""Return (spec, source). Cache is a speed-up, never a source of truth.
A stale cache is still preferred over failing the call outright: the
catalog only names routes, and a name that has since moved fails loudly at
call time anyway.
"""
base = (base or api_base()).rstrip("/")
path = cache_file(spec_url(base))
fresh = (
not refresh
and path.exists()
and (time.time() - path.stat().st_mtime) < ttl
)
if fresh:
try:
return json.loads(path.read_text()), f"cache:{path}"
except (OSError, ValueError):
pass # corrupt cache: fall through and refetch
try:
spec = fetch(base)
except RuntimeError:
if path.exists():
try:
return json.loads(path.read_text()), f"cache-stale:{path}"
except (OSError, ValueError):
pass
raise
try:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(spec))
except OSError:
pass # a read-only home must not break the call
return spec, spec_url(base)
def split_params(
route: Route, params: dict[str, Any]
) -> tuple[dict[str, Any] | None, dict[str, Any] | None]:
"""Split caller params into (query, body) for a route.
Params already consumed by the path template are dropped so they are not
also sent as query/body noise.
"""
rest: dict[str, Any] = {
k: v for k, v in params.items() if k not in route.bound and v is not None
}
if route.method.lower() in BODY_METHODS:
return None, rest
return rest or None, None
def iter_actions(ops: Iterable[Operation]) -> list[str]:
return sorted({o.action for o in ops})