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:
oliver 2026-10-16 18:52:05 +08:00
parent d8da7acb7d
commit 9085cac742
63 changed files with 3251 additions and 1092 deletions

View 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、业务与协议错误、通知、凭据来源优先级、文件错误回退、令牌轮换与脱敏及长原文不截断。

View 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"
}
}
}
}

View 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"]

View file

@ -0,0 +1,3 @@
"""Standalone, read-only netx business-monitor MCP."""
__version__ = "0.1.0"

View file

@ -0,0 +1,3 @@
from .server import main
main()

View file

@ -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

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

View file

@ -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()

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

View 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)