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:
oliver 2026-10-16 21:52:08 +08:00
parent 918ece86c3
commit 52679a7cfc
150 changed files with 2367 additions and 2812 deletions

View file

@ -0,0 +1,268 @@
"""The eight bizMonitor tools, aligned with netxops and backend HTTP contracts."""
from __future__ import annotations
import math
from typing import Any
from urllib.parse import quote
from netx_api.mcp_remote.asgi_client import http_json, mcp_from_handler_result
class _InvalidArgument(ValueError):
"""Only messages constructed from known schema fields; never include values."""
def _str(description: str = "") -> dict[str, Any]:
return {"type": "string", "description": description}
def _int(default: int, maximum: int, minimum: int = 1) -> dict[str, Any]:
return {"type": "integer", "default": default, "minimum": minimum, "maximum": maximum}
def _tool(name: str, description: str, properties: dict[str, Any],
required: tuple[str, ...] = ()) -> dict[str, Any]:
return {"name": name, "description": description, "inputSchema": {
"type": "object", "properties": properties, "required": list(required),
"additionalProperties": False,
}, "annotations": {"readOnlyHint": True, "destructiveHint": False, "idempotentHint": True}}
HTTP_MCP_TOOLS = [
_tool("listBizMonitors", "入口:列出割接项目和业务状态任务,无需 ID。取得 project_id/task_id 后查上下文或批次。", {
"kind": {"type": "string", "enum": ["all", "projects", "tasks"], "default": "all"},
"purpose": _str("任务用途:portrait / cutover_hf;空为全部"),
"status": _str("本地过滤状态,如 active/paused"), "q": _str("名称、设备、备注或 ID 关键词"),
"limit": _int(100, 500),
}),
_tool("listBizMonitorBatches", "列批次/评估运行 ID:project_id→割接批次,task_id→采集批次,batch_id+kind=runs→评估运行。include_runs 最多展开前 5 个割接批次。", {
"kind": {"type": "string", "enum": ["auto", "all", "cutover", "collect", "runs"], "default": "auto"},
"project_id": _str(), "task_id": _str(), "batch_id": _str("割接批次 ID,非采集批次 ID"),
"include_runs": {"type": "boolean", "default": False}, "status": _str(),
"limit": _int(50, 200), "runs_limit": _int(10, 50),
}),
_tool("getBizMonitorContext", "读取项目、监控/对比模板、端口映射及关联任务命令。project_id/task_id 至少一个;原始 A/B 键保留。", {
"project_id": _str(), "task_id": _str(),
}),
_tool("getBizMonitorBoard", "读取割接批次看板、最新或指定评估运行摘要、sheet 卡片及 verdict 统计。", {
"batch_id": _str("割接批次 ID"), "run_id": _str("可选评估运行 ID"),
}, ("batch_id",)),
_tool("listBizMonitorReds", "读取项目红票及证据(设备、采集时间、show 命令、接口原始/归一/映射键);status 空为全部。", {
"project_id": _str(), "status": _str("open / carried / resolved;空为全部"), "limit": _int(200, 500),
}, ("project_id",)),
_tool("getBizMonitorDiffs", "分页读取评估差异,保留原始 A/B、match_* 键与 show 命令证据。q 转发后端 kw;用 offset/limit 翻页。", {
"run_id": _str(), "metric_id": _str(), "sheet_id": _str(), "verdict": _str(),
"color": _str("如 red"), "q": _str("差异关键词,映射 HTTP kw"),
"limit": _int(100, 500), "offset": _int(0, 9007199254740991, 0),
}, ("run_id",)),
_tool("getBizCollectBatch", "读取采集批次工作簿摘要、commands 和 sheet 目录。后端不返回全量指标行及 CLI 原文;以命令 ID 查原文。", {
"batch_id": _str("业务状态采集批次 ID"),
}, ("batch_id",)),
_tool("getBizCollectCommandRaw", "深挖一个采集命令:返回完整 raw_text、raw_command、设备和采集时间;仅凭据脱敏,不截断原文。", {
"batch_id": _str("采集批次 ID"), "command_id": _str("getBizCollectBatch 返回的命令 ID"),
}, ("batch_id", "command_id")),
]
TOOL_REQUIRED_SCOPE = {t["name"]: "biz-monitor:read" for t in HTTP_MCP_TOOLS}
_SCHEMAS = {t["name"]: t["inputSchema"] for t in HTTP_MCP_TOOLS}
def tools_for_scopes(scopes: list[str] | None) -> list[dict[str, Any]]:
# If /auth/me is unavailable, let the backend enforce authorization per GET.
return list(HTTP_MCP_TOOLS) if scopes is None or "biz-monitor:read" in scopes else []
def _s(args: dict[str, Any], key: str, default: str = "") -> str:
return str(args.get(key, default)).strip()
def _n(args: dict[str, Any], key: str, default: int, low: int, high: int) -> int:
value = args.get(key, default)
if isinstance(value, bool) or not isinstance(value, (int, float)) or not math.isfinite(value):
raise _InvalidArgument(f"{key} 必须为有限数值")
return max(low, min(high, math.trunc(value)))
def _pick(row: dict[str, Any], keys: str) -> dict[str, Any]:
return {k: row[k] for k in keys.split() if k in row}
def _items(result: dict[str, Any]) -> list[dict[str, Any]]:
rows = result.get("data", {}).get("items", [])
return [row for row in rows if isinstance(row, dict)] if isinstance(rows, list) else []
def _failed(result: dict[str, Any]) -> bool:
return result.get("ok") is False or (isinstance(result.get("data"), dict)
and result["data"].get("ok") is False)
def _error(code: str, detail: str = "") -> dict[str, Any]:
return {"ok": False, "error": code, **({"detail": detail} if detail else {})}
def _project(row: dict[str, Any]) -> dict[str, Any]:
out = _pick(row, "id name status note monitor_template_id old_task_id new_task_id old_hf_task_id new_hf_task_id hf_interval_sec hf_start_at hf_end_at updated_at")
out["monitor_template_name"] = (row.get("monitor_template") or {}).get("name", "")
for side in ("old", "new"):
task = row.get(f"{side}_task") or {}
name, ip = str(task.get("ne_name") or "").strip(), str(task.get("ne_ip") or "").strip()
out[f"{side}_ne"] = f"{name} ({ip})" if name and ip else name or ip
return out
def _run(row: dict[str, Any]) -> dict[str, Any]:
out = _pick(row, "id batch_id purpose old_batch_id new_batch_id created_at")
summary = row.get("summary") or {}
progress = summary.get("progress") or {}
if "anomaly" in summary:
out["anomaly"] = summary["anomaly"]
for key in ("ok", "total"):
value = progress.get(key, summary.get(f"progress_{key}"))
if value is not None:
out[f"progress_{key}"] = value
return out
def _catalog(args: dict[str, Any]) -> dict[str, Any]:
kind = _s(args, "kind", "all").lower() or "all"
if kind not in {"all", "projects", "tasks"}:
return _error("kind_invalid", "kind must be all|projects|tasks")
limit = _n(args, "limit", 100, 1, 500)
status, query = _s(args, "status").lower(), _s(args, "q").lower()
data: dict[str, Any] = {"kind": kind}
counts = {"projects": 0, "tasks": 0, "projects_returned": 0, "tasks_returned": 0}
for group, path in (("projects", "/v1/biz-migration/projects"), ("tasks", "/v1/biz-state/tasks")):
if kind not in {"all", group}:
continue
params = {"purpose": _s(args, "purpose")} if group == "tasks" and _s(args, "purpose") else {}
result = http_json("GET", path, params=params)
if _failed(result):
return result
rows = [_project(row) for row in _items(result)] if group == "projects" else _items(result)
rows = [row for row in rows if (not status or str(row.get("status", "")).lower() == status)
and (not query or query in " ".join(str(row.get(k) or "").lower() for k in
"id name note status ne_name ne_ip ne_id old_ne new_ne monitor_template_name purpose vendor".split()))]
counts[group] = len(rows)
data[group] = rows[:limit]
counts[f"{group}_returned"] = len(data[group])
data.update(counts=counts, next="将 project_id/task_id 传给 getBizMonitorContext;用 listBizMonitorBatches 获取 batch_id/run_id。")
return {"ok": True, "data": data}
def _batches(args: dict[str, Any]) -> dict[str, Any]:
pid, tid, bid = (_s(args, key) for key in ("project_id", "task_id", "batch_id"))
kind = _s(args, "kind", "auto").lower() or "auto"
include = args.get("include_runs") is True
limit, run_limit = _n(args, "limit", 50, 1, 200), _n(args, "runs_limit", 10, 1, 50)
status = _s(args, "status").lower()
if kind == "auto":
kind = "all" if pid and tid else "cutover" if pid else "collect" if tid else "runs" if bid else ""
if not kind:
return _error("project_id_or_task_id_or_batch_id_required")
if kind not in {"all", "cutover", "collect", "runs"}:
return _error("kind_invalid", "kind must be auto|all|cutover|collect|runs")
cutover, collect = kind in {"all", "cutover"}, kind in {"all", "collect"}
for needed, value, key in ((cutover, pid, "project_id"), (collect, tid, "task_id"), (kind == "runs", bid, "batch_id")):
if needed and not value:
return _error(f"{key}_required")
cb: list[dict[str, Any]] = []
cl: list[dict[str, Any]] = []
runs: list[dict[str, Any]] = []
if cutover:
result = http_json("GET", f"/v1/biz-migration/projects/{quote(pid, safe='')}/batches", params={})
if _failed(result):
return result
cb = [_pick(row, "id project_id batch_label status accept_status accept_run_id started_at ended_at note created_at updated_at")
for row in _items(result) if (not bid or str(row.get("id") or "") == bid)
and (not status or str(row.get("status") or "").lower() == status)][:limit]
if collect:
result = http_json("GET", f"/v1/biz-state/tasks/{quote(tid, safe='')}/batches", params={"limit": limit})
if _failed(result):
return result
cl = [_pick(row, "id task_id status ne_name command_count row_count is_baseline protected message started_at ended_at")
for row in _items(result) if not status or str(row.get("status") or "").lower() == status][:limit]
for row in cl:
row["task_id"] = row.get("task_id") or tid
run_bids = [bid] if kind == "runs" else [str(row.get("id") or "").strip() for row in cb if row.get("id")][:5] if include else []
for run_bid in run_bids:
result = http_json("GET", f"/v1/biz-migration/batches/{quote(run_bid, safe='')}/runs", params={"limit": run_limit})
target = next((row for row in cb if row.get("id") == run_bid), None)
if _failed(result):
if kind == "runs":
return result
if target is not None:
target["runs_error"] = result.get("error") or "runs_fetch_failed"
continue
items = [_run(row) for row in _items(result)][:run_limit]
runs.extend(items)
if target is not None:
target["runs"] = items
data: dict[str, Any] = {"kind": kind, "counts": {"cutover_batches": len(cb), "collect_batches": len(cl), "runs": len(runs)}}
data.update({key: val for key, val in (("project_id", pid), ("task_id", tid), ("batch_id", bid)) if val})
if cutover:
data["cutover_batches"] = cb
if collect:
data["collect_batches"] = cl
if kind == "runs":
data["runs"] = runs
data["next"] = "割接 batch_id→getBizMonitorBoard/listBizMonitorBatches(kind=runs);采集 batch_id→getBizCollectBatch;run_id→getBizMonitorDiffs。"
return {"ok": True, "data": data}
def call_http_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
try:
schema = _SCHEMAS.get(name)
if schema is None:
return mcp_from_handler_result(_error("unknown_tool"))
for key, value in args.items():
prop = schema["properties"].get(key)
if prop is None:
raise _InvalidArgument("存在未知字段;允许字段:" + ", ".join(schema["properties"]))
if prop["type"] == "string" and not isinstance(value, str):
raise _InvalidArgument(f"{key} 必须为字符串")
if prop["type"] == "boolean" and not isinstance(value, bool):
raise _InvalidArgument(f"{key} 必须为布尔值")
if name == "listBizMonitors":
result = _catalog(args)
elif name == "listBizMonitorBatches":
result = _batches(args)
else:
pid, tid, bid, rid, cid = (_s(args, key) for key in ("project_id", "task_id", "batch_id", "run_id", "command_id"))
params: dict[str, Any] = {}
if name == "getBizMonitorContext":
if not pid and not tid:
return mcp_from_handler_result(_error("project_id_or_task_id_required"))
path = "/v1/biz-migration/monitor-context"
params = {k: v for k, v in (("project_id", pid), ("task_id", tid)) if v}
else:
missing = [key for key in schema["required"] if not _s(args, key)]
if missing:
code = "batch_id_and_command_id_required" if name == "getBizCollectCommandRaw" else f"{missing[0]}_required"
return mcp_from_handler_result(_error(code))
if name == "getBizMonitorBoard":
path = f"/v1/biz-migration/batches/{quote(bid, safe='')}/board"
if rid:
params["run_id"] = rid
elif name == "listBizMonitorReds":
path = f"/v1/biz-migration/projects/{quote(pid, safe='')}/red-tickets"
if _s(args, "status"):
params["status"] = _s(args, "status")
elif name == "getBizMonitorDiffs":
path = f"/v1/biz-migration/runs/{quote(rid, safe='')}/diffs"
params = {("kw" if k == "q" else k): _s(args, k) for k in ("metric_id", "sheet_id", "verdict", "color", "q") if _s(args, k)}
if "offset" in args:
params["offset"] = _n(args, "offset", 0, 0, 9007199254740991)
elif name == "getBizCollectBatch":
path = f"/v1/biz-state/batches/{quote(bid, safe='')}"
else:
path = f"/v1/biz-state/batches/{quote(bid, safe='')}/commands/{quote(cid, safe='')}"
if "limit" in args:
params["limit"] = _n(args, "limit", 200, 1, 500)
result = http_json("GET", path, params=params)
return mcp_from_handler_result(result)
except _InvalidArgument as exc:
return mcp_from_handler_result(_error("invalid_argument", str(exc)))
except (ValueError, TypeError, OverflowError):
# Avoid echoing user-provided values (which might contain credentials).
return mcp_from_handler_result(_error("invalid_argument", "参数类型或字段不合法;请参照 tools/list 的 inputSchema。"))