mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-23 20:23:00 +00:00
Run ruff check --fix and ruff format across the repo to fix ~495 auto-fixable lint errors (mostly I001 import sorting) and reformat 225 files to match the py312 target. Co-authored-by: Hanzo Dev <[email protected]>
657 lines
22 KiB
Python
657 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,
|
|
)
|