mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Read the fleet from the same catalog the other runtimes do
Python addressed the cloud through ten hand-written packages while TypeScript and Rust read a generated catalog, and the hand-written ones had drifted: paas is platform, storage is s3, auth folded into iam, and commerce asked for plurals the fleet does not publish. hanzo-tools-cloud embeds the file those two already embed, so the three cannot disagree about what the API offers — a test compares them by digest. One routing tool over 114 subsystems rather than 114 tools, for the reason the fleet groups its own. Naming a subsystem the fleet does not serve is answered before the credential check: demanding a key for something that does not exist sends the caller to fix the wrong thing.
This commit is contained in:
@@ -281,6 +281,41 @@ metadata contradicted the file inside it. Metadata now matches the file: `Apache
|
||||
emitted any more, so the suite had a failing test that named a module the client does not
|
||||
have. It reads `analytics_api` now, off the client.
|
||||
|
||||
## The cloud tool surface is GENERATED, and ten packages duplicate it
|
||||
|
||||
`pkg/hanzo-tools-cloud` offers the fleet the way the fleet offers itself: one
|
||||
routing tool over 114 subsystems and 1,416 operations, read from
|
||||
`catalog.json` — generated out of cloud's own typed operations
|
||||
(`plugin/gen-mcp-catalog`). It is the SAME file `@hanzo/mcp` and the Rust
|
||||
`hanzo-mcp` crate embed, and `tests/test_cloud.py` compares the three by digest,
|
||||
because three copies of one value is how they come to disagree.
|
||||
|
||||
It is a catalog and not a fetch because a tool list is answered before any
|
||||
request has been made. Refresh every runtime's copy at once with
|
||||
`pnpm sync:catalog` in `hanzoai/mcp`; it refuses to write a smaller catalog than
|
||||
it replaces without `--shrink`, since a fleet answering partially and a fleet
|
||||
that lost capabilities look identical.
|
||||
|
||||
**The withholding rule is applied once, in cloud.** The endpoint keeps an
|
||||
operation off the agent surface when its name discloses a bearer secret at any
|
||||
verb, or when a mutating verb acts on identity or authority — 124 of 1,540. The
|
||||
generator asks `fleet.Withheld`, the same predicate the endpoint asks, so a
|
||||
client cannot offer what the fleet refuses. `get_iam_users` survives;
|
||||
`post_iam_users` does not.
|
||||
|
||||
**Ten hand-written packages duplicate this, and they duplicate `hanzoai.cloud`
|
||||
too.** `hanzo-tools-{auth,billing,commerce,iam,ingress,kms,mpc,paas,s3,team}` are
|
||||
4,893 lines addressing the fleet by hand, and every one of them predates both the
|
||||
generated tool surface and the generated typed client. They have already drifted:
|
||||
`paas` is the name the fleet left behind (it is `platform`), `storage` is `s3`,
|
||||
`auth` folded into `iam`, and the commerce tool asked for plural resources
|
||||
(`/products`, `/orders`) where the fleet publishes singulars under
|
||||
`/v1/commerce/product/` — with no order or user collection at all, because an
|
||||
order is reached through its store and a user is IAM's.
|
||||
|
||||
`hanzo-tools-api` is NOT in that set: 8,028 of its 14,653 lines are a vendored
|
||||
APIs.guru directory, so it is a generic any-API tool rather than ours.
|
||||
|
||||
## Key entry points
|
||||
- `pkg/hanzoai/` — the client (`ApiClient`, `Configuration`, `*Api`) under `cloud/`, plus
|
||||
seven hand-written modules beside it (`config`, `mcp`, `protocols`, `session`, `zap`, …).
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
# hanzo-tools-cloud
|
||||
|
||||
The Hanzo Cloud surface, offered as the fleet publishes it: one entry per
|
||||
subsystem carrying that subsystem's operation names, plus `describe`.
|
||||
|
||||
Nothing here is hand-written. `catalog.json` is generated from cloud's own typed
|
||||
operations, and it is the same file the TypeScript (`@hanzo/mcp`) and Rust
|
||||
(`hanzo-mcp`) runtimes embed — so the three cannot disagree about what the API
|
||||
offers.
|
||||
|
||||
```python
|
||||
from hanzo_tools.cloud import call, services, operations
|
||||
|
||||
services() # every subsystem the fleet serves
|
||||
operations("iam") # that subsystem's operations
|
||||
call("iam", "get_iam_users")
|
||||
```
|
||||
|
||||
Every call goes to `api.hanzo.ai/v1/mcp`. Set `HANZO_API_KEY`; point elsewhere
|
||||
with `HANZO_API_URL`.
|
||||
|
||||
Refresh the catalog from the repo root: `pnpm sync:catalog` in `hanzoai/mcp`, or
|
||||
`go run ./plugin/gen-mcp-catalog .` in `hanzoai/cloud`.
|
||||
@@ -0,0 +1 @@
|
||||
__path__ = __import__("pkgutil").extend_path(__path__, __name__)
|
||||
@@ -0,0 +1,7 @@
|
||||
"""The Hanzo Cloud surface, generated from the fleet's own typed operations."""
|
||||
|
||||
from .cloud_tool import CloudTool, call, operations, reach, services
|
||||
|
||||
TOOLS = [CloudTool]
|
||||
|
||||
__all__ = ["CloudTool", "TOOLS", "call", "operations", "reach", "services"]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,157 @@
|
||||
"""The Hanzo Cloud surface, offered as the fleet publishes it.
|
||||
|
||||
One tool per subsystem, carrying that subsystem's operation names, plus
|
||||
``describe`` — which answers one operation's prose and schema. That is the shape
|
||||
the fleet serves at ``POST /v1/mcp``, and it is why this file is short: a flat
|
||||
projection is 1,416 tools and roughly a megabyte to enumerate, which a model pays
|
||||
for on every turn and a client with a tool cap truncates.
|
||||
|
||||
The names come from ``catalog.json``, generated out of cloud's own typed
|
||||
operations (``plugin/gen-mcp-catalog``). It is the same file the TypeScript and
|
||||
Rust runtimes embed, so the three cannot come to disagree about what the API
|
||||
offers — which is what "the runtimes mirror one another" has to mean to be worth
|
||||
saying.
|
||||
|
||||
It is a catalog rather than a fetch because a tool list is answered before any
|
||||
request has been made, and a client that needs the network to say what it can do
|
||||
has nothing to say when the network is what failed.
|
||||
|
||||
Every call goes to ``api.hanzo.ai`` — the one endpoint. The ten hand-written
|
||||
packages this replaces dialled four hosts, named resources the fleet had renamed,
|
||||
and asked for plurals where it publishes singulars.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import httpx
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_CATALOG = Path(__file__).with_name("catalog.json")
|
||||
|
||||
|
||||
@lru_cache(maxsize=1)
|
||||
def _fleet() -> dict[str, dict[str, list[str]]]:
|
||||
return json.loads(_CATALOG.read_text())
|
||||
|
||||
|
||||
def services() -> list[str]:
|
||||
"""Every subsystem the fleet offers, in a stable order."""
|
||||
return sorted(_fleet())
|
||||
|
||||
|
||||
def operations(service: str) -> list[str]:
|
||||
"""One subsystem's operations, empty for a name the fleet does not serve."""
|
||||
return list(_fleet().get(service, {}).get("ops", []))
|
||||
|
||||
|
||||
def reach() -> int:
|
||||
"""What this client can address, for anything reporting coverage."""
|
||||
return sum(len(e["ops"]) for e in _fleet().values())
|
||||
|
||||
|
||||
def _endpoint() -> str:
|
||||
return os.getenv("HANZO_API_URL", "https://api.hanzo.ai").rstrip("/")
|
||||
|
||||
|
||||
def _token() -> str:
|
||||
for name in ("HANZO_API_KEY", "API_KEY", "API_TOKEN", "HANZO_TOKEN"):
|
||||
value = os.getenv(name)
|
||||
if value:
|
||||
return value
|
||||
return ""
|
||||
|
||||
|
||||
def call(service: str, op: str, params: dict[str, Any] | None = None) -> Any:
|
||||
"""Hand one operation to the fleet and return what it answered.
|
||||
|
||||
A refusal is returned as a refusal. Folding it into a success whose body
|
||||
happens to say it failed is a refusal the caller cannot see, which is worse
|
||||
than no answer because it is acted on.
|
||||
"""
|
||||
# The name is checked first because it is a fact held here, needing no
|
||||
# credential. Demanding a key for a subsystem that does not exist sends the
|
||||
# caller to fix a credential when the request is what was wrong.
|
||||
if service not in _fleet():
|
||||
raise ValueError(f"the fleet does not serve {service!r}; it serves {', '.join(services())}")
|
||||
key = _token()
|
||||
if not key:
|
||||
raise RuntimeError("HANZO_API_KEY required")
|
||||
|
||||
body = {
|
||||
"jsonrpc": "2.0",
|
||||
"id": 1,
|
||||
"method": "tools/call",
|
||||
"params": {"name": service, "arguments": {"op": op, "input": params or {}}},
|
||||
}
|
||||
with httpx.Client(timeout=30.0) as client:
|
||||
answer = client.post(
|
||||
f"{_endpoint()}/v1/mcp",
|
||||
json=body,
|
||||
headers={"Authorization": f"Bearer {key}"},
|
||||
)
|
||||
answer.raise_for_status()
|
||||
payload = answer.json()
|
||||
|
||||
if "error" in payload:
|
||||
raise RuntimeError(payload["error"].get("message", "refused"))
|
||||
|
||||
result = payload.get("result", payload)
|
||||
if isinstance(result, dict) and result.get("isError"):
|
||||
content = result.get("content") or [{}]
|
||||
raise RuntimeError(content[0].get("text", "refused"))
|
||||
return result
|
||||
|
||||
|
||||
class CloudTool:
|
||||
"""The fleet behind one tool, routed by subsystem and operation.
|
||||
|
||||
One tool rather than 114, for the reason the fleet groups its own: a flat
|
||||
projection is roughly a megabyte to enumerate, which a model pays for on
|
||||
every turn and a client with a tool cap truncates. The subsystem names and
|
||||
their operations are read from the generated catalog, so a subsystem the
|
||||
fleet gains is reachable here the day the catalog is regenerated — there is
|
||||
no list in this file to fall behind.
|
||||
"""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return "cloud"
|
||||
|
||||
@property
|
||||
def description(self) -> str:
|
||||
return (
|
||||
f"The Hanzo Cloud API: {len(services())} subsystems, {reach()} operations. "
|
||||
"Name a subsystem in `service` and one of its operations in `op`, and pass "
|
||||
"that operation's own arguments in `input`. Omit `op` to list a subsystem's "
|
||||
"operations; omit `service` to list the subsystems."
|
||||
)
|
||||
|
||||
async def call(self, ctx: Any = None, **params: Any) -> Any:
|
||||
service = (params.get("service") or "").strip()
|
||||
if not service:
|
||||
return {"services": services()}
|
||||
if service not in _fleet():
|
||||
return {"error": f"the fleet does not serve {service!r}", "services": services()}
|
||||
op = (params.get("op") or "").strip()
|
||||
if not op:
|
||||
return {"service": service, "operations": operations(service)}
|
||||
try:
|
||||
return call(service, op, params.get("input") or {})
|
||||
except Exception as exc: # a refusal is reported as one, never as an empty result
|
||||
logger.debug("cloud %s.%s refused", service, op, exc_info=True)
|
||||
return {"error": str(exc)}
|
||||
|
||||
def register(self, mcp_server: Any) -> None:
|
||||
@mcp_server.tool(name=self.name, description=self.description)
|
||||
async def cloud( # type: ignore[no-untyped-def]
|
||||
service: str = "", op: str = "", input: dict[str, Any] | None = None
|
||||
):
|
||||
return await self.call(service=service, op=op, input=input)
|
||||
@@ -0,0 +1,29 @@
|
||||
[project]
|
||||
name = "hanzo-tools-cloud"
|
||||
version = "0.1.0"
|
||||
description = "The Hanzo Cloud surface, generated from the fleet's own typed operations"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12"
|
||||
license = { text = "MIT" }
|
||||
authors = [{ name = "Hanzo AI", email = "[email protected]" }]
|
||||
keywords = ["hanzo", "mcp", "cloud", "tools"]
|
||||
classifiers = [
|
||||
"Development Status :: 4 - Beta",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
]
|
||||
dependencies = ["httpx>=0.27"]
|
||||
|
||||
[project.entry-points."hanzo.tools"]
|
||||
cloud = "hanzo_tools.cloud:TOOLS"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["hanzo_tools"]
|
||||
|
||||
[tool.hatch.build.targets.wheel.force-include]
|
||||
"hanzo_tools/cloud/catalog.json" = "hanzo_tools/cloud/catalog.json"
|
||||
@@ -0,0 +1,81 @@
|
||||
"""The cloud surface is GENERATED, so what is asserted is the generation.
|
||||
|
||||
The valuable assertion is the last one: the catalog this package ships must be
|
||||
byte-identical to the one the TypeScript and Rust runtimes ship. Three copies of
|
||||
one value is how they come to disagree, and nothing else here would notice.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from hanzo_tools.cloud import operations, reach, services
|
||||
from hanzo_tools.cloud.cloud_tool import _CATALOG
|
||||
|
||||
|
||||
def test_the_fleet_is_whole() -> None:
|
||||
assert len(services()) > 100, f"expected the fleet, got {len(services())}"
|
||||
assert reach() > 1000, f"expected the fleet's operations, got {reach()}"
|
||||
|
||||
|
||||
def test_a_subsystem_carries_its_operations() -> None:
|
||||
assert "iam" in services()
|
||||
assert "get_iam_users" in operations("iam")
|
||||
|
||||
|
||||
def test_what_the_fleet_withholds_is_absent() -> None:
|
||||
# The rule is applied once, in cloud, and reaches here through the generated
|
||||
# file: reading who holds a role is not the same act as granting one.
|
||||
assert "post_iam_users" not in operations("iam")
|
||||
assert "delete_iam_users" not in operations("iam")
|
||||
|
||||
|
||||
def test_a_name_the_fleet_dropped_is_not_offered() -> None:
|
||||
# paas became platform and storage became s3. A client still naming the old
|
||||
# ones describes an API that has moved.
|
||||
assert "paas" not in services()
|
||||
assert "storage" not in services()
|
||||
assert "platform" in services()
|
||||
|
||||
|
||||
def test_an_unserved_subsystem_is_refused_rather_than_called() -> None:
|
||||
from hanzo_tools.cloud import call
|
||||
|
||||
with pytest.raises(ValueError, match="does not serve"):
|
||||
call("paas", "anything")
|
||||
|
||||
|
||||
def test_every_runtime_ships_the_same_catalog() -> None:
|
||||
"""The three runtimes must not drift.
|
||||
|
||||
Skipped rather than failed when a sibling is not checked out — absence of a
|
||||
checkout is not evidence of disagreement, and a test that fails on a partial
|
||||
workspace is a test people learn to ignore.
|
||||
"""
|
||||
mine = hashlib.sha256(_CATALOG.read_bytes()).hexdigest()
|
||||
root = Path(__file__).resolve().parents[4]
|
||||
siblings = {
|
||||
"@hanzo/mcp": root / "mcp" / "src" / "tools" / "catalog.json",
|
||||
"cloud": root / "cloud" / "fleet" / "mcp.json",
|
||||
}
|
||||
checked = 0
|
||||
for name, path in siblings.items():
|
||||
if not path.exists():
|
||||
continue
|
||||
checked += 1
|
||||
assert hashlib.sha256(path.read_bytes()).hexdigest() == mine, (
|
||||
f"{name} ships a different catalog than this package. "
|
||||
f"Regenerate them together: `pnpm sync:catalog` in hanzoai/mcp."
|
||||
)
|
||||
if checked == 0:
|
||||
pytest.skip("no sibling runtime checked out beside this one")
|
||||
|
||||
|
||||
def test_the_catalog_is_the_shape_the_runtimes_read() -> None:
|
||||
raw = json.loads(_CATALOG.read_text())
|
||||
assert all(sorted(entry) == ["ops"] for entry in raw.values())
|
||||
assert all(entry["ops"] for entry in raw.values()), "an empty enum is a tool nobody can call"
|
||||
Reference in New Issue
Block a user