mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 14:33:18 +08:00
- 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>
285 lines
8.7 KiB
Python
285 lines
8.7 KiB
Python
"""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)
|