netx/netx_api/mcp_remote/asgi_client.py
oliver 52679a7cfc MCP: serve tools from the API process over Streamable HTTP at /mcp
- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:52:08 +08:00

285 lines
8.7 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""In-process ASGI client used by MCP tool handlers (no TCP loopback)."""
from __future__ import annotations
import json
import os
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
from urllib.parse import quote
import httpx
from .context import get_app, get_request_context
_PROTOCOL_KEY_ZH_TO_EN: dict[str, str] = {
"其他": "Other",
"时钟": "Clock",
"OTN/光": "OTN/Optical",
"电源": "Power",
}
_SECRET_KEYS = frozenset(
{
"password",
"passwd",
"pwd",
"secret",
"token",
"api_token",
"api_key",
"authorization",
"access_token",
"refresh_token",
"private_key",
"client_secret",
"ssh_password",
"enable_password",
"hop_password",
"hop_password_enc",
"password_enc",
"password_ciphertext",
"hop_password_ciphertext",
"credential_secret",
"secret_key",
"credentials",
}
)
def quote_ne_id(ne_id: str) -> str:
return quote(str(ne_id or "").strip(), safe="")
def api_token() -> str:
from pathlib import Path
ctx = get_request_context()
if ctx and ctx.token and ctx.token != "disabled":
return ctx.token
token = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip()
if token:
return token
for candidate in (os.getenv("NETX_MCP_TOKEN_FILE", "").strip(), "data/auth/mcp_token"):
if not candidate:
continue
try:
token = Path(candidate).read_text(encoding="utf-8").strip()
except (OSError, UnicodeError, ValueError):
continue
if token:
return token
return ""
def lang_query_params() -> dict[str, str]:
ctx = get_request_context()
if ctx is not None:
lang = str(ctx.lang or "zh").strip().lower()
else:
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
if lang.startswith("en"):
return {"lang": "en"}
return {}
def localize_payload(data: dict[str, Any]) -> dict[str, Any]:
ctx = get_request_context()
if ctx is not None:
lang = str(ctx.lang or "zh").strip().lower()
else:
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
if not lang.startswith("en"):
return data
proto = data.get("protocol_summary")
if isinstance(proto, list):
for row in proto:
if isinstance(row, dict):
k = str(row.get("key") or "")
if k in _PROTOCOL_KEY_ZH_TO_EN:
row["key"] = _PROTOCOL_KEY_ZH_TO_EN[k]
return data
def redact(value: Any, *, tokens: tuple[str, ...] | None = None) -> Any:
if tokens is None:
tokens = (api_token(),)
if isinstance(value, dict):
return {
k: redact(v, tokens=tokens)
for k, v in value.items()
if str(k).lower() not in _SECRET_KEYS
}
if isinstance(value, list):
return [redact(v, tokens=tokens) for v in value]
if isinstance(value, str):
for token in tokens:
if token:
value = value.replace(token, "[REDACTED]")
value = re.sub(r"(?i)\bBearer\s+[^\s\"']+", "Bearer [REDACTED]", value)
value = re.sub(
r"(?im)(\b(?:password|passwd|secret|api[_-]?key|access[_-]?token)\s*[:=]\s*)([^\s,;]+)",
r"\1[REDACTED]",
value,
)
return value
def _headers() -> dict[str, str]:
h = {"accept": "application/json"}
tok = api_token()
if tok:
h["authorization"] = f"Bearer {tok}"
return h
def _default_timeout() -> float:
try:
return float(os.getenv("NETX_HTTP_TIMEOUT") or 120.0)
except (TypeError, ValueError):
return 120.0
def _client(timeout: float) -> httpx.Client:
app = get_app()
if app is None:
base = (
os.getenv("NETX_API_URL")
or os.getenv("OCLAW_NETX_BASE_URL")
or "http://127.0.0.1:8890"
).rstrip("/")
return httpx.Client(base_url=base, timeout=timeout, trust_env=False)
return httpx.Client(
transport=httpx.ASGITransport(app=app),
base_url="http://mcp.internal",
timeout=timeout,
trust_env=False,
)
def http_json(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
timeout: float | None = None,
) -> dict[str, Any]:
# When not attached to the FastAPI app, require an explicit outbound token
# (remote MCP itself authenticates at /mcp; this covers unit tests / fallback).
if get_app() is None and not api_token():
return {
"ok": False,
"error": "netx_token_missing",
"hint": "设置 NETX_API_TOKEN、NETX_MCP_TOKEN_FILE,或工作目录 data/auth/mcp_token。",
}
merged: dict[str, Any] = dict(lang_query_params())
if params:
merged.update(params)
to = float(timeout) if timeout is not None else _default_timeout()
tok = api_token()
headers = {"accept": "application/json"}
if tok:
headers["authorization"] = f"Bearer {tok}"
try:
with _client(to) as client:
resp = client.request(
method,
path,
params=merged or None,
json=body,
headers=headers,
)
text = resp.text
if not resp.is_success:
return {
"ok": False,
"error": f"netx_http_{resp.status_code}",
"detail": text[:800],
"hint": "检查 API 地址、令牌 scope 和资源 ID。",
}
data = resp.json() if text else {}
if isinstance(data, dict):
data = localize_payload(data)
data = redact(data, tokens=(tok,))
else:
data = {"raw": data}
return {"ok": True, "data": data}
except Exception as exc:
return {
"ok": False,
"error": "netx_request_failed",
"detail": str(exc)[:800],
"hint": "检查 API 连通性及 JSON 响应。",
}
def http_post_json(path: str, body: dict[str, Any], *, timeout: float = 180.0) -> dict[str, Any]:
return http_json("POST", path, body=body, timeout=timeout)
def http_json_many(
requests: list[dict[str, Any]],
*,
max_workers: int = 12,
timeout: float | None = None,
) -> list[dict[str, Any]]:
if not requests:
return []
if len(requests) == 1:
r0 = requests[0]
out = http_json(
str(r0.get("method") or "GET"),
str(r0.get("path") or ""),
params=r0.get("params") if isinstance(r0.get("params"), dict) else None,
body=r0.get("body") if isinstance(r0.get("body"), dict) else None,
timeout=timeout,
)
if "key" in r0:
out = {**out, "key": r0.get("key")}
return [out]
to = float(timeout) if timeout is not None else _default_timeout()
workers = max(1, min(int(max_workers), len(requests)))
results: list[dict[str, Any] | None] = [None] * len(requests)
def _one(idx: int, req: dict[str, Any]) -> tuple[int, dict[str, Any]]:
out = http_json(
str(req.get("method") or "GET"),
str(req.get("path") or ""),
params=req.get("params") if isinstance(req.get("params"), dict) else None,
body=req.get("body") if isinstance(req.get("body"), dict) else None,
timeout=to,
)
if "key" in req:
out = {**out, "key": req.get("key")}
return idx, out
with ThreadPoolExecutor(max_workers=workers) as pool:
futs = [pool.submit(_one, i, r) for i, r in enumerate(requests)]
for fut in as_completed(futs):
idx, out = fut.result()
results[idx] = out
return [r if isinstance(r, dict) else {"ok": False, "error": "parallel_slot_empty"} for r in results]
def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]:
out: dict[str, Any] = {
"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]
}
if is_error:
out["isError"] = True
return out
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
result = redact(result) if isinstance(result, dict) else result
data = result.get("data") if isinstance(result, dict) else None
if (
not isinstance(result, dict)
or not result.get("ok")
or (isinstance(data, dict) and data.get("ok") is False)
):
return mcp_text_result(result, is_error=True)
return mcp_text_result(result)