Files
python-sdk/pkg/hanzo-tools-api/hanzo_tools/api/api_tool.py
T
zooqueenandHanzo Dev b144973bfc fix(lint): resolve all ruff + black errors across all packages
Fix 35 ruff errors (E722, F841, B904, B905, B007, E741, S202, S310,
S306, S110, TID251, UP042, F401) and run black on 526 files.

Co-authored-by: Hanzo Dev <[email protected]>
2026-02-21 23:32:32 -08:00

669 lines
22 KiB
Python

"""Unified API tool for calling any REST API.
This is a thin MCP wrapper over APIClient. The real logic lives in client.py.
Provides a single tool interface for:
- Managing API credentials
- Loading OpenAPI specs
- Calling API operations
- Making raw HTTP requests
"""
import json
import logging
from typing import Annotated, 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, auto_timeout, create_tool_context
from .client import APIClient, get_api_client
from .errors import APIError
from .models import APICallResult, OperationListResult, ProviderListResult
logger = logging.getLogger(__name__)
def _format_provider_list(result: ProviderListResult) -> str:
"""Format provider list for display."""
lines = ["=== API Providers ===", ""]
configured = [p for p in result.providers if p.configured]
unconfigured = [p for p in result.providers if not p.configured]
if configured:
lines.append("Configured:")
for p in sorted(configured, key=lambda x: x.name):
source = f"[{p.source.value}]" if p.source else ""
lines.append(f" {p.name}: {p.display_name} {source}")
if unconfigured:
lines.append("\nAvailable (not configured):")
for p in sorted(unconfigured, key=lambda x: x.name)[:20]: # Limit display
lines.append(f" {p.name}: {p.display_name}")
if len(unconfigured) > 20:
lines.append(f" ... and {len(unconfigured) - 20} more")
lines.append("")
lines.append(
f"Total: {result.total_count} providers, {result.configured_count} configured"
)
lines.append("")
lines.append("Use 'api --provider <name>' for details")
lines.append(
"Use 'api --action config --provider <name> --api_key <key>' to configure"
)
return "\n".join(lines)
def _format_operation_list(result: OperationListResult, provider: str) -> str:
"""Format operation list for display."""
if not result.operations:
return f"No operations found for {provider}. Try loading the spec first."
lines = [f"=== {provider} Operations ==="]
# Group by tag
tags: dict[str, list] = {}
for op in result.operations:
for t in op.tags or ["untagged"]:
if t not in tags:
tags[t] = []
tags[t].append(op)
for tag_name, tag_ops in sorted(tags.items()):
lines.append(f"\n[{tag_name}]")
for op in tag_ops[:10]: # Limit per tag
lines.append(f" {op.operation_id}: {op.method} {op.path}")
if op.summary:
lines.append(f" {op.summary[:80]}")
if len(tag_ops) > 10:
lines.append(f" ... and {len(tag_ops) - 10} more")
lines.append(f"\nTotal: {result.total_count} operations")
if tags:
lines.append(f"Tags: {', '.join(sorted(tags.keys()))}")
return "\n".join(lines)
def _format_api_result(result: APICallResult) -> str:
"""Format API call result for display."""
lines = [f"Status: {result.status_code} {'✓' if result.success else '✗'}"]
if result.body is not None:
if isinstance(result.body, dict):
formatted = json.dumps(result.body, indent=2)
else:
formatted = str(result.body)
# Truncate if too long
if len(formatted) > 5000:
formatted = formatted[:5000] + "\n... (truncated)"
lines.append("")
lines.append(formatted)
return "\n".join(lines)
@final
class APITool(BaseTool):
"""Unified tool for calling any REST API.
This is a thin wrapper over APIClient. See client.py for the real logic.
Actions:
- list: List available/configured providers
- config: Configure credentials for a provider
- delete: Remove stored credentials
- spec: Load/refresh OpenAPI spec
- ops: List available operations for a provider
- call: Call an API operation
- raw: Make a raw HTTP request
"""
name = "api"
def __init__(self, client: APIClient | None = None):
"""Initialize with optional client."""
self._client = client
@property
def client(self) -> APIClient:
"""Get the API client (lazy initialization)."""
if self._client is None:
self._client = get_api_client()
return self._client
@property
@override
def description(self) -> str:
return """Generic API tool for calling any REST API via OpenAPI specs.
Supports searching, exploring, and dynamically using ANY public API.
Actions:
- list: Show all providers and their configuration status
- config: Set credentials for a provider
- delete: Remove credentials for a provider
- spec: Load/refresh OpenAPI spec for a provider
- ops: List available operations for a provider
- call: Call an API operation by ID
- raw: Make a raw HTTP request to any endpoint
- search: Search for APIs in public registries (openapisearch.com)
- register: Register a custom API from any OpenAPI spec URL
- overview: Get agent-friendly overview of an API
- preload: Download and cache common OpenAPI specs
DISCOVER ANY API:
api --action search --search notion
api --action register --provider notion --spec_url <url>
api --action overview --provider notion
api --action call --provider notion --operation <op>
BUILT-IN PROVIDERS:
Auto-detects credentials from environment variables:
- Cloudflare: CLOUDFLARE_API_TOKEN, CF_API_TOKEN, CLOUDFLARE_API_KEY
- GitHub: GITHUB_TOKEN, GH_TOKEN
- Stripe: STRIPE_API_KEY, STRIPE_SECRET_KEY
- OpenAI: OPENAI_API_KEY
- Anthropic: ANTHROPIC_API_KEY
- etc. (26+ providers)
Configure manually:
api --action config --provider cloudflare --api_key "your-key"
MAKING CALLS:
api --action call --provider cloudflare --operation zones-get
api --action raw --provider github --method GET --path /user
Examples:
api # List all providers
api --action search --search weather # Search for weather APIs
api --action register --provider petstore --spec_url https://petstore.swagger.io/v2/swagger.json
api --action overview --provider cloudflare # Agent-friendly summary
api --action ops --provider cloudflare --search zones
api --action call --provider cloudflare --operation zones-get
"""
@override
@auto_timeout("api")
async def call(
self,
ctx: MCPContext,
action: str = "list",
provider: str | None = None,
# Config params
api_key: str | None = None,
api_secret: str | None = None,
account_id: str | None = None,
base_url: str | None = None,
# Spec params
spec_url: str | None = None,
force_refresh: bool = False,
# Operation params
operation: str | None = None,
params: str | None = None, # JSON string
body: str | None = None, # JSON string
# Raw call params
method: str = "GET",
path: str | None = None,
# List/search params
search: str | None = None,
tag: str | None = None,
configured_only: bool = False,
**kwargs,
) -> str:
"""Execute API action."""
tool_ctx = create_tool_context(ctx)
await tool_ctx.set_tool_info(self.name)
try:
if action == "list":
return await self._handle_list(provider, configured_only)
elif action == "config":
return await self._handle_config(
provider, api_key, api_secret, account_id, base_url
)
elif action == "delete":
return await self._handle_delete(provider)
elif action == "spec":
return await self._handle_spec(provider, spec_url, force_refresh)
elif action == "ops":
return await self._handle_ops(provider, search, tag)
elif action == "call":
return await self._handle_call(provider, operation, params, body)
elif action == "raw":
return await self._handle_raw(provider, method, path, params, body)
elif action == "search":
return await self._handle_search(search)
elif action == "register":
return await self._handle_register(
provider,
spec_url,
base_url,
api_key,
kwargs.get("auth_type", "bearer"),
)
elif action == "overview":
return await self._handle_overview(provider)
elif action == "preload":
return await self._handle_preload()
else:
return f"Unknown action: {action}. Valid: list, config, delete, spec, ops, call, raw, search, register, overview, preload"
except APIError as e:
# Structured error with hints
return str(e)
except Exception as e:
logger.exception(f"API tool error: {e}")
return f"Error: {e}"
async def _handle_list(self, provider: str | None, configured_only: bool) -> str:
"""List providers and their status."""
if provider:
# Show details for specific provider
try:
status = await self.client.get_provider(provider)
lines = [f"=== {provider} ==="]
lines.append(f"Display name: {status.display_name}")
lines.append(f"Configured: {status.configured}")
if status.source:
lines.append(f"Source: {status.source.value}")
if status.base_url:
lines.append(f"Base URL: {status.base_url}")
if status.auth_type:
lines.append(f"Auth type: {status.auth_type}")
if status.spec_url:
lines.append(f"Spec URL: {status.spec_url}")
if status.env_vars:
lines.append(f"Env vars: {', '.join(status.env_vars)}")
return "\n".join(lines)
except Exception as e:
return f"Error getting provider info: {e}"
# List all providers
result = await self.client.list_providers(configured_only=configured_only)
return _format_provider_list(result)
async def _handle_config(
self,
provider: str | None,
api_key: str | None,
api_secret: str | None,
account_id: str | None,
base_url: str | None,
) -> str:
"""Configure credentials for a provider."""
if not provider:
return "Error: --provider required for config action"
if not api_key:
return "Error: --api_key required for config action"
await self.client.config(
provider=provider,
api_key=api_key,
api_secret=api_secret,
account_id=account_id,
base_url=base_url,
)
return f"Configured credentials for {provider}"
async def _handle_delete(self, provider: str | None) -> str:
"""Delete credentials for a provider."""
if not provider:
return "Error: --provider required for delete action"
if await self.client.delete_config(provider):
return f"Deleted credentials for {provider}"
return f"No stored credentials for {provider}"
async def _handle_spec(
self,
provider: str | None,
spec_url: str | None,
force_refresh: bool,
) -> str:
"""Load or refresh OpenAPI spec."""
if not provider:
return "Error: --provider required for spec action"
try:
count = await self.client.spec(
provider=provider,
spec_url=spec_url,
force_refresh=force_refresh,
)
return f"Loaded spec for {provider}: {count} operations"
except Exception as e:
return f"Error loading spec: {e}"
async def _handle_ops(
self,
provider: str | None,
search: str | None,
tag: str | None,
) -> str:
"""List operations for a provider."""
if not provider:
return "Error: --provider required for ops action"
try:
result = await self.client.ops(
provider=provider,
search=search,
tag=tag,
)
return _format_operation_list(result, provider)
except Exception as e:
return f"Error listing operations: {e}"
async def _handle_call(
self,
provider: str | None,
operation: str | None,
params: str | None,
body: str | None,
) -> str:
"""Call an API operation."""
if not provider:
return "Error: --provider required for call action"
if not operation:
return "Error: --operation required for call action"
try:
# Parse params and body
parsed_params = json.loads(params) if params else None
parsed_body = json.loads(body) if body else None
result = await self.client.call(
provider=provider,
operation_id=operation,
params=parsed_params,
body=parsed_body,
)
return _format_api_result(result)
except json.JSONDecodeError as e:
return f"Error parsing JSON: {e}"
except Exception as e:
return f"Error calling operation: {e}"
async def _handle_raw(
self,
provider: str | None,
method: str,
path: str | None,
params: str | None,
body: str | None,
) -> str:
"""Make a raw API request."""
if not provider:
return "Error: --provider required for raw action"
if not path:
return "Error: --path required for raw action"
try:
# Parse params and body
parsed_params = json.loads(params) if params else None
parsed_body = json.loads(body) if body else None
result = await self.client.raw(
provider=provider,
method=method,
path=path,
params=parsed_params,
body=parsed_body,
)
return _format_api_result(result)
except json.JSONDecodeError as e:
return f"Error parsing JSON: {e}"
except Exception as e:
return f"Error making request: {e}"
async def _handle_search(self, query: str | None) -> str:
"""Search for APIs in public registries."""
if not query:
return "Error: --search <query> required for search action"
try:
results = await self.client.search(query)
if not results:
return f"No APIs found matching '{query}'"
lines = [f"=== API Search: {query} ===", ""]
for r in results[:20]: # Limit results
name = r.get("name") or r.get("id", "Unknown")
desc = r.get("description", "")[:60]
spec_url = r.get("spec_url", "")
lines.append(f" {r.get('id', name)}: {name}")
if desc:
lines.append(f" {desc}")
if spec_url:
lines.append(f" Spec: {spec_url}")
lines.append("")
lines.append(f"Found {len(results)} API(s)")
lines.append("")
lines.append("To use an API:")
lines.append(" api --action register --provider <id> --spec_url <url>")
return "\n".join(lines)
except Exception as e:
return f"Error searching: {e}"
async def _handle_register(
self,
name: str | None,
spec_url: str | None,
base_url: str | None,
api_key: str | None,
auth_type: str,
) -> str:
"""Register a custom API provider."""
if not name:
return "Error: --provider <name> required for register action"
if not spec_url:
return "Error: --spec_url required for register action"
try:
count = await self.client.register(
name=name,
spec_url=spec_url,
base_url=base_url,
api_key=api_key,
auth_type=auth_type,
)
lines = [
f"Registered API: {name}",
f"Loaded {count} operations from spec",
"",
"Next steps:",
f" api --action overview --provider {name}",
f" api --action ops --provider {name}",
]
if not api_key:
lines.append(
f" api --action config --provider {name} --api_key <your-key>"
)
return "\n".join(lines)
except Exception as e:
return f"Error registering API: {e}"
async def _handle_overview(self, provider: str | None) -> str:
"""Get API overview."""
if not provider:
return "Error: --provider required for overview action"
try:
return await self.client.overview(provider)
except Exception as e:
return f"Error getting overview: {e}"
async def _handle_preload(self) -> str:
"""Preload and cache common OpenAPI specs."""
try:
results = await self.client.preload_specs()
lines = ["=== Preloading OpenAPI Specs ===", ""]
success = []
failed = []
for name, count in sorted(results.items()):
if count >= 0:
success.append(f" {name}: {count} operations")
else:
failed.append(f" {name}: failed")
if success:
lines.append("Loaded:")
lines.extend(success)
if failed:
lines.append("\nFailed:")
lines.extend(failed)
lines.append(f"\nTotal: {len(success)} loaded, {len(failed)} failed")
lines.append("Cache: ~/.hanzo/api/specs/")
return "\n".join(lines)
except Exception as e:
return f"Error preloading specs: {e}"
def register(self, mcp_server: FastMCP) -> None:
"""Register with MCP server."""
tool_instance = self
@mcp_server.tool()
async def api(
action: Annotated[
str,
Field(
description="Action: list, config, delete, spec, ops, call, raw",
default="list",
),
] = "list",
provider: Annotated[
str | None,
Field(description="Provider name (e.g., cloudflare, github)"),
] = None,
api_key: Annotated[
str | None,
Field(description="API key or token for config action"),
] = None,
api_secret: Annotated[
str | None,
Field(description="API secret (if needed) for config action"),
] = None,
account_id: Annotated[
str | None,
Field(description="Account/org ID for config action"),
] = None,
base_url: Annotated[
str | None,
Field(description="Override base URL for provider"),
] = None,
spec_url: Annotated[
str | None,
Field(description="URL to OpenAPI spec for spec action"),
] = None,
force_refresh: Annotated[
bool,
Field(description="Force refresh cached spec"),
] = False,
operation: Annotated[
str | None,
Field(description="Operation ID for call action"),
] = None,
params: Annotated[
str | None,
Field(description="JSON parameters for call/raw action"),
] = None,
body: Annotated[
str | None,
Field(description="JSON body for call/raw action"),
] = None,
method: Annotated[
str,
Field(description="HTTP method for raw action"),
] = "GET",
path: Annotated[
str | None,
Field(description="URL path for raw action"),
] = None,
search: Annotated[
str | None,
Field(description="Search filter for ops action"),
] = None,
tag: Annotated[
str | None,
Field(description="Tag filter for ops action"),
] = None,
configured_only: Annotated[
bool,
Field(description="Only list configured providers"),
] = False,
ctx: MCPContext = None,
) -> str:
"""Generic API tool for calling any REST API via OpenAPI specs.
Manage credentials and call APIs for various cloud providers.
Auto-detects credentials from environment variables.
Actions:
- list: Show all providers and their status
- config: Set credentials for a provider
- delete: Remove credentials for a provider
- spec: Load/refresh OpenAPI spec for a provider
- ops: List available operations for a provider
- call: Call an API operation by operation ID
- raw: Make a raw HTTP request
"""
return await tool_instance.call(
ctx,
action=action,
provider=provider,
api_key=api_key,
api_secret=api_secret,
account_id=account_id,
base_url=base_url,
spec_url=spec_url,
force_refresh=force_refresh,
operation=operation,
params=params,
body=body,
method=method,
path=path,
search=search,
tag=tag,
configured_only=configured_only,
)