mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 17:03:27 +08:00
MCP: add bizMonitor server, align ops/topology tools, harden CLI exec
- New packages/netx-bizmonitor-mcp: 8 read-only cutover/biz-state tools mirroring the netxops bizMonitor group. - Align netx-mcp (15) and netx-topology-mcp (16) with backend HTTP contracts; surface partial batch failures as tool errors. - CLI hard gate: single-line show/display only; exec job IDs, path containment, owner and ne:exec checks. - Cisco IOSv collection: stable prompt/echo handling over CR. - Remove legacy netx_api/mcp and mcp_server.py; add mcp.all.json; sync skills and docs. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
d8da7acb7d
commit
9085cac742
63 changed files with 3251 additions and 1092 deletions
62
packages/netx-bizmonitor-mcp/README.md
Normal file
62
packages/netx-bizmonitor-mcp/README.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
# netx-bizmonitor-mcp
|
||||
|
||||
独立 Python stdio MCP,只读访问 netx 的割接对比与业务状态 HTTP API。运行依赖仅 `httpx`,不依赖 `netx_api`、数据库驱动或 netxops。采用独立包与现有 topology 包、一组一 skill 的组织方式一致,客户端可按组挂载最小权限,避免扩大 ops 工具域及引入重依赖。
|
||||
|
||||
## 配置与启动
|
||||
|
||||
安装本包后使用 `python -m netx_bizmonitor_mcp`,或命令 `netx-bizmonitor-mcp`。无需安装时,将本包 `src` 的绝对路径设置为 `PYTHONPATH`。`mcp.json` 是客户端配置示例,需改为实际 Python/src 路径与令牌。
|
||||
|
||||
使用凭据文件时,应删除样例的 NETX_API_TOKEN 占位值或设为空;同时为客户端设定正确工作目录,或用 NETX_MCP_TOKEN_FILE 指定绝对路径。
|
||||
|
||||
- `NETX_API_URL`:默认 `http://127.0.0.1:8890`,支持 HTTP(S) 和路径前缀;禁止内嵌用户名/密码、查询参数、片段。
|
||||
- `NETX_API_TOKEN`:最高优先级 Bearer API 令牌;为空时依次读 `NETX_MCP_TOKEN_FILE` 指定的 UTF-8 文件、当前工作目录的 `data/auth/mcp_token`。文件为空、缺失、不可读或编码错误时继续下一候选。后端要求 **`biz-monitor:read`**,`ne:read` 或 `ops:write` 不代替此 scope。无可用令牌时返回 `netx_token_missing`,不发送请求。
|
||||
- `NETX_LANG`:默认 `zh`;`en` 时附加 `lang=en`。
|
||||
- Windows 建议 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`;程序也会将 stdio 配为 UTF-8。
|
||||
|
||||
令牌读取遵循现有独立 MCP 的凭据文件约定,仅在内存使用,文件内容与路径不回显;响应脱敏覆盖文件令牌,包括请求中途文件轮换前实际使用的令牌。所有业务请求仅 GET;不会触发采集、评估、CLI 执行或设备写入。通过 `GET /v1/auth/me` 探测权限,每次工具调用重新读取,避免缓存权限撤销;探测失败时仍列出工具,由目标 GET 的后端权限检查最终裁决。
|
||||
|
||||
## 工具与 HTTP 路由
|
||||
|
||||
工具名与 netxops bizMonitor 组相同,去掉 `netx__` 前缀,仅提供以下 8 个工具:
|
||||
|
||||
1. `listBizMonitors` → `GET /v1/biz-migration/projects` 与/或 `GET /v1/biz-state/tasks`。
|
||||
`kind=all|projects|tasks`,`purpose` 只转发任务查询;`status/q` 本地过滤,`limit` 默认 100、上限 500,各列表分别限额。项目压缩为名称、状态、任务 ID、设备摘要、模板名称、高频参数与更新时间;任务保留后端目录字段。counts 区分匹配总数与返回数量。
|
||||
2. `listBizMonitorBatches` → `GET /v1/biz-migration/projects/{project_id}/batches`、`GET /v1/biz-state/tasks/{task_id}/batches`、`GET /v1/biz-migration/batches/{batch_id}/runs`。
|
||||
`kind=auto|all|cutover|collect|runs`;auto 根据项目/任务/批次 ID 选择。`all` 需要 project_id 和 task_id;`runs` 需要割接 batch_id。批次 limit 默认 50、上限 200;runs_limit 默认 10、上限 50。include_runs 最多展开前 5 个已筛选割接批次;单个展开失败保留批次并附 runs_error,直接 runs 查询失败则返回工具错误。只保留批次摘要与运行 anomaly/progress,不回传大 summary;counts 为本次返回数量。
|
||||
3. `getBizMonitorContext` → `GET /v1/biz-migration/monitor-context`。
|
||||
project_id/task_id 至少一个;传同名查询参数。保留后端定义包的模板、映射、任务和命令。
|
||||
4. `getBizMonitorBoard` → `GET /v1/biz-migration/batches/{batch_id}/board`。
|
||||
必填割接 batch_id,可选 run_id;保留看板统计与证据。
|
||||
5. `listBizMonitorReds` → `GET /v1/biz-migration/projects/{project_id}/red-tickets`。
|
||||
必填 project_id,可选 status、limit(1..500)。未传 limit 时沿用后端默认 200;保留原始 A/B 键及设备、命令、时间证据。
|
||||
6. `getBizMonitorDiffs` → `GET /v1/biz-migration/runs/{run_id}/diffs`。
|
||||
必填 run_id,可选 metric_id/sheet_id/verdict/color/q/limit/offset。**MCP `q` 映射 HTTP `kw`**,修正 TS 将 q 原样传递导致搜索失效的问题。limit 限 1..500,未传时后端默认 100;offset 下限 0。原始 A/B 和 match_* 键保持不变。
|
||||
7. `getBizCollectBatch` → `GET /v1/biz-state/batches/{batch_id}`。
|
||||
必填采集 batch_id。后端返回工作簿摘要、命令及 sheet 目录,不含原始 CLI 全文和全量指标行。本包保持后端响应,避免误称其会返回全部指标数据。
|
||||
8. `getBizCollectCommandRaw` → `GET /v1/biz-state/batches/{batch_id}/commands/{command_id}`。
|
||||
两个 ID 必填;返回完整 raw_text 和采集证据,按 netxops 不做字符/行数截断。大原文只在明确调用此工具时获取。
|
||||
|
||||
路径 ID 均作为单个段编码。数字参数按 netxops 截为整数并限幅;未知字段/类型错误返回可修正的工具错误,不接受过时单数 kind 或旧工具别名。
|
||||
|
||||
## 推荐调用链
|
||||
|
||||
- 无 ID 时先 `listBizMonitors({"q":"站点名"})`,取得项目 ID。
|
||||
- `getBizMonitorContext({"project_id":"..."})` 理解模板与 old/new 任务。
|
||||
- `listBizMonitorBatches({"project_id":"...","include_runs":true})` 找割接批次及运行;也可 `kind=runs,batch_id=...` 单独查运行。
|
||||
- `getBizMonitorBoard({"batch_id":"...","run_id":"..."})` 看结论,再以 `listBizMonitorReds({"project_id":"..."})` 与 `getBizMonitorDiffs({"run_id":"...","color":"red","limit":100,"offset":0})` 查看证据和分页差异。
|
||||
- 需要原文时,用上下文中的任务 ID 调 `listBizMonitorBatches({"task_id":"..."})` 找采集批次,`getBizCollectBatch` 找 command_id,再 `getBizCollectCommandRaw` 获取指定命令全文。割接批次 ID 与采集批次 ID 不可混用。
|
||||
|
||||
所有返回使用 `{ok,data}` 文本 JSON。参数或上游业务/HTTP 错误使用 MCP `result.isError=true`;JSON-RPC 的解析、请求结构、方法和 tools/call 结构错误分别为 -32700/-32600/-32601/-32602。通知永不回包且不触发业务请求;stdout 只输出协议消息。
|
||||
|
||||
凭据字段递归删除,配置令牌、Bearer 与常见 `password=...`/`secret:...` 文本脱敏。HTTP 错误体、异常文本、URL 不回显,避免凭据泄露;业务 evidence、raw A/B、show 命令、时间字段保持。原始设备输出可能含任意格式的敏感业务信息,部署方仍应控制授权采集内容。
|
||||
|
||||
## 验证
|
||||
|
||||
在 worktree 根目录运行(不安装依赖,不启动项目服务,不连接真实设备):
|
||||
|
||||
```powershell
|
||||
$env:PYTHONPATH = (Resolve-Path packages/netx-bizmonitor-mcp/src).Path
|
||||
& D:/project/chatgpt/netx/.venv/Scripts/python.exe -m pytest packages/netx-bizmonitor-mcp/tests -q
|
||||
```
|
||||
|
||||
测试使用 HTTP mock;stdio 子进程清空令牌环境变量并在无令牌文件的临时目录运行,因此不发送网络请求。覆盖 8 工具、目录/批次裁剪与展开、q→kw、scope、业务与协议错误、通知、凭据来源优先级、文件错误回退、令牌轮换与脱敏及长原文不截断。
|
||||
16
packages/netx-bizmonitor-mcp/mcp.json
Normal file
16
packages/netx-bizmonitor-mcp/mcp.json
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"netx-bizmonitor": {
|
||||
"command": "python",
|
||||
"args": ["-m", "netx_bizmonitor_mcp"],
|
||||
"env": {
|
||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
||||
"NETX_API_TOKEN": "nxt_replace_with_biz_monitor_read_token",
|
||||
"NETX_LANG": "zh",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1",
|
||||
"PYTHONPATH": "D:/project/chatgpt/netx/packages/netx-bizmonitor-mcp/src"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
packages/netx-bizmonitor-mcp/pyproject.toml
Normal file
18
packages/netx-bizmonitor-mcp/pyproject.toml
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
[build-system]
|
||||
requires = ["setuptools>=68", "wheel"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "netx-bizmonitor-mcp"
|
||||
version = "0.1.0"
|
||||
description = "Read-only stdio MCP for netx business monitoring and cutover evidence"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
dependencies = ["httpx>=0.27.0"]
|
||||
|
||||
[project.scripts]
|
||||
netx-bizmonitor-mcp = "netx_bizmonitor_mcp.server:main"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["src"]
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
"""Standalone, read-only netx business-monitor MCP."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
from .server import main
|
||||
|
||||
main()
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
"""HTTP transport. Never echo credentials, upstream error bodies, or URLs."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
import httpx
|
||||
|
||||
_SECRET_KEYS = {
|
||||
"password", "passwd", "pwd", "secret", "token", "api_token", "api_key",
|
||||
"authorization", "access_token", "refresh_token", "private_key", "client_secret",
|
||||
"ssh_password", "enable_password", "hop_password", "credentials",
|
||||
}
|
||||
|
||||
|
||||
def api_token() -> str:
|
||||
"""Use the shared MCP credential convention; never log paths or file contents."""
|
||||
token = os.getenv("NETX_API_TOKEN", "").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 redact(value: Any, *, tokens: tuple[str, ...] | None = None) -> Any:
|
||||
"""Remove credential fields while retaining device raw A/B and CLI evidence."""
|
||||
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 http_json(method: str, path: str, *, params: dict[str, Any] | None = None,
|
||||
timeout: float = 45.0) -> dict[str, Any]:
|
||||
if method != "GET":
|
||||
return {"ok": False, "error": "read_only_transport"}
|
||||
base = os.getenv("NETX_API_URL", "http://127.0.0.1:8890").strip().rstrip("/")
|
||||
try:
|
||||
parsed = urlsplit(base)
|
||||
if (parsed.scheme not in {"http", "https"} or not parsed.hostname
|
||||
or parsed.username or parsed.password or parsed.query or parsed.fragment):
|
||||
return {"ok": False, "error": "netx_api_url_invalid"}
|
||||
except ValueError:
|
||||
return {"ok": False, "error": "netx_api_url_invalid"}
|
||||
token = api_token()
|
||||
if not token:
|
||||
return {"ok": False, "error": "netx_token_missing",
|
||||
"hint": "设置 NETX_API_TOKEN、NETX_MCP_TOKEN_FILE,或工作目录 data/auth/mcp_token。"}
|
||||
query: dict[str, Any] = {}
|
||||
if os.getenv("NETX_LANG", "zh").strip().lower().startswith("en"):
|
||||
query["lang"] = "en"
|
||||
query.update(params or {})
|
||||
try:
|
||||
with httpx.Client(timeout=timeout, trust_env=False, follow_redirects=False) as client:
|
||||
response = client.request("GET", base + path, params=query or None, headers={
|
||||
"accept": "application/json", "authorization": f"Bearer {token}",
|
||||
})
|
||||
if not response.is_success:
|
||||
return {"ok": False, "error": f"netx_http_{response.status_code}",
|
||||
"hint": "检查 API 地址、令牌 scope 和资源 ID。"}
|
||||
data = response.json() if response.content else {}
|
||||
# Use the actual request token even if the credential file rotates mid-request.
|
||||
return {"ok": True, "data": redact(data if isinstance(data, dict) else {"raw": data}, tokens=(token,))}
|
||||
except (ValueError, httpx.HTTPError):
|
||||
return {"ok": False, "error": "netx_request_failed",
|
||||
"hint": "检查 API 连通性及 JSON 响应。"}
|
||||
|
||||
|
||||
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
|
||||
result = redact(result)
|
||||
out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(result, ensure_ascii=False)}]}
|
||||
if result.get("ok") is False or (isinstance(result.get("data"), dict)
|
||||
and result["data"].get("ok") is False):
|
||||
out["isError"] = True
|
||||
return out
|
||||
|
|
@ -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 .http_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。"))
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
"""Newline-delimited UTF-8 JSON-RPC MCP on stdio; stdout is protocol-only."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
from . import __version__
|
||||
from .http_client import http_json, mcp_from_handler_result
|
||||
from .http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
|
||||
|
||||
|
||||
def _fetch_scopes() -> list[str] | None:
|
||||
result = http_json("GET", "/v1/auth/me")
|
||||
if result.get("ok") is not True or not isinstance(result.get("data"), dict):
|
||||
return None
|
||||
data = result["data"]
|
||||
scopes = data.get("scopes")
|
||||
if not isinstance(scopes, list) and isinstance(data.get("user"), dict):
|
||||
scopes = data["user"].get("scopes")
|
||||
return [str(scope) for scope in scopes] if isinstance(scopes, list) else None
|
||||
|
||||
|
||||
def _result(rid: Any, result: dict[str, Any]) -> dict[str, Any]:
|
||||
return {"jsonrpc": "2.0", "id": rid, "result": result}
|
||||
|
||||
|
||||
def _error(rid: Any, code: int, message: str) -> dict[str, Any]:
|
||||
return {"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}
|
||||
|
||||
|
||||
def handle_request(request: Any) -> dict[str, Any] | None:
|
||||
if (not isinstance(request, dict) or request.get("jsonrpc") != "2.0"
|
||||
or not isinstance(request.get("method"), str)
|
||||
or ("id" in request and (isinstance(request["id"], bool)
|
||||
or not isinstance(request["id"], (str, int, type(None)))))):
|
||||
return _error(None, -32600, "Invalid Request")
|
||||
# Includes initialize/ping/unknown-method notifications: never reply or execute tools.
|
||||
if "id" not in request:
|
||||
return None
|
||||
rid, method = request["id"], request["method"]
|
||||
params = request.get("params", {})
|
||||
if not isinstance(params, dict):
|
||||
return _error(rid, -32602, "params must be an object")
|
||||
if method == "initialize":
|
||||
return _result(rid, {"protocolVersion": "2024-11-05", "capabilities": {"tools": {}},
|
||||
"serverInfo": {"name": "netx-bizmonitor-mcp", "version": __version__}})
|
||||
if method == "ping":
|
||||
return _result(rid, {})
|
||||
if method == "tools/list":
|
||||
return _result(rid, {"tools": tools_for_scopes(_fetch_scopes())})
|
||||
if method == "tools/call":
|
||||
name, args = params.get("name"), params.get("arguments", {})
|
||||
if not isinstance(name, str) or name not in TOOL_REQUIRED_SCOPE:
|
||||
return _error(rid, -32602, "Unknown tool name")
|
||||
if not isinstance(args, dict):
|
||||
return _error(rid, -32602, "arguments must be an object")
|
||||
# Refresh per call so revocation is not hidden by a cached permission list.
|
||||
granted = _fetch_scopes()
|
||||
required = TOOL_REQUIRED_SCOPE[name]
|
||||
if granted is not None and required not in granted:
|
||||
return _result(rid, mcp_from_handler_result({"ok": False, "error": "insufficient_scope",
|
||||
"required_scope": required, "hint": "请管理员授予 API 令牌 biz-monitor:read。"}))
|
||||
return _result(rid, call_http_tool(name, args))
|
||||
return _error(rid, -32601, "Method not found")
|
||||
|
||||
|
||||
def run_stdio_loop() -> None:
|
||||
for line in sys.stdin:
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
request = json.loads(line)
|
||||
except (ValueError, RecursionError):
|
||||
response = _error(None, -32700, "Parse error")
|
||||
else:
|
||||
try:
|
||||
response = handle_request(request)
|
||||
except Exception:
|
||||
# Never expose traceback, connection URL, headers or exception text.
|
||||
response = None if isinstance(request, dict) and "id" not in request else _error(
|
||||
request.get("id") if isinstance(request, dict) else None, -32603, "Internal error")
|
||||
if response is not None:
|
||||
sys.stdout.write(json.dumps(response, ensure_ascii=False) + "\n")
|
||||
sys.stdout.flush()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for stream in (sys.stdin, sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
run_stdio_loop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
6
packages/netx-bizmonitor-mcp/tests/conftest.py
Normal file
6
packages/netx-bizmonitor-mcp/tests/conftest.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
"""Always import this checkout's standalone package, not the main repository."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
|
||||
340
packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py
Normal file
340
packages/netx-bizmonitor-mcp/tests/test_mcp_bizmonitor.py
Normal file
|
|
@ -0,0 +1,340 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from netx_bizmonitor_mcp.http_client import api_token, http_json, redact
|
||||
from netx_bizmonitor_mcp.http_tools import HTTP_MCP_TOOLS, TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
|
||||
from netx_bizmonitor_mcp.server import _fetch_scopes, handle_request, run_stdio_loop
|
||||
|
||||
|
||||
def payload(result):
|
||||
return json.loads(result["content"][0]["text"])
|
||||
|
||||
|
||||
def request(method, params=None, rid=1):
|
||||
return {"jsonrpc": "2.0", "id": rid, "method": method, "params": params or {}}
|
||||
|
||||
|
||||
def test_exact_eight_tools_read_only_and_canonical_scope():
|
||||
assert {t["name"] for t in HTTP_MCP_TOOLS} == {
|
||||
"listBizMonitors", "listBizMonitorBatches", "getBizMonitorContext", "getBizMonitorBoard",
|
||||
"listBizMonitorReds", "getBizMonitorDiffs", "getBizCollectBatch", "getBizCollectCommandRaw",
|
||||
}
|
||||
assert len(HTTP_MCP_TOOLS) == 8
|
||||
assert all(t["annotations"]["readOnlyHint"] for t in HTTP_MCP_TOOLS)
|
||||
assert set(TOOL_REQUIRED_SCOPE.values()) == {"biz-monitor:read"}
|
||||
assert tools_for_scopes(["ne:read", "ops:write"]) == []
|
||||
assert len(tools_for_scopes(["biz-monitor:read"])) == 8
|
||||
assert len(tools_for_scopes(None)) == 8
|
||||
|
||||
|
||||
def test_catalog_filters_slims_projects_and_keeps_totals():
|
||||
def fake(method, path, *, params):
|
||||
assert method == "GET"
|
||||
if path == "/v1/biz-migration/projects":
|
||||
assert params == {}
|
||||
return {"ok": True, "data": {"items": [
|
||||
{"id": f"p{i}", "status": "active", "monitor_template": {"name": "Core"},
|
||||
"old_task": {"ne_name": "R1", "ne_ip": "10.0.0.1", "commands": "large"}, "large_template": "drop"}
|
||||
for i in range(3)]}}
|
||||
assert path == "/v1/biz-state/tasks" and params == {"purpose": "portrait"}
|
||||
return {"ok": True, "data": {"items": [{"id": "t", "status": "paused", "ne_name": "R1"}]}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake):
|
||||
result = payload(call_http_tool("listBizMonitors", {"q": "r1", "status": "ACTIVE", "purpose": "portrait", "limit": 1}))
|
||||
data = result["data"]
|
||||
assert data["counts"] == {"projects": 3, "tasks": 0, "projects_returned": 1, "tasks_returned": 0}
|
||||
assert data["projects"][0]["old_ne"] == "R1 (10.0.0.1)"
|
||||
assert "old_task" not in data["projects"][0] and "large_template" not in data["projects"][0]
|
||||
assert "netx__" not in data["next"]
|
||||
|
||||
|
||||
def test_batch_cutover_expand_runs_and_partial_error():
|
||||
def fake(method, path, *, params):
|
||||
if path.endswith("/batches"):
|
||||
return {"ok": True, "data": {"items": [{"id": "b1", "status": "done", "large": "drop"}, {"id": "b2", "status": "done"}]}}
|
||||
assert params == {"limit": 3}
|
||||
if "/b2/" in path:
|
||||
return {"ok": False, "error": "netx_http_404"}
|
||||
return {"ok": True, "data": {"items": [{"id": "r1", "summary": {"anomaly": 2, "progress": {"ok": 4, "total": 5}, "large": "drop"}}]}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake) as mock:
|
||||
out = call_http_tool("listBizMonitorBatches", {"project_id": "p/1", "include_runs": True, "runs_limit": 3})
|
||||
data = payload(out)["data"]
|
||||
assert "isError" not in out and mock.call_count == 3
|
||||
assert mock.call_args_list[0].args == ("GET", "/v1/biz-migration/projects/p%2F1/batches")
|
||||
assert data["cutover_batches"][1]["runs_error"] == "netx_http_404"
|
||||
assert data["cutover_batches"][0]["runs"] == [{"id": "r1", "anomaly": 2, "progress_ok": 4, "progress_total": 5}]
|
||||
assert data["counts"]["runs"] == 1 and "runs" not in data
|
||||
assert "large" not in data["cutover_batches"][0]
|
||||
|
||||
|
||||
def test_batch_collect_and_runs_modes():
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json") as mock:
|
||||
mock.return_value = {"ok": True, "data": {"items": [{"id": "c1", "status": "done", "raw_text": "drop"}]}}
|
||||
out = payload(call_http_tool("listBizMonitorBatches", {"task_id": "t", "limit": 20}))
|
||||
mock.assert_called_once_with("GET", "/v1/biz-state/tasks/t/batches", params={"limit": 20})
|
||||
assert out["data"]["collect_batches"] == [{"id": "c1", "status": "done", "task_id": "t"}]
|
||||
mock.reset_mock()
|
||||
mock.return_value = {"ok": True, "data": {"items": [{"id": "r1", "summary": {"progress_ok": 2}}]}}
|
||||
out = payload(call_http_tool("listBizMonitorBatches", {"batch_id": "b"}))
|
||||
mock.assert_called_once_with("GET", "/v1/biz-migration/batches/b/runs", params={"limit": 10})
|
||||
assert out["data"]["runs"] == [{"id": "r1", "progress_ok": 2}]
|
||||
|
||||
|
||||
def test_batch_all_caps_run_expansion_at_five():
|
||||
def fake(method, path, *, params):
|
||||
if path == "/v1/biz-migration/projects/p/batches":
|
||||
return {"ok": True, "data": {"items": [{"id": f"b{i}"} for i in range(8)]}}
|
||||
if path == "/v1/biz-state/tasks/t/batches":
|
||||
return {"ok": True, "data": {"items": [{"id": "c"}]}}
|
||||
assert path.endswith("/runs")
|
||||
return {"ok": True, "data": {"items": [{"id": "r"}]}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", side_effect=fake) as mock:
|
||||
data = payload(call_http_tool("listBizMonitorBatches", {
|
||||
"project_id": "p", "task_id": "t", "include_runs": True,
|
||||
}))["data"]
|
||||
assert mock.call_count == 7 # two catalogs plus five run lookups
|
||||
assert data["kind"] == "all"
|
||||
assert data["counts"] == {"cutover_batches": 8, "collect_batches": 1, "runs": 5}
|
||||
assert sum("runs" in row for row in data["cutover_batches"]) == 5
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args,path,params", [
|
||||
("getBizMonitorContext", {"project_id": " p/1 ", "task_id": "t"}, "/v1/biz-migration/monitor-context", {"project_id": "p/1", "task_id": "t"}),
|
||||
("getBizMonitorBoard", {"batch_id": "b/1", "run_id": "r"}, "/v1/biz-migration/batches/b%2F1/board", {"run_id": "r"}),
|
||||
("listBizMonitorReds", {"project_id": "p", "status": "open", "limit": 700}, "/v1/biz-migration/projects/p/red-tickets", {"status": "open", "limit": 500}),
|
||||
("getBizMonitorDiffs", {"run_id": "r/1", "metric_id": "m", "sheet_id": "s", "verdict": "changed", "color": "red", "q": "xe-1", "offset": -2, "limit": 10}, "/v1/biz-migration/runs/r%2F1/diffs", {"metric_id": "m", "sheet_id": "s", "verdict": "changed", "color": "red", "kw": "xe-1", "offset": 0, "limit": 10}),
|
||||
("getBizCollectBatch", {"batch_id": "c/1"}, "/v1/biz-state/batches/c%2F1", {}),
|
||||
("getBizCollectCommandRaw", {"batch_id": "c", "command_id": "cmd/1"}, "/v1/biz-state/batches/c/commands/cmd%2F1", {}),
|
||||
])
|
||||
def test_each_read_tool_forwards_exact_http(name, args, path, params):
|
||||
evidence = {"old_key": "xe-1", "new_key": "ge-2", "evidence": {"raw_command": "show interface"}}
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": evidence}) as mock:
|
||||
result = call_http_tool(name, args)
|
||||
mock.assert_called_once_with("GET", path, params=params)
|
||||
assert payload(result)["data"] == evidence
|
||||
|
||||
|
||||
def test_diff_default_limit_not_forwarded_and_only_kw_used():
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": {}}) as mock:
|
||||
call_http_tool("getBizMonitorDiffs", {"run_id": "r", "q": " A B "})
|
||||
assert mock.call_args.kwargs["params"] == {"kw": "A B"}
|
||||
|
||||
|
||||
def test_raw_not_truncated_and_credentials_removed(monkeypatch):
|
||||
monkeypatch.setenv("NETX_API_TOKEN", "nxt_do_not_leak")
|
||||
raw = "show interfaces\n" + "interface xe-1 up\n" * 10000
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value={"ok": True, "data": {
|
||||
"raw_text": raw, "device": {"password": "hidden", "ne_name": "R1"},
|
||||
"api_token": "hidden", "note": "Bearer abcd password=secret nxt_do_not_leak",
|
||||
}}):
|
||||
result = call_http_tool("getBizCollectCommandRaw", {"batch_id": "b", "command_id": "c"})
|
||||
data = payload(result)["data"]
|
||||
assert data["raw_text"] == raw and data["device"] == {"ne_name": "R1"}
|
||||
assert "api_token" not in data
|
||||
assert "abcd" not in data["note"] and "secret" not in data["note"] and "nxt_do_not_leak" not in data["note"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("name,args,error", [
|
||||
("getBizMonitorContext", {}, "project_id_or_task_id_required"),
|
||||
("getBizMonitorBoard", {}, "batch_id_required"),
|
||||
("listBizMonitorReds", {}, "project_id_required"),
|
||||
("getBizMonitorDiffs", {}, "run_id_required"),
|
||||
("getBizCollectBatch", {}, "batch_id_required"),
|
||||
("getBizCollectCommandRaw", {"batch_id": "b"}, "batch_id_and_command_id_required"),
|
||||
("listBizMonitorBatches", {}, "project_id_or_task_id_or_batch_id_required"),
|
||||
("listBizMonitorBatches", {"kind": "all", "project_id": "p"}, "task_id_required"),
|
||||
("listBizMonitors", {"kind": "project"}, "kind_invalid"),
|
||||
("listBizMonitors", {"limit": "two"}, "invalid_argument"),
|
||||
("getBizMonitorDiffs", {"run_id": "r", "offset": True}, "invalid_argument"),
|
||||
("getBizMonitorDiffs", {"run_id": "r", "kw": "x"}, "invalid_argument"),
|
||||
])
|
||||
def test_bad_arguments_never_make_http_call(name, args, error):
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json") as mock:
|
||||
result = call_http_tool(name, args)
|
||||
mock.assert_not_called()
|
||||
assert result["isError"] is True and payload(result)["error"] == error
|
||||
|
||||
|
||||
def test_argument_error_names_field_without_echoing_value():
|
||||
result = payload(call_http_tool("listBizMonitors", {"limit": "password=hidden"}))
|
||||
assert "limit" in result["detail"]
|
||||
assert "hidden" not in json.dumps(result)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [
|
||||
{"ok": False, "error": "netx_http_404"},
|
||||
{"ok": True, "data": {"ok": False, "error": "not_found"}},
|
||||
])
|
||||
def test_upstream_failures_are_tool_errors(failure):
|
||||
with patch("netx_bizmonitor_mcp.http_tools.http_json", return_value=failure):
|
||||
result = call_http_tool("getBizCollectBatch", {"batch_id": "b"})
|
||||
assert result["isError"] is True
|
||||
|
||||
|
||||
def test_fetch_scopes_and_server_scope_denial():
|
||||
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": True, "data": {"scopes": ["ne:read"]}}):
|
||||
assert _fetch_scopes() == ["ne:read"]
|
||||
assert handle_request(request("tools/list"))["result"]["tools"] == []
|
||||
with patch("netx_bizmonitor_mcp.server.call_http_tool") as call:
|
||||
denied = handle_request(request("tools/call", {"name": "getBizCollectBatch", "arguments": {"batch_id": "b"}}))
|
||||
call.assert_not_called()
|
||||
assert "error" not in denied and denied["result"]["isError"]
|
||||
assert payload(denied["result"])["required_scope"] == "biz-monitor:read"
|
||||
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": True, "data": {"user": {"scopes": ["biz-monitor:read"]}}}):
|
||||
assert _fetch_scopes() == ["biz-monitor:read"]
|
||||
with patch("netx_bizmonitor_mcp.server.http_json", return_value={"ok": False}):
|
||||
assert _fetch_scopes() is None
|
||||
|
||||
|
||||
def test_protocol_errors_and_notifications():
|
||||
for obj in ([], "x", {}, {"jsonrpc": "2.0", "id": True, "method": "ping"}):
|
||||
assert handle_request(obj)["error"]["code"] == -32600
|
||||
assert handle_request(request("unknown"))["error"]["code"] == -32601
|
||||
assert handle_request(request("tools/call", {"name": "netx__getBizCollectBatch"}))["error"]["code"] == -32602
|
||||
assert handle_request(request("tools/call", {"name": "getBizCollectBatch", "arguments": []}))["error"]["code"] == -32602
|
||||
with patch("netx_bizmonitor_mcp.server.http_json") as mock:
|
||||
for method in ("initialize", "notifications/initialized", "notifications/cancelled", "ping", "tools/call"):
|
||||
assert handle_request({"jsonrpc": "2.0", "method": method}) is None
|
||||
mock.assert_not_called()
|
||||
stdin, stdout = io.StringIO('{bad\n' + json.dumps(request("ping", rid=2)) + '\n'), io.StringIO()
|
||||
with patch("sys.stdin", stdin), patch("sys.stdout", stdout):
|
||||
run_stdio_loop()
|
||||
lines = [json.loads(line) for line in stdout.getvalue().splitlines()]
|
||||
assert lines[0]["error"]["code"] == -32700 and lines[1]["id"] == 2
|
||||
|
||||
|
||||
def test_http_auth_lang_redaction_and_error_sanitization(monkeypatch):
|
||||
monkeypatch.setenv("NETX_API_TOKEN", "nxt_secret")
|
||||
monkeypatch.setenv("NETX_API_URL", "http://example.test/netx")
|
||||
monkeypatch.setenv("NETX_LANG", "en")
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
response = httpx.Response(200, json={"id": "b", "password": "secret", "nested": {"api_key": "bad"}})
|
||||
client.return_value.__enter__.return_value.request.return_value = response
|
||||
result = http_json("GET", "/v1/biz-state/batches/b", params={"limit": 2})
|
||||
client.assert_called_once_with(timeout=45.0, trust_env=False, follow_redirects=False)
|
||||
kwargs = client.return_value.__enter__.return_value.request.call_args.kwargs
|
||||
assert kwargs["headers"]["authorization"] == "Bearer nxt_secret"
|
||||
assert kwargs["params"] == {"lang": "en", "limit": 2}
|
||||
assert result["data"] == {"id": "b", "nested": {}}
|
||||
client.return_value.__enter__.return_value.request.return_value = httpx.Response(500, text="password=leak nxt_secret")
|
||||
result = http_json("GET", "/v1/test")
|
||||
assert result["error"] == "netx_http_500" and "leak" not in json.dumps(result)
|
||||
client.return_value.__enter__.return_value.request.side_effect = httpx.ConnectError("Bearer leak")
|
||||
assert "leak" not in json.dumps(http_json("GET", "/v1/test"))
|
||||
|
||||
|
||||
def test_invalid_url_token_and_write_never_open_client(monkeypatch, tmp_path):
|
||||
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
|
||||
monkeypatch.delenv("NETX_MCP_TOKEN_FILE", raising=False)
|
||||
monkeypatch.chdir(tmp_path)
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
monkeypatch.setenv("NETX_API_URL", "https://user:pass@example.test")
|
||||
assert http_json("GET", "/x")["error"] == "netx_api_url_invalid"
|
||||
monkeypatch.setenv("NETX_API_URL", "http://example.test")
|
||||
assert http_json("GET", "/x")["error"] == "netx_token_missing"
|
||||
assert http_json("POST", "/x")["error"] == "read_only_transport"
|
||||
client.assert_not_called()
|
||||
|
||||
|
||||
def test_stdio_subprocess_initialize_list_notifications_errors(tmp_path):
|
||||
env = os.environ.copy()
|
||||
env["PYTHONPATH"] = str(Path(__file__).resolve().parents[1] / "src")
|
||||
env["NETX_API_URL"] = "http://127.0.0.1:1"
|
||||
env.pop("NETX_API_TOKEN", None) # no network attempted when token is absent
|
||||
env.pop("NETX_MCP_TOKEN_FILE", None)
|
||||
messages = [request("initialize"), {"jsonrpc": "2.0", "method": "initialize"},
|
||||
{"jsonrpc": "2.0", "method": "notifications/initialized"}, request("ping", rid=2),
|
||||
request("tools/list", rid=3), request("tools/call", {"name": "getBizCollectBatch", "arguments": {}}, rid=4)]
|
||||
run = subprocess.run([sys.executable, "-m", "netx_bizmonitor_mcp"],
|
||||
input="\n".join(json.dumps(item) for item in messages) + "\n{bad\n",
|
||||
text=True, encoding="utf-8", capture_output=True, env=env, cwd=tmp_path, timeout=10)
|
||||
assert run.returncode == 0 and run.stderr == ""
|
||||
replies = [json.loads(line) for line in run.stdout.splitlines()]
|
||||
assert len(replies) == 5
|
||||
assert replies[0]["result"]["serverInfo"]["name"] == "netx-bizmonitor-mcp"
|
||||
assert replies[1] == {"jsonrpc": "2.0", "id": 2, "result": {}}
|
||||
assert len(replies[2]["result"]["tools"]) == 8
|
||||
assert replies[3]["result"]["isError"] is True
|
||||
assert replies[4]["error"]["code"] == -32700
|
||||
|
||||
|
||||
def test_token_priority_env_then_explicit_file_then_cwd(monkeypatch, tmp_path):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
default = tmp_path / "data" / "auth" / "mcp_token"
|
||||
default.parent.mkdir(parents=True)
|
||||
default.write_text(" default-token\n", encoding="utf-8")
|
||||
explicit = tmp_path / "custom-token"
|
||||
explicit.write_text(" explicit-token\n", encoding="utf-8")
|
||||
monkeypatch.setenv("NETX_API_TOKEN", " env-token ")
|
||||
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(explicit))
|
||||
assert api_token() == "env-token"
|
||||
monkeypatch.setenv("NETX_API_TOKEN", " ")
|
||||
assert api_token() == "explicit-token"
|
||||
explicit.write_text(" \n", encoding="utf-8")
|
||||
assert api_token() == "default-token"
|
||||
monkeypatch.delenv("NETX_MCP_TOKEN_FILE")
|
||||
assert api_token() == "default-token"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("file_error", ["missing", "directory", "invalid_utf8", "permission"])
|
||||
def test_token_file_errors_fall_back_without_exposing_contents(monkeypatch, tmp_path, capsys, file_error):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
|
||||
default = tmp_path / "data" / "auth" / "mcp_token"
|
||||
default.parent.mkdir(parents=True)
|
||||
default.write_text("fallback-token", encoding="utf-8")
|
||||
explicit = tmp_path / "sensitive-file"
|
||||
if file_error == "directory":
|
||||
explicit.mkdir()
|
||||
elif file_error == "invalid_utf8":
|
||||
explicit.write_bytes(b"secret\xff")
|
||||
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(explicit))
|
||||
if file_error == "permission":
|
||||
original = Path.read_text
|
||||
def read(path, *args, **kwargs):
|
||||
if path == explicit:
|
||||
raise PermissionError("sensitive exception text")
|
||||
return original(path, *args, **kwargs)
|
||||
monkeypatch.setattr(Path, "read_text", read)
|
||||
assert api_token() == "fallback-token"
|
||||
default.unlink()
|
||||
assert api_token() == ""
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
result = http_json("GET", "/v1/auth/me")
|
||||
client.assert_not_called()
|
||||
assert result["error"] == "netx_token_missing"
|
||||
assert "sensitive-file" not in json.dumps(result) and "sensitive exception" not in json.dumps(result)
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == captured.err == ""
|
||||
|
||||
|
||||
@pytest.mark.parametrize("use_explicit", [True, False])
|
||||
def test_file_token_auth_and_redaction_survives_rotation(monkeypatch, tmp_path, use_explicit):
|
||||
monkeypatch.chdir(tmp_path)
|
||||
monkeypatch.delenv("NETX_API_TOKEN", raising=False)
|
||||
monkeypatch.delenv("NETX_MCP_TOKEN_FILE", raising=False)
|
||||
monkeypatch.setenv("NETX_API_URL", "http://example.test")
|
||||
token_file = tmp_path / "custom-token" if use_explicit else tmp_path / "data" / "auth" / "mcp_token"
|
||||
token_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
token_file.write_text("nxt_file_secret", encoding="utf-8")
|
||||
if use_explicit:
|
||||
monkeypatch.setenv("NETX_MCP_TOKEN_FILE", str(token_file))
|
||||
assert redact({"nested": ["raw nxt_file_secret"]}) == {"nested": ["raw [REDACTED]"]}
|
||||
def response(*args, **kwargs):
|
||||
assert kwargs["headers"]["authorization"] == "Bearer nxt_file_secret"
|
||||
token_file.write_text("nxt_rotated", encoding="utf-8")
|
||||
return httpx.Response(200, json={"raw_text": "prefix nxt_file_secret suffix"})
|
||||
with patch("netx_bizmonitor_mcp.http_client.httpx.Client") as client:
|
||||
client.return_value.__enter__.return_value.request.side_effect = response
|
||||
result = http_json("GET", "/v1/biz-state/batches/b/commands/c")
|
||||
assert result["data"]["raw_text"] == "prefix [REDACTED] suffix"
|
||||
assert "nxt_file_secret" not in json.dumps(result)
|
||||
Loading…
Add table
Add a link
Reference in a new issue