mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 10:20:47 +08:00
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>
This commit is contained in:
parent
918ece86c3
commit
52679a7cfc
150 changed files with 2367 additions and 2812 deletions
285
netx_api/mcp_remote/asgi_client.py
Normal file
285
netx_api/mcp_remote/asgi_client.py
Normal file
|
|
@ -0,0 +1,285 @@
|
|||
"""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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue