mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 17:03:27 +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>
268 lines
15 KiB
Python
268 lines
15 KiB
Python
"""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。"))
|