mirror of
https://github.com/hanzoai/python-sdk.git
synced 2026-10-27 04:20:09 +00:00
310 lines
11 KiB
Python
310 lines
11 KiB
Python
"""MCP tool for Traefik ingress inspection and PaaS domain management.
|
|
|
|
Traefik actions query the Traefik API for routers, services, middlewares,
|
|
entrypoints, and dashboard overview. Domain actions use the PaaS API to
|
|
manage custom domains, verify DNS, and check TLS certificate status.
|
|
|
|
Auth: Uses HanzoSession from hanzo-tools-auth for authenticated API calls.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from typing import Any, Annotated, final
|
|
|
|
from pydantic import Field
|
|
from mcp.server import FastMCP
|
|
from mcp.server.fastmcp import Context as MCPContext
|
|
|
|
from hanzo_tools.core.base import BaseTool
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
DESCRIPTION = """Traefik ingress inspection and PaaS domain management.
|
|
|
|
Requires authentication via `hanzo login` (stored at ~/.hanzo/auth/token.json).
|
|
|
|
Traefik actions:
|
|
- routers: List HTTP routers
|
|
- services: List HTTP services
|
|
- middlewares: List middlewares
|
|
- entrypoints: List entrypoints
|
|
- overview: Dashboard overview stats
|
|
- tcp_routers: List TCP routers
|
|
|
|
Domain actions (PaaS):
|
|
- domains: List custom domains for a project
|
|
- add_domain: Add a custom domain (params: project_id, domain)
|
|
- remove_domain: Remove a custom domain (params: project_id, domain)
|
|
- verify_domain: Verify domain DNS (params: project_id, domain)
|
|
- tls_status: Get TLS certificate status (params: domain)
|
|
"""
|
|
|
|
|
|
def _get_session():
|
|
"""Get HanzoSession singleton."""
|
|
from hanzo_tools.auth.session import HanzoSession
|
|
return HanzoSession.get()
|
|
|
|
|
|
@final
|
|
class IngressTool(BaseTool):
|
|
"""MCP tool for Traefik ingress and domain operations."""
|
|
|
|
@property
|
|
def name(self) -> str:
|
|
return "ingress"
|
|
|
|
@property
|
|
def description(self) -> str:
|
|
return DESCRIPTION
|
|
|
|
async def call(
|
|
self,
|
|
ctx: MCPContext,
|
|
action: str = "overview",
|
|
project_id: str | None = None,
|
|
domain: str | None = None,
|
|
**kwargs: Any,
|
|
) -> str:
|
|
try:
|
|
# Traefik actions
|
|
if action == "routers":
|
|
return await self._routers()
|
|
elif action == "services":
|
|
return await self._services()
|
|
elif action == "middlewares":
|
|
return await self._middlewares()
|
|
elif action == "entrypoints":
|
|
return await self._entrypoints()
|
|
elif action == "overview":
|
|
return await self._overview()
|
|
elif action == "tcp_routers":
|
|
return await self._tcp_routers()
|
|
# Domain actions
|
|
elif action == "domains":
|
|
return await self._domains(project_id)
|
|
elif action == "add_domain":
|
|
return await self._add_domain(project_id, domain)
|
|
elif action == "remove_domain":
|
|
return await self._remove_domain(project_id, domain)
|
|
elif action == "verify_domain":
|
|
return await self._verify_domain(project_id, domain)
|
|
elif action == "tls_status":
|
|
return await self._tls_status(domain)
|
|
else:
|
|
return json.dumps({
|
|
"error": f"Unknown action: {action}",
|
|
"available": [
|
|
"routers", "services", "middlewares", "entrypoints",
|
|
"overview", "tcp_routers",
|
|
"domains", "add_domain", "remove_domain",
|
|
"verify_domain", "tls_status",
|
|
],
|
|
})
|
|
except RuntimeError as e:
|
|
return json.dumps({"error": str(e)})
|
|
except Exception as e:
|
|
logger.exception(f"Ingress tool error: {e}")
|
|
return json.dumps({"error": f"Ingress error: {e}"})
|
|
|
|
# -- Traefik actions -----------------------------------------------------
|
|
|
|
async def _traefik_get(self, path: str) -> Any:
|
|
"""GET from the Traefik API via the PaaS gateway."""
|
|
session = _get_session()
|
|
paas = session.get_paas_client()
|
|
return paas.get(f"/v1/ingress{path}")
|
|
|
|
async def _routers(self) -> str:
|
|
data = await self._traefik_get("/http/routers")
|
|
routers = data if isinstance(data, list) else []
|
|
result = []
|
|
for r in routers:
|
|
result.append({
|
|
"name": r.get("name"),
|
|
"rule": r.get("rule"),
|
|
"service": r.get("service"),
|
|
"entryPoints": r.get("entryPoints"),
|
|
"status": r.get("status"),
|
|
"tls": bool(r.get("tls")),
|
|
"provider": r.get("provider"),
|
|
})
|
|
return json.dumps({"count": len(result), "routers": result}, indent=2)
|
|
|
|
async def _services(self) -> str:
|
|
data = await self._traefik_get("/http/services")
|
|
services = data if isinstance(data, list) else []
|
|
result = []
|
|
for s in services:
|
|
result.append({
|
|
"name": s.get("name"),
|
|
"type": s.get("type"),
|
|
"status": s.get("status"),
|
|
"provider": s.get("provider"),
|
|
"servers": s.get("loadBalancer", {}).get("servers") if isinstance(s.get("loadBalancer"), dict) else None,
|
|
})
|
|
return json.dumps({"count": len(result), "services": result}, indent=2)
|
|
|
|
async def _middlewares(self) -> str:
|
|
data = await self._traefik_get("/http/middlewares")
|
|
middlewares = data if isinstance(data, list) else []
|
|
result = []
|
|
for m in middlewares:
|
|
result.append({
|
|
"name": m.get("name"),
|
|
"type": m.get("type"),
|
|
"status": m.get("status"),
|
|
"provider": m.get("provider"),
|
|
})
|
|
return json.dumps({"count": len(result), "middlewares": result}, indent=2)
|
|
|
|
async def _entrypoints(self) -> str:
|
|
data = await self._traefik_get("/entrypoints")
|
|
entrypoints = data if isinstance(data, list) else []
|
|
result = []
|
|
for ep in entrypoints:
|
|
result.append({
|
|
"name": ep.get("name"),
|
|
"address": ep.get("address"),
|
|
"protocol": ep.get("protocol"),
|
|
})
|
|
return json.dumps({"count": len(result), "entrypoints": result}, indent=2)
|
|
|
|
async def _overview(self) -> str:
|
|
data = await self._traefik_get("/overview")
|
|
return json.dumps(data, indent=2)
|
|
|
|
async def _tcp_routers(self) -> str:
|
|
data = await self._traefik_get("/tcp/routers")
|
|
routers = data if isinstance(data, list) else []
|
|
result = []
|
|
for r in routers:
|
|
result.append({
|
|
"name": r.get("name"),
|
|
"rule": r.get("rule"),
|
|
"service": r.get("service"),
|
|
"entryPoints": r.get("entryPoints"),
|
|
"status": r.get("status"),
|
|
"tls": bool(r.get("tls")),
|
|
})
|
|
return json.dumps({"count": len(result), "tcp_routers": result}, indent=2)
|
|
|
|
# -- Domain actions (PaaS) -----------------------------------------------
|
|
|
|
async def _domains(self, project_id: str | None) -> str:
|
|
if not project_id:
|
|
return json.dumps({"error": "Required: project_id"})
|
|
|
|
session = _get_session()
|
|
paas = session.get_paas_client()
|
|
data = paas.get(f"/v1/project/{project_id}/domain")
|
|
domains = data if isinstance(data, list) else []
|
|
result = []
|
|
for d in domains:
|
|
result.append({
|
|
"id": d.get("id"),
|
|
"domain": d.get("domain"),
|
|
"verified": d.get("verified"),
|
|
"tls": d.get("tls"),
|
|
"created_at": d.get("createdAt"),
|
|
})
|
|
return json.dumps({
|
|
"project_id": project_id,
|
|
"count": len(result),
|
|
"domains": result,
|
|
}, indent=2)
|
|
|
|
async def _add_domain(self, project_id: str | None, domain: str | None) -> str:
|
|
if not project_id or not domain:
|
|
return json.dumps({"error": "Required: project_id and domain"})
|
|
|
|
session = _get_session()
|
|
paas = session.get_paas_client()
|
|
result = paas.post(f"/v1/project/{project_id}/domain", json={"domain": domain})
|
|
return json.dumps({
|
|
"action": "add_domain",
|
|
"project_id": project_id,
|
|
"domain": domain,
|
|
"result": result,
|
|
}, indent=2)
|
|
|
|
async def _remove_domain(self, project_id: str | None, domain: str | None) -> str:
|
|
if not project_id or not domain:
|
|
return json.dumps({"error": "Required: project_id and domain"})
|
|
|
|
session = _get_session()
|
|
paas = session.get_paas_client()
|
|
result = paas.delete(f"/v1/project/{project_id}/domain/{domain}")
|
|
return json.dumps({
|
|
"action": "remove_domain",
|
|
"project_id": project_id,
|
|
"domain": domain,
|
|
"result": result,
|
|
}, indent=2)
|
|
|
|
async def _verify_domain(self, project_id: str | None, domain: str | None) -> str:
|
|
if not project_id or not domain:
|
|
return json.dumps({"error": "Required: project_id and domain"})
|
|
|
|
session = _get_session()
|
|
paas = session.get_paas_client()
|
|
result = paas.post(f"/v1/project/{project_id}/domain/{domain}/verify")
|
|
return json.dumps({
|
|
"action": "verify_domain",
|
|
"project_id": project_id,
|
|
"domain": domain,
|
|
"result": result,
|
|
}, indent=2)
|
|
|
|
async def _tls_status(self, domain: str | None) -> str:
|
|
if not domain:
|
|
return json.dumps({"error": "Required: domain"})
|
|
|
|
session = _get_session()
|
|
paas = session.get_paas_client()
|
|
result = paas.get(f"/v1/domain/{domain}/tls")
|
|
return json.dumps({
|
|
"domain": domain,
|
|
"tls": result,
|
|
}, indent=2)
|
|
|
|
# -- Registration --------------------------------------------------------
|
|
|
|
def register(self, mcp_server: FastMCP) -> None:
|
|
"""Register ingress tool with explicit parameters."""
|
|
tool_instance = self
|
|
|
|
@mcp_server.tool(
|
|
name="ingress",
|
|
description=DESCRIPTION,
|
|
)
|
|
async def ingress(
|
|
action: Annotated[
|
|
str,
|
|
Field(
|
|
description=(
|
|
"Action to perform. "
|
|
"Traefik: routers, services, middlewares, entrypoints, overview, tcp_routers. "
|
|
"Domains: domains, add_domain, remove_domain, verify_domain, tls_status."
|
|
),
|
|
),
|
|
] = "overview",
|
|
project_id: Annotated[
|
|
str | None,
|
|
Field(description="Project ID (for domain actions)"),
|
|
] = None,
|
|
domain: Annotated[
|
|
str | None,
|
|
Field(description="Domain name (for add_domain, remove_domain, verify_domain, tls_status)"),
|
|
] = None,
|
|
ctx: MCPContext = None,
|
|
) -> str:
|
|
return await tool_instance.call(
|
|
ctx,
|
|
action=action,
|
|
project_id=project_id,
|
|
domain=domain,
|
|
)
|