Research-Stack/4-Infrastructure/infra/service-orchestrator/service_orchestrator.py
allaun c3d7516849 fix(infra): purge hardcoded Postgres password from service_orchestrator.py
Replace hardcoded DATABASE_URL default (containing live password) with
required os.environ lookup. The credential must be set at runtime.

Build: N/A (Python shim)
2026-06-28 19:58:35 -05:00

884 lines
33 KiB
Python

#!/usr/bin/env python3
"""Service Orchestrator MCP Server.
Registers/unregisters services across Authentik + Caddy + Credential Server
with a single MCP tool call. Uses PostgreSQL for the service registry and
Valkey for async job queues.
MCP tools:
register_service — register a new service (Authentik provider+app + Caddy route)
unregister_service — remove a service
list_services — list registered services
get_credentials — fetch credentials from the credential server
service_status — get detailed status of a registered service
"""
from __future__ import annotations
import asyncio
import json
import os
import sys
from dataclasses import dataclass, field
from datetime import datetime, timezone
from typing import Any
import httpx
# ─── config ──────────────────────────────────────────────────────────────────
AUTHENTIK_BASE = os.getenv("AUTHENTIK_BASE", "http://localhost:9000")
AUTHENTIK_TOKEN = os.getenv("AUTHENTIK_TOKEN")
CADDY_ADMIN = os.getenv("CADDY_ADMIN", "http://100.101.247.127:2019")
CREDENTIAL_SERVER = os.getenv("CREDENTIAL_SERVER", "http://100.101.247.127:8444")
AUTHORIZATION_FLOW = os.getenv(
"AUTHORIZATION_FLOW",
"6e3e666f-3295-4e7e-907f-f344de3e43c9",
)
INVALIDATION_FLOW = os.getenv(
"INVALIDATION_FLOW",
"5c673a74-cdba-428d-a2c9-f758a88492b9",
)
# PostgreSQL + Valkey config
DATABASE_URL = os.environ["DATABASE_URL"]
VALKEY_URL = os.getenv("VALKEY_URL", "redis://localhost:6379/0")
# ─── domain ──────────────────────────────────────────────────────────────────
BASE_DOMAIN = "researchstack.info"
# Known upstream targets for reverse proxy resolution
UPSTREAM_TARGETS = {
"nixos-laptop": "100.102.173.61",
"nixos-steamdeck": "100.85.244.73",
"microvm-racknerd": "100.101.247.127",
"361395-1": "100.72.130.76",
"qfox-1": "100.88.57.96",
}
@dataclass
class ServiceManifest:
name: str
slug: str
domain: str
internal_host: str
internal_port: int
upstream_node: str = ""
auth_required: bool = True
public: bool = False
description: str = "" # noqa: DJ008
@dataclass
class RegisteredService:
slug: str
name: str
domain: str
internal_host: str
internal_port: int
authentik_provider: str
authentik_application: str
caddy_route_path: str
created_at: str
enabled: bool = True
# ─── Authentik client ────────────────────────────────────────────────────────
class AuthentikClient:
def __init__(self, base: str, token: str) -> None:
self.base = base.rstrip("/")
self.headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
}
async def _request(
self,
method: str,
path: str,
json_data: dict[str, Any] | None = None,
) -> dict[str, Any]:
async with httpx.AsyncClient(
verify=False,
headers=self.headers,
timeout=30,
) as client:
url = f"{self.base}{path}"
resp = await client.request(method, url, json=json_data)
if resp.status_code >= 400:
try:
detail = resp.json()
except Exception:
detail = resp.text
raise RuntimeError(
f"Authentik API error {resp.status_code} for {method} {path}: {detail}"
)
if resp.status_code == 204 or not resp.content:
return {}
return resp.json()
async def create_proxy_provider(
self,
name: str,
internal_host: str,
external_host: str,
) -> str:
data = {
"name": name,
"authorization_flow": AUTHORIZATION_FLOW,
"invalidation_flow": INVALIDATION_FLOW,
"internal_host": internal_host,
"external_host": external_host,
"mode": "forward_domain",
"access_token_validity": "hours=24",
"refresh_token_validity": "hours=720",
}
result = await self._request("POST", "/api/v3/providers/proxy/", data)
return result["pk"]
async def create_application(self, name: str, slug: str, provider_pk: str) -> str:
data = {
"name": name,
"slug": slug,
"provider": provider_pk,
"meta_launch_url": f"https://{slug}.{BASE_DOMAIN}",
}
result = await self._request("POST", "/api/v3/core/applications/", data)
return result["pk"]
async def delete_provider(self, pk: str) -> None:
try:
await self._request("DELETE", f"/api/v3/providers/proxy/{pk}/")
except httpx.HTTPStatusError as e:
if e.response.status_code != 404:
raise
async def delete_application(self, slug: str) -> None:
try:
await self._request("DELETE", f"/api/v3/core/applications/{slug}/")
except httpx.HTTPStatusError as e:
if e.response.status_code != 404:
raise
async def list_applications(self) -> list[dict[str, Any]]:
result = await self._request("GET", "/api/v3/core/applications/")
return result.get("results", [])
async def list_providers(self) -> list[dict[str, Any]]:
result = await self._request("GET", "/api/v3/providers/all/")
return result.get("results", [])
# ─── Caddy admin client ──────────────────────────────────────────────────────
class CaddyClient:
def __init__(self, admin_url: str) -> None:
self.admin_url = admin_url.rstrip("/")
async def _request(
self,
method: str,
path: str,
json_data: Any = None,
) -> dict[str, Any]:
async with httpx.AsyncClient(verify=False, timeout=30) as client:
url = f"{self.admin_url}{path}"
resp = await client.request(method, url, json=json_data)
if resp.status_code not in (200, 201, 204):
raise RuntimeError(f"Caddy API error {resp.status_code}: {resp.text}")
if resp.content:
return resp.json()
return {}
async def add_route(
self,
hostname: str,
upstream: str,
auth_required: bool = True,
) -> str:
route_path = f"/{hostname.replace('.', '_')}_route"
matcher = {"host": [hostname]}
if auth_required:
# Replicate the forward_auth expansion that the Caddyfile adapter produces
# using only built-in handlers (forward_auth module not compiled in).
# Route 0: reverse_proxy to Authentik for auth decision
# Route 1: reverse_proxy to actual upstream
auth_headers = [
"X-Authentik-Email", "X-Authentik-Jwt", "X-Authentik-Meta-App",
"X-Authentik-Meta-Jwt", "X-Authentik-Meta-Version",
"X-Authentik-Name", "X-Authentik-Uid", "X-Authentik-Username",
]
copy_routes: list[dict[str, Any]] = [
{"handle": [{"handler": "vars"}]},
]
for hdr in auth_headers:
copy_routes.append({
"handle": [{"handler": "headers", "request": {"delete": [hdr]}}],
})
copy_routes.append({
"handle": [{"handler": "headers", "request": {"set": {hdr: [f"{{http.reverse_proxy.header.{hdr}}}"]}}}],
"match": [{"not": [{"vars": {f"{{http.reverse_proxy.header.{hdr}}}": [""]}}]}],
})
route = {
"group": hostname,
"match": [matcher],
"handle": [{
"handler": "subroute",
"routes": [
{
"handle": [{
"handler": "reverse_proxy",
"rewrite": {
"method": "GET",
"uri": "/outpost.goauthentik.io/auth/caddy",
},
"headers": {
"request": {
"set": {
"X-Forwarded-Method": ["{http.request.method}"],
"X-Forwarded-Uri": ["{http.request.uri}"],
},
},
},
"handle_response": [{
"match": {"status_code": [2]},
"routes": copy_routes,
}],
"upstreams": [{"dial": "100.102.173.61:9000"}],
}],
},
{
"handle": [{
"handler": "reverse_proxy",
"headers": {
"request": {
"set": {
"Host": ["{http.request.host}"],
"X-Forwarded-For": ["{http.request.remote}"],
"X-Forwarded-Host": ["{http.request.host}"],
"X-Forwarded-Proto": ["{http.request.scheme}"],
"X-Real-Ip": ["{http.request.remote}"],
},
},
},
"upstreams": [{"dial": upstream}],
}],
},
],
}],
"terminal": True,
}
else:
route = {
"group": hostname,
"match": [matcher],
"handle": [{
"handler": "subroute",
"routes": [{
"handle": [{
"handler": "reverse_proxy",
"headers": {
"request": {
"set": {
"Host": ["{http.request.host}"],
"X-Forwarded-For": ["{http.request.remote}"],
"X-Forwarded-Host": ["{http.request.host}"],
"X-Forwarded-Proto": ["{http.request.scheme}"],
"X-Real-Ip": ["{http.request.remote}"],
},
},
},
"upstreams": [{"dial": upstream}],
}],
}],
}],
"terminal": True,
}
# Caddy v2 admin API: POST appends to the routes array
await self._request(
"POST",
"/config/apps/http/servers/srv0/routes/",
route,
)
return route_path
async def remove_route(self, hostname: str) -> None:
existing = await self._request("GET", "/config/apps/http/servers/srv0/routes/")
routes: list[dict[str, Any]] = existing if isinstance(existing, list) else []
cleaned = [r for r in routes if not any(
hostname in m.get("host", []) for m in r.get("match", [])
)]
if len(cleaned) == len(routes):
return
await self._request(
"PUT",
"/config/apps/http/servers/srv0/routes/",
cleaned,
)
async def list_routes(self) -> list[dict[str, Any]]:
existing = await self._request("GET", "/config/apps/http/servers/srv0/routes/")
if isinstance(existing, list):
return existing
return []
# ─── Credential server client ────────────────────────────────────────────────
class CredentialClient:
def __init__(self, base: str) -> None:
self.base = base.rstrip("/")
async def list_providers(self) -> list[dict[str, Any]]:
async with httpx.AsyncClient(verify=False, timeout=15) as client:
resp = await client.get(f"{self.base}/credentials")
resp.raise_for_status()
data = resp.json()
return data.get("providers", [])
async def get_credential(self, name: str) -> dict[str, Any] | None:
async with httpx.AsyncClient(verify=False, timeout=15) as client:
resp = await client.get(f"{self.base}/credentials/{name}")
if resp.status_code == 200:
return resp.json()
return None
# ─── Service registry (PostgreSQL) ────────────────────────────────────────────
class ServiceRegistry:
def __init__(self, database_url: str) -> None:
self.database_url = database_url
self._pool = None
async def init(self) -> None:
try:
import asyncpg
self._pool = await asyncpg.create_pool(
self.database_url,
min_size=1,
max_size=5,
)
await self._ensure_schema()
except ImportError:
# Fallback to in-memory for development
self._memory_store: dict[str, dict[str, Any]] = {}
self._memory = True
async def _ensure_schema(self) -> None:
if not self._pool:
return
async with self._pool.acquire() as conn:
await conn.execute("""
CREATE SCHEMA IF NOT EXISTS service_orchestrator;
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS service_orchestrator.services (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
domain TEXT NOT NULL,
internal_host TEXT NOT NULL,
internal_port INTEGER NOT NULL,
authentik_provider TEXT NOT NULL DEFAULT '',
authentik_application TEXT NOT NULL DEFAULT '',
caddy_route_path TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
enabled BOOLEAN NOT NULL DEFAULT TRUE
);
""")
await conn.execute("""
CREATE TABLE IF NOT EXISTS service_orchestrator.jobs (
id BIGSERIAL PRIMARY KEY,
service_slug TEXT REFERENCES service_orchestrator.services(slug),
action TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
error TEXT
);
""")
async def register(self, svc: RegisteredService) -> None:
if hasattr(self, "_memory") and self._memory:
self._memory_store[svc.slug] = {
"slug": svc.slug,
"name": svc.name,
"domain": svc.domain,
"internal_host": svc.internal_host,
"internal_port": svc.internal_port,
"authentik_provider": svc.authentik_provider,
"authentik_application": svc.authentik_application,
"caddy_route_path": svc.caddy_route_path,
"created_at": svc.created_at,
"enabled": svc.enabled,
}
return
async with self._pool.acquire() as conn:
await conn.execute(
"""
INSERT INTO service_orchestrator.services
(slug, name, domain, internal_host, internal_port,
authentik_provider, authentik_application, caddy_route_path,
created_at, enabled)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (slug) DO UPDATE SET
name = EXCLUDED.name,
domain = EXCLUDED.domain,
internal_host = EXCLUDED.internal_host,
internal_port = EXCLUDED.internal_port,
authentik_provider = EXCLUDED.authentik_provider,
authentik_application = EXCLUDED.authentik_application,
caddy_route_path = EXCLUDED.caddy_route_path,
enabled = EXCLUDED.enabled
""",
svc.slug,
svc.name,
svc.domain,
svc.internal_host,
svc.internal_port,
svc.authentik_provider,
svc.authentik_application,
svc.caddy_route_path,
svc.created_at,
svc.enabled,
)
async def unregister(self, slug: str) -> bool:
if hasattr(self, "_memory") and self._memory:
return self._memory_store.pop(slug, None) is not None
async with self._pool.acquire() as conn:
result = await conn.execute(
"DELETE FROM service_orchestrator.services WHERE slug = $1",
slug,
)
return "DELETE " in result and int(result.split()[-1]) > 0
async def list(self) -> list[dict[str, Any]]:
if hasattr(self, "_memory") and self._memory:
return list(self._memory_store.values())
async with self._pool.acquire() as conn:
rows = await conn.fetch(
"SELECT * FROM service_orchestrator.services ORDER BY created_at DESC",
)
return [dict(r) for r in rows]
async def get(self, slug: str) -> dict[str, Any] | None:
if hasattr(self, "_memory") and self._memory:
return self._memory_store.get(slug)
async with self._pool.acquire() as conn:
row = await conn.fetchrow(
"SELECT * FROM service_orchestrator.services WHERE slug = $1",
slug,
)
return dict(row) if row else None
# ─── MCP protocol handlers ────────────────────────────────────────────────────
class ServiceOrchestrator:
def __init__(self) -> None:
self.auth = AuthentikClient(AUTHENTIK_BASE, AUTHENTIK_TOKEN)
self.caddy = CaddyClient(CADDY_ADMIN)
self.cred = CredentialClient(CREDENTIAL_SERVER)
self.registry = ServiceRegistry(DATABASE_URL)
async def init(self) -> None:
await self.registry.init()
def resolve_upstream(self, node_or_host: str, port: int) -> str:
if node_or_host in UPSTREAM_TARGETS:
ip = UPSTREAM_TARGETS[node_or_host]
else:
ip = node_or_host
return f"{ip}:{port}"
async def register_service(
self,
name: str,
slug: str,
internal_host: str,
internal_port: int,
auth_required: bool = True,
) -> dict[str, Any]:
domain = f"{slug}.{BASE_DOMAIN}"
upstream = self.resolve_upstream(internal_host, internal_port)
external_host = f"https://{domain}"
try:
auth_provider = await self.auth.create_proxy_provider(
f"{name} Provider",
f"http://{upstream}",
external_host,
)
except Exception as e:
return {"ok": False, "error": f"Authentik provider creation failed: {e}"}
try:
auth_app = await self.auth.create_application(name, slug, auth_provider)
except Exception as e:
await self.auth.delete_provider(auth_provider)
return {"ok": False, "error": f"Authentik application creation failed: {e}"}
try:
route_path = await self.caddy.add_route(domain, upstream, auth_required)
except Exception as e:
await self.auth.delete_application(slug)
await self.auth.delete_provider(auth_provider)
return {"ok": False, "error": f"Caddy route creation failed: {e}"}
now = datetime.now(timezone.utc).isoformat()
svc = RegisteredService(
slug=slug,
name=name,
domain=domain,
internal_host=internal_host,
internal_port=internal_port,
authentik_provider=auth_provider,
authentik_application=auth_app,
caddy_route_path=route_path,
created_at=now,
)
await self.registry.register(svc)
return {
"ok": True,
"service": {
"slug": svc.slug,
"name": svc.name,
"domain": svc.domain,
"internal_target": upstream,
"authentik_provider": svc.authentik_provider,
"authentik_application": svc.authentik_application,
"auth_required": auth_required,
},
}
async def unregister_service(self, slug: str) -> dict[str, Any]:
svc = await self.registry.get(slug)
if not svc:
return {"ok": False, "error": f"Service '{slug}' not found"}
domain = svc.get("domain", f"{slug}.{BASE_DOMAIN}")
errors: list[str] = []
if svc.get("authentik_application"):
try:
await self.auth.delete_application(slug)
except Exception as e:
errors.append(f"Authentik app delete: {e}")
if svc.get("authentik_provider"):
try:
await self.auth.delete_provider(svc["authentik_provider"])
except Exception as e:
errors.append(f"Authentik provider delete: {e}")
try:
await self.caddy.remove_route(domain)
except Exception as e:
errors.append(f"Caddy route remove: {e}")
await self.registry.unregister(slug)
if errors:
return {"ok": True, "warnings": errors}
return {"ok": True}
async def list_services(self) -> dict[str, Any]:
services = await self.registry.list()
services_list = []
for s in services:
services_list.append({
"slug": s.get("slug"),
"name": s.get("name"),
"domain": s.get("domain"),
"internal_host": s.get("internal_host"),
"internal_port": s.get("internal_port"),
"enabled": s.get("enabled", True),
"created_at": str(s.get("created_at", "")),
})
return {"ok": True, "services": services_list}
async def get_credentials(self, name: str) -> dict[str, Any]:
providers_list = await self.cred.list_providers()
available = [p["name"] for p in providers_list]
if name == "_all":
return {"ok": True, "providers": available}
cred = await self.cred.get_credential(name)
if cred:
return {"ok": True, "provider": name, "credential": cred}
return {"ok": False, "error": f"Provider '{name}' not found", "available": available}
async def service_status(self, slug: str) -> dict[str, Any]:
svc = await self.registry.get(slug)
if not svc:
return {"ok": False, "error": f"Service '{slug}' not found"}
domain = svc.get("domain", f"{slug}.{BASE_DOMAIN}")
upstream = self.resolve_upstream(
svc.get("internal_host", ""),
svc.get("internal_port", 0),
)
checks: dict[str, Any] = {}
async with httpx.AsyncClient(verify=False, timeout=10) as client:
try:
r = await client.get(f"https://{domain}", follow_redirects=False)
checks["https_accessible"] = r.status_code
except Exception as e:
checks["https_accessible"] = f"error: {e}"
try:
r = await client.get(f"http://{upstream}", timeout=5)
checks["upstream_reachable"] = r.status_code
except Exception as e:
checks["upstream_reachable"] = f"error: {e}"
return {
"ok": True,
"service": {
"slug": slug,
"name": svc.get("name"),
"domain": domain,
"internal_target": upstream,
"auth_enabled": svc.get("auth_required", True),
"created_at": str(svc.get("created_at", "")),
},
"checks": checks,
}
# ─── MCP server entry point ──────────────────────────────────────────────────
async def handle_request(
orch: ServiceOrchestrator,
request: dict[str, Any],
) -> dict[str, Any]:
req_id = request.get("id")
method = request.get("method", "")
if method == "initialize":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"protocolVersion": "2025-03-26",
"capabilities": {
"tools": {},
},
"serverInfo": {
"name": "service-orchestrator",
"version": "0.1.0",
},
},
}
if method == "tools/list":
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {
"tools": [
{
"name": "register_service",
"description": "Register a new service: creates Authentik SSO provider + application, adds Caddy reverse proxy route, and records in the registry.",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Human-readable service name"},
"slug": {"type": "string", "description": "URL-safe identifier (used as subdomain)"},
"internal_host": {"type": "string", "description": "Upstream hostname, Tailscale IP, or node name (nixos-laptop, microvm-racknerd, etc.)"},
"internal_port": {"type": "integer", "description": "Upstream port number"},
"auth_required": {"type": "boolean", "description": "Require Authentik SSO (default: true)"},
},
"required": ["name", "slug", "internal_host", "internal_port"],
},
},
{
"name": "unregister_service",
"description": "Remove a registered service: deletes Authentik provider + application, removes Caddy route, and deletes from registry.",
"inputSchema": {
"type": "object",
"properties": {
"slug": {"type": "string", "description": "URL-safe service identifier to remove"},
},
"required": ["slug"],
},
},
{
"name": "list_services",
"description": "List all registered services with their domains and status.",
"inputSchema": {
"type": "object",
"properties": {},
},
},
{
"name": "get_credentials",
"description": "Get credentials from the credential server. Use '_all' as name to list available providers.",
"inputSchema": {
"type": "object",
"properties": {
"name": {"type": "string", "description": "Provider name or '_all' to list"},
},
"required": ["name"],
},
},
{
"name": "service_status",
"description": "Check the live health of a registered service (HTTPS accessibility + upstream reachability).",
"inputSchema": {
"type": "object",
"properties": {
"slug": {"type": "string", "description": "Service slug to check"},
},
"required": ["slug"],
},
},
],
},
}
if method == "tools/call":
params = request.get("params", {})
tool_name = params.get("name", "")
args = params.get("arguments", {})
try:
if tool_name == "register_service":
result = await orch.register_service(
name=args["name"],
slug=args["slug"],
internal_host=args["internal_host"],
internal_port=args["internal_port"],
auth_required=args.get("auth_required", True),
)
elif tool_name == "unregister_service":
result = await orch.unregister_service(args["slug"])
elif tool_name == "list_services":
result = await orch.list_services()
elif tool_name == "get_credentials":
result = await orch.get_credentials(args["name"])
elif tool_name == "service_status":
result = await orch.service_status(args["slug"])
else:
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32601, "message": f"Tool not found: {tool_name}"},
}
except Exception as e:
import traceback
tb = traceback.format_exc()
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32603, "message": f"{e}\n{tb}"},
}
return {
"jsonrpc": "2.0",
"id": req_id,
"result": {"content": [{"type": "text", "text": json.dumps(result, indent=2)}]},
}
return {
"jsonrpc": "2.0",
"id": req_id,
"error": {"code": -32601, "message": f"Method not found: {method}"},
}
async def main_stdio() -> None:
orch = ServiceOrchestrator()
await orch.init()
reader = asyncio.StreamReader()
protocol = asyncio.StreamReaderProtocol(reader)
await asyncio.get_event_loop().connect_read_pipe(lambda: protocol, sys.stdin)
writer = sys.stdout
while True:
line = await reader.readline()
if not line:
break
line = line.decode("utf-8").strip()
if not line:
continue
try:
request = json.loads(line)
except json.JSONDecodeError:
continue
response = await handle_request(orch, request)
writer.write(json.dumps(response, ensure_ascii=False) + "\n")
writer.flush()
async def main_http(host: str = "0.0.0.0", port: int = 8338) -> None:
"""Run as HTTP server with JSON-RPC over HTTP for MCP."""
orch = ServiceOrchestrator()
await orch.init()
from starlette.applications import Starlette
from starlette.requests import Request
from starlette.responses import JSONResponse, PlainTextResponse
from starlette.routing import Route
async def mcp_endpoint(request: Request) -> JSONResponse:
body = await request.json()
result = await handle_request(orch, body)
return JSONResponse(result)
async def health_endpoint(request: Request) -> PlainTextResponse:
return PlainTextResponse("ok")
async def info_endpoint(request: Request) -> JSONResponse:
services = await orch.list_services()
return JSONResponse({
"service": "service-orchestrator",
"version": "0.1.0",
"services_count": len(services.get("services", [])),
"mcp_endpoint": "/mcp",
})
app = Starlette(
debug=False,
routes=[
Route("/mcp", mcp_endpoint, methods=["POST"]),
Route("/health", health_endpoint, methods=["GET"]),
Route("/", info_endpoint, methods=["GET"]),
],
)
import uvicorn
config = uvicorn.Config(app, host=host, port=port, log_level="info")
server = uvicorn.Server(config)
await server.serve()
if __name__ == "__main__":
mode = sys.argv[1] if len(sys.argv) > 1 else "stdio"
if mode == "http":
port = int(os.getenv("ORCH_PORT", "8338"))
host = os.getenv("ORCH_HOST", "0.0.0.0")
asyncio.run(main_http(host, port))
else:
asyncio.run(main_stdio())