netx/netx_api/mcp_remote/bizmonitor/http_tools.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

268 lines
15 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.

"""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。"))