mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
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]>
209 lines
7.3 KiB
Python
209 lines
7.3 KiB
Python
"""Unified Hanzo platform tool, projected from cloud's OpenAPI registry.
|
|
|
|
One MCP tool reaches every service cloud serves. The service/action surface is
|
|
derived from ``/v1/openapi.json`` at runtime rather than hand-listed here, so a
|
|
newly mounted app is callable the moment cloud serves it — no Python release,
|
|
and no list that silently drifts behind the platform.
|
|
|
|
One tool, not one per service: agents degrade badly with hundreds of tools, and
|
|
the spec already carries the routing information a dispatcher needs.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from typing import Annotated, Any, final, override
|
|
|
|
from mcp.server import FastMCP
|
|
from mcp.server.fastmcp import Context as MCPContext
|
|
from pydantic import Field
|
|
|
|
from hanzo_tools.core import BaseTool, HanzoCloud, auto_timeout, create_tool_context
|
|
|
|
from . import spec as openapi
|
|
|
|
# Names that address the catalog itself rather than a cloud service.
|
|
CATALOG_SERVICES = frozenset({"services", "list"})
|
|
REFRESH_SERVICES = frozenset({"refresh", "reload"})
|
|
|
|
# Convenience synonyms for services whose product tag is not the obvious word.
|
|
SERVICE_ALIASES: dict[str, str] = {
|
|
"identity": "iam",
|
|
"payments": "billing",
|
|
"knowledge": "kb",
|
|
"platform": "paas",
|
|
"rag": "kb",
|
|
}
|
|
|
|
|
|
@final
|
|
class HanzoTool(BaseTool):
|
|
"""Unified tool for every Hanzo cloud service."""
|
|
|
|
name = "hanzo"
|
|
|
|
def __init__(self) -> None:
|
|
self._catalog: openapi.Catalog | None = None
|
|
self._source: str = ""
|
|
self._cloud: HanzoCloud | None = None
|
|
|
|
@property
|
|
@override
|
|
def description(self) -> str:
|
|
return """Unified Hanzo platform tool — every service cloud serves.
|
|
|
|
The service/action surface is generated from cloud's live OpenAPI registry, so
|
|
it always matches what the platform actually serves.
|
|
|
|
Parameters:
|
|
- service: Product tag (the first path segment after /v1/). "services" lists them.
|
|
- action: Path within the service ("plans" -> /v1/billing/plans). Omit to list
|
|
a service's actions.
|
|
- params: JSON object string; sent as the body for POST/PUT/PATCH, else as the
|
|
query string. Values also fill {templated} path segments.
|
|
- method: Override the HTTP method when an action serves several.
|
|
|
|
Examples:
|
|
hanzo(service="services")
|
|
hanzo(service="billing")
|
|
hanzo(service="billing", action="plans")
|
|
hanzo(service="kb", action="search", params='{"query":"authentication oauth jwt"}')
|
|
hanzo(service="refresh")
|
|
"""
|
|
|
|
def _get_catalog(self, refresh: bool = False) -> openapi.Catalog:
|
|
if self._catalog is None or refresh:
|
|
document, source = openapi.load(refresh=refresh)
|
|
self._catalog = openapi.Catalog(document)
|
|
self._source = source
|
|
return self._catalog
|
|
|
|
def _get_cloud(self) -> HanzoCloud:
|
|
if self._cloud is None:
|
|
self._cloud = HanzoCloud()
|
|
return self._cloud
|
|
|
|
@staticmethod
|
|
def _normalize(service: str) -> str:
|
|
key = (service or "").strip().lower()
|
|
return SERVICE_ALIASES.get(key, key)
|
|
|
|
@staticmethod
|
|
def _parse_params(params: str | None) -> dict[str, Any]:
|
|
if not params:
|
|
return {}
|
|
try:
|
|
parsed = json.loads(params)
|
|
except json.JSONDecodeError as exc:
|
|
raise ValueError(f"params must be a JSON object string: {exc}") from exc
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError("params must decode to a JSON object")
|
|
return parsed
|
|
|
|
@override
|
|
@auto_timeout("hanzo")
|
|
async def call(
|
|
self,
|
|
ctx: MCPContext,
|
|
service: str = "services",
|
|
action: str = "",
|
|
params: str | dict[str, Any] | None = None,
|
|
method: str | None = None,
|
|
**kwargs: Any,
|
|
) -> str:
|
|
tool_ctx = create_tool_context(ctx)
|
|
await tool_ctx.set_tool_info(self.name)
|
|
|
|
key = self._normalize(service)
|
|
try:
|
|
if key in REFRESH_SERVICES:
|
|
catalog = self._get_catalog(refresh=True)
|
|
return self._dump(
|
|
{
|
|
"refreshed": True,
|
|
"source": self._source,
|
|
"services": len(catalog.services),
|
|
}
|
|
)
|
|
|
|
catalog = self._get_catalog()
|
|
|
|
if key in CATALOG_SERVICES:
|
|
summary = catalog.summary()
|
|
return self._dump(
|
|
{
|
|
"source": self._source,
|
|
"count": len(summary),
|
|
"services": summary,
|
|
"usage": 'hanzo(service="billing", action="plans")',
|
|
}
|
|
)
|
|
|
|
supplied = params if isinstance(params, dict) else self._parse_params(params)
|
|
supplied = {**supplied, **{k: v for k, v in kwargs.items() if v is not None}}
|
|
|
|
if not action:
|
|
return self._dump(catalog.describe(key))
|
|
|
|
route = catalog.resolve(key, action, method, has_params=bool(supplied))
|
|
query, body = openapi.split_params(route, supplied)
|
|
data = await self._get_cloud().call(route.method, route.path, query, body)
|
|
return self._dump(
|
|
{"service": key, "request": f"{route.method} {route.path}", "data": data}
|
|
)
|
|
except Exception as exc:
|
|
return self._dump(self._error(key, exc))
|
|
|
|
def _error(self, service: str, exc: Exception) -> dict[str, Any]:
|
|
out: dict[str, Any] = {"error": str(exc), "service": service}
|
|
if self._catalog is not None and service not in self._catalog.services:
|
|
out["hint"] = 'call hanzo(service="services") to list services'
|
|
return out
|
|
|
|
@staticmethod
|
|
def _dump(payload: dict[str, Any]) -> str:
|
|
return json.dumps(payload, indent=2, default=str)
|
|
|
|
def register(self, mcp_server: FastMCP) -> None:
|
|
"""Register the single unified hanzo tool."""
|
|
tool_instance = self
|
|
|
|
@mcp_server.tool(name=self.name, description=self.description)
|
|
async def hanzo(
|
|
service: Annotated[
|
|
str,
|
|
Field(
|
|
description=(
|
|
"Product tag, e.g. billing, iam, kb, paas, git. "
|
|
'Use "services" to list them, "refresh" to refetch the spec.'
|
|
)
|
|
),
|
|
] = "services",
|
|
action: Annotated[
|
|
str,
|
|
Field(
|
|
description=(
|
|
"Path within the service, e.g. 'plans' for "
|
|
"/v1/billing/plans. Omit to list the service's actions."
|
|
)
|
|
),
|
|
] = "",
|
|
params: Annotated[
|
|
str | None,
|
|
Field(
|
|
description=(
|
|
"JSON object string. Body for POST/PUT/PATCH, else query "
|
|
'string. Example: "{\\"query\\":\\"oauth jwt\\"}"'
|
|
)
|
|
),
|
|
] = None,
|
|
method: Annotated[
|
|
str | None,
|
|
Field(description="Override HTTP method (GET/POST/PUT/PATCH/DELETE)."),
|
|
] = None,
|
|
ctx: MCPContext = None, # type: ignore[assignment]
|
|
) -> str:
|
|
return await tool_instance.call(
|
|
ctx, service=service, action=action, params=params, method=method
|
|
)
|