Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,230 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import ipaddress
|
||||
import json
|
||||
import os
|
||||
from collections.abc import AsyncIterator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from starlette.applications import Starlette
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import JSONResponse, Response
|
||||
from starlette.routing import Route
|
||||
|
||||
from packages.hub_runtime import read_secret_file
|
||||
|
||||
from .common import connector_context, load_connector_identity_key, run_asgi
|
||||
|
||||
UPSTREAM_TO_PUBLIC = {
|
||||
"gitea.actions.runners.status": "server.ci.runners.status",
|
||||
"gitea.projects.catalog": "server.project.list",
|
||||
"gitea.projects.reconcile": "server.project.reconcile",
|
||||
"get_system_summary": "server.summary.get",
|
||||
"list_apps": "server.application.list",
|
||||
"list_containers": "server.container.list",
|
||||
"get_safety_findings": "server.safety.findings",
|
||||
"get_action_registry": "server.action.list",
|
||||
"preview_action": "server.operation.preview",
|
||||
"search_audit": "server.audit.search",
|
||||
"query_knowledge": "server.knowledge.query",
|
||||
"server.operation.request": "server.operation.request",
|
||||
"server.operation.status": "server.operation.status",
|
||||
"server.operation.execute": "server.operation.execute",
|
||||
"server.job.status": "server.job.status",
|
||||
"server.job.cancel": "server.job.cancel",
|
||||
"deployment.preflight": "server.deployment.preflight",
|
||||
"deployment.capabilities": "server.deployment.capabilities",
|
||||
"enrollment.preflight": "server.deployment.enrollment.preflight",
|
||||
"target.enroll": "server.deployment.target.enroll",
|
||||
"enrollment.status": "server.deployment.enrollment.status",
|
||||
"deployment.apply": "server.deployment.apply",
|
||||
"deployment.status": "server.deployment.status",
|
||||
"deployment.verify": "server.deployment.verify",
|
||||
"deployment.rollback": "server.deployment.rollback",
|
||||
}
|
||||
PUBLIC_TO_UPSTREAM = {value: key for key, value in UPSTREAM_TO_PUBLIC.items()}
|
||||
MAX_BODY_BYTES = 1024 * 1024
|
||||
MAX_RESPONSE_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def upstream_headers(
|
||||
request_headers: Mapping[str, str], token: str, *, has_body: bool
|
||||
) -> dict[str, str]:
|
||||
headers = {
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/json, text/event-stream",
|
||||
}
|
||||
if has_body:
|
||||
headers["Content-Type"] = request_headers.get("content-type", "application/json")
|
||||
for name in ("mcp-session-id", "mcp-protocol-version", "last-event-id"):
|
||||
if value := request_headers.get(name):
|
||||
headers[name] = value
|
||||
return headers
|
||||
|
||||
|
||||
def _validate_endpoint(value: str) -> str:
|
||||
endpoint = value.rstrip("/")
|
||||
parsed = urlparse(endpoint)
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username
|
||||
or parsed.password
|
||||
or parsed.query
|
||||
or parsed.fragment
|
||||
or parsed.path not in {"", "/"}
|
||||
):
|
||||
raise ValueError("AppOps endpoint must be an HTTP(S) origin")
|
||||
if parsed.scheme == "http":
|
||||
try:
|
||||
address = ipaddress.ip_address(parsed.hostname)
|
||||
except ValueError as exc:
|
||||
raise ValueError("plain HTTP AppOps endpoints must use a private IP address") from exc
|
||||
if not address.is_private:
|
||||
raise ValueError("plain HTTP AppOps endpoints must use a private IP address")
|
||||
return endpoint
|
||||
|
||||
|
||||
def _settings() -> tuple[str, str, set[str], bytes]:
|
||||
endpoint = _validate_endpoint(os.environ["APPOPS_ENDPOINT"])
|
||||
token = read_secret_file(
|
||||
Path(os.environ.get("APPOPS_SERVICE_TOKEN_FILE", "/run/secrets/appops_hub_service_token"))
|
||||
)
|
||||
if len(token) < 32:
|
||||
raise ValueError("AppOps service token must contain at least 32 characters")
|
||||
projects = {
|
||||
item.strip().lower()
|
||||
for item in os.environ.get("APPOPS_PROJECTS", "server-ops").split(",")
|
||||
if item.strip()
|
||||
}
|
||||
return endpoint, token, projects, load_connector_identity_key()
|
||||
|
||||
|
||||
def rewrite_request(document: Any) -> Any:
|
||||
if not isinstance(document, dict) or document.get("method") != "tools/call":
|
||||
return document
|
||||
params = document.get("params")
|
||||
if not isinstance(params, dict) or not isinstance(params.get("name"), str):
|
||||
return document
|
||||
upstream_name = PUBLIC_TO_UPSTREAM.get(params["name"])
|
||||
if upstream_name is None:
|
||||
raise ValueError("AppOps tool is not registered")
|
||||
return {**document, "params": {**params, "name": upstream_name}}
|
||||
|
||||
|
||||
def rewrite_response(document: Any, request_method: str | None) -> Any:
|
||||
if request_method != "tools/list" or not isinstance(document, dict):
|
||||
return document
|
||||
result = document.get("result")
|
||||
if not isinstance(result, dict) or not isinstance(result.get("tools"), list):
|
||||
return document
|
||||
tools = []
|
||||
for tool in result["tools"]:
|
||||
if not isinstance(tool, dict) or not isinstance(tool.get("name"), str):
|
||||
continue
|
||||
if public_name := UPSTREAM_TO_PUBLIC.get(tool["name"]):
|
||||
tools.append({**tool, "name": public_name})
|
||||
return {**document, "result": {**result, "tools": tools}}
|
||||
|
||||
|
||||
def build_application() -> Starlette:
|
||||
endpoint, token, projects, signing_key = _settings()
|
||||
client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=5.0), follow_redirects=False)
|
||||
|
||||
async def health(_: Request) -> JSONResponse:
|
||||
return JSONResponse({"status": "healthy", "component": "appops-connector"})
|
||||
|
||||
async def ready(_: Request) -> JSONResponse:
|
||||
try:
|
||||
response = await client.get(f"{endpoint}/api/health")
|
||||
payload = response.json()
|
||||
ready_state = response.status_code == 200 and isinstance(payload, dict)
|
||||
except (httpx.HTTPError, ValueError, TypeError):
|
||||
ready_state, payload = False, {}
|
||||
return JSONResponse(
|
||||
{
|
||||
"status": "ready" if ready_state else "unavailable",
|
||||
"component": "appops-connector",
|
||||
"upstream": payload,
|
||||
},
|
||||
status_code=200 if ready_state else 503,
|
||||
)
|
||||
|
||||
async def mcp(request: Request) -> Response:
|
||||
body = await request.body()
|
||||
if len(body) > MAX_BODY_BYTES:
|
||||
return JSONResponse({"error": "APPOPS_REQUEST_TOO_LARGE"}, status_code=413)
|
||||
document: Any = None
|
||||
method: str | None = None
|
||||
if body:
|
||||
try:
|
||||
document = json.loads(body)
|
||||
if not isinstance(document, dict):
|
||||
raise ValueError("JSON-RPC batches are not accepted")
|
||||
method = document.get("method")
|
||||
if method == "tools/call":
|
||||
connector_context(
|
||||
{str(key).lower(): str(value) for key, value in request.headers.items()},
|
||||
projects,
|
||||
signing_key=signing_key,
|
||||
)
|
||||
document = rewrite_request(document)
|
||||
body = json.dumps(document, separators=(",", ":")).encode("utf-8")
|
||||
except PermissionError:
|
||||
return JSONResponse({"error": "APPOPS_CONNECTOR_IDENTITY_DENIED"}, status_code=403)
|
||||
except (ValueError, TypeError, json.JSONDecodeError):
|
||||
return JSONResponse({"error": "APPOPS_REQUEST_INVALID"}, status_code=400)
|
||||
|
||||
headers = upstream_headers(request.headers, token, has_body=bool(body))
|
||||
try:
|
||||
upstream = await client.request(
|
||||
request.method,
|
||||
f"{endpoint}/mcp/",
|
||||
content=body or None,
|
||||
headers=headers,
|
||||
)
|
||||
except httpx.HTTPError:
|
||||
return JSONResponse({"error": "APPOPS_UNAVAILABLE"}, status_code=502)
|
||||
content = upstream.content
|
||||
if len(content) > MAX_RESPONSE_BYTES:
|
||||
return JSONResponse({"error": "APPOPS_RESPONSE_TOO_LARGE"}, status_code=502)
|
||||
content_type = upstream.headers.get("content-type", "application/json")
|
||||
if content and "application/json" in content_type:
|
||||
try:
|
||||
content = json.dumps(
|
||||
rewrite_response(upstream.json(), method), separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
except (ValueError, TypeError):
|
||||
return JSONResponse({"error": "APPOPS_RESPONSE_INVALID"}, status_code=502)
|
||||
response_headers = {"content-type": content_type}
|
||||
if session_id := upstream.headers.get("mcp-session-id"):
|
||||
response_headers["mcp-session-id"] = session_id
|
||||
return Response(content, status_code=upstream.status_code, headers=response_headers)
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(_: Starlette) -> AsyncIterator[None]:
|
||||
yield
|
||||
await client.aclose()
|
||||
|
||||
return Starlette(
|
||||
routes=[
|
||||
Route("/health", health),
|
||||
Route("/healthz", health),
|
||||
Route("/ready", ready),
|
||||
Route("/health/ready", ready),
|
||||
Route("/mcp", mcp, methods=["GET", "POST", "DELETE"]),
|
||||
],
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
|
||||
def run() -> None:
|
||||
run_asgi(build_application())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
run()
|
||||
Reference in new issue
Block a user