Research-Stack/4-Infrastructure/infra/service-orchestrator/service_orchestrator.py
allaun 36a78fec72 feat(infra): add support for secure file-based Authentik token loading
Add support for AUTHENTIK_TOKEN_FILE environment variable fallback to
service_orchestrator.py, configure_vault_authentik.py, and the Rust
authentik_agent_manager CLI/MCP server. This prevents hardcoding or
exposing raw tokens in environment blocks or CLI arguments. Document
the new Authentik SSO stack deployment on cupfox k3s in AGENTS.md and
update .mcp.json.full configuration.

Build: 0 jobs, 0 errors (lake build)
2026-07-05 15:54:27 -05:00

890 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")
if not AUTHENTIK_TOKEN:
token_file = os.getenv("AUTHENTIK_TOKEN_FILE")
if token_file and os.path.exists(token_file):
with open(token_file, "r") as f:
AUTHENTIK_TOKEN = f.read().strip()
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())