MCP: serve tools from the API process over Streamable HTTP at /mcp

- netx_api/mcp_remote: ops / topology / bizMonitor tools, Bearer auth,
  per-tool RBAC filtering, ?groups= filter, readOnlyHint and group meta.
- Mount the official MCP Streamable HTTP app at /mcp (stateless);
  AuthAuditMiddleware is pure ASGI and skips /mcp.
- Let uvicorn chunk MCP responses; some Windows LSPs rewrite
  Content-Length replies on */mcp* paths into broken chunked framing.
- NETX_MCP_ALLOWED_HOSTS opts into DNS-rebinding checks (off by default
  so LAN and reverse-proxy hosts work; Bearer is the gate).
- Remove the three stdio Python MCP packages; tests moved to /mcp,
  including real-TCP framing regressions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 21:52:08 +08:00
parent 918ece86c3
commit 52679a7cfc
150 changed files with 2367 additions and 2812 deletions

View file

@ -9,7 +9,7 @@ Licensed under the [MIT License](LICENSE). Security reports: [SECURITY.md](SECUR
- Import ZTE Alarm Monitor Excel - Import ZTE Alarm Monitor Excel
- Normalize and store alarms into an isolated PostgreSQL - Normalize and store alarms into an isolated PostgreSQL
- Query and aggregate alarms via REST API - Query and aggregate alarms via REST API
- Expose the same read capabilities through MCP stdio tools - Expose the same capabilities through built-in remote MCP (`/mcp`)
- Quick diagnostics endpoint (`/v1/diagnostics`) - Quick diagnostics endpoint (`/v1/diagnostics`)
- AP analysis bridge to oclaw (`/v1/ap/analyze`) - AP analysis bridge to oclaw (`/v1/ap/analyze`)
@ -162,32 +162,21 @@ After `npm run build` in `web/`, the API can also serve the UI at `http://127.0.
Fool-proof Windows delivery via `NetX-Setup-*.exe` (bundled or external Postgres, program/data split, offline upgrade over existing installs) lives under [`packaging/`](packaging/README.md). **Linux installs are unchanged** — keep using your own Postgres and `scripts/start_netx.sh`. Fool-proof Windows delivery via `NetX-Setup-*.exe` (bundled or external Postgres, program/data split, offline upgrade over existing installs) lives under [`packaging/`](packaging/README.md). **Linux installs are unchanged** — keep using your own Postgres and `scripts/start_netx.sh`.
### 6) MCP(Cursor / oclaw / Claude) ### 6) MCP(Cursor / Codex / Claude / DSH)
先启动 netx API(§5),再在 **MCP 宿主同机** 安装轻量客户端并配置。 先启动 netx API(§5)。MCP 已内置在 API 同端口:`http://127.0.0.1:8890/mcp`(Streamable HTTP)。客户端只需 URL + Bearer token,**不必再装 Python MCP 包**。
**完整说明(安装、配置、更新、排错)见:[docs/MCP.md](docs/MCP.md)** **完整说明见:[docs/MCP.md](docs/MCP.md)**(topology / bizMonitor 分组见同目录文档)。配置示例:[mcp.all.json](mcp.all.json)、[mcp.json](mcp.json)。
**拓扑画布独立 MCP** 见:[docs/MCP_TOPOLOGY.md](docs/MCP_TOPOLOGY.md)
**割接 / 业务监控独立 MCP** 见:[docs/MCP_BIZMONITOR.md](docs/MCP_BIZMONITOR.md)。三组一起使用时无需 netxops,完整配置为 [mcp.all.json](mcp.all.json)。 ```bash
# Claude Code
claude mcp add --transport http netx http://127.0.0.1:8890/mcp \
--header "Authorization: Bearer <token>"
速查: # 只要 ops / topology / bizMonitor 之一:
# http://127.0.0.1:8890/mcp?groups=ops
```powershell
pip install -e ./packages/netx-mcp
# 拓扑(可选,单独安装)
pip install -e ./packages/netx-topology-mcp
pip install -e ./packages/netx-bizmonitor-mcp
# 三组配置见 mcp.all.json,分别运行:
python -m netx_mcp
python -m netx_topology_mcp
python -m netx_bizmonitor_mcp
``` ```
- 客户端配置:[`mcp.json`](mcp.json)(含 `netx` + 可选 `netx-topology`)
- oclaw payload:[`mcp_install_payload.json`](mcp_install_payload.json) / [`mcp_topology_install_payload.json`](mcp_topology_install_payload.json)
- 子包:[`packages/netx-mcp`](packages/netx-mcp/README.md)、[`packages/netx-topology-mcp`](packages/netx-topology-mcp/README.md)、[`packages/netx-bizmonitor-mcp`](packages/netx-bizmonitor-mcp/README.md)
## Useful API endpoints ## Useful API endpoints
- `POST /v1/alarms/import` (legacy import path, kept for compatibility) - `POST /v1/alarms/import` (legacy import path, kept for compatibility)

View file

@ -1,249 +1,64 @@
# netx MCP — 安装与更新 # netx 远程 MCP(Streamable HTTP)
netx 通过 **stdio MCP** 把告警/网元能力暴露给 Cursor、Claude Desktop、oclaw 等宿主。MCP 进程只做 HTTP 客户端,**不直连数据库**;数据来自已运行的 netx REST API。 netx API 在同一端口(默认 `8890`)提供 `/mcp` 端点,使用 MCP **Streamable HTTP** 传输。客户端(Claude Code、Codex、Cursor、DSH netxops)只需配置 URL 与 Bearer token,**无需安装 Python MCP 包**。
## 架构
``` ```
MCP 宿主 (Cursor / oclaw) → stdio netx_mcp → HTTP NETX_API_URL → netx API MCP 宿主 → HTTP http://127.0.0.1:8890/mcp → netx_api.mcp_remote(进程内工具)
``` ```
| 组件 | 部署位置 | 说明 | 工具分组(查询参数 `groups`):
|------|----------|------|
| **netx API** | 本机或远端服务器 | `python -m netx_api.main`,默认 `http://127.0.0.1:8890` |
| **netx-mcp** | 与 MCP 宿主同机 | `pip install` 轻量包,仅需 Python + httpx |
--- | groups | 工具数 | 说明 |
|--------|--------|------|
| `ops` | 15 | 告警 / 网元 / 只读 CLI |
| `topology` | 16 | 拓扑树 / 画布 / Fabric / 布局 |
| `bizMonitor` | 8 | 割接与业务状态只读 |
| (省略) | 39 | 当前 token 有权限的全部工具 |
## 前置条件 示例:`/mcp?groups=ops,bizMonitor`
1. **netx API 已启动**且可访问: ## 鉴权
```powershell - Header:`Authorization: Bearer <token>`
curl http://127.0.0.1:8890/health - 接受 MCP 令牌(`NETX_AUTH_MCP_TOKEN_FILE`,bootstrap 写入)或用户 JWT
``` - 无 token / 无效 → HTTP **401**
- `tools/list` 只列出当前 token 有 scope 的工具;`tools/call` 再按 RBAC 校验
2. **Python 3.11+**(与 MCP 宿主使用的 `python` 一致)。 ## 客户端配置
3. 远端 API 时:记下 base URL(无尾部 `/`),例如 `http://10.0.0.5:8890`。 见根目录 [`mcp.all.json`](../mcp.all.json)。Claude Code 示例:
--- ```bash
claude mcp add --transport http netx http://127.0.0.1:8890/mcp \
## 1. 安装 `netx-mcp` 包 --header "Authorization: Bearer <token>"
在 **运行 MCP 的那台机器**上执行(不必安装整套 netx 服务依赖):
```powershell
cd D:\project\chatgpt\netx
pip install -e ./packages/netx-mcp
``` ```
验证(**必须用 oclaw 启动时会调用的同一个 `python`**,见下方说明): 仅 ops:
```powershell ```text
where python http://127.0.0.1:8890/mcp?groups=ops
python -c "import netx_mcp; print('ok')"
python -m netx_mcp
``` ```
若你在 A 终端里 `import netx_mcp` 成功,但 oclaw Admin 安装/Health 无反应或失败,多半是 **pip 装到了另一个 Python**。请用 oclaw 实际用的解释器安装: ## 语言
```powershell - 环境变量 `NETX_LANG=zh|en`,或请求头 `Accept-Language`
# 把路径换成 where python 的第一条,或 oclaw 服务 venv 里的 python.exe - 工具 `description` / `inputSchema` 与历史 Python 包一致;`en` 时 API 查询附加 `lang=en`
C:\Path\To\Same\python.exe -m pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp"
C:\Path\To\Same\python.exe -c "import netx_mcp; print('ok')"
```
从 GitHub 安装(无本地仓库时,**子目录必须是 `packages/netx-mcp`**): ## 安全
```powershell - `execManagedNe` 等写工具带 `annotations.readOnlyHint=false`
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp" - CLI 硬门(`cli_guard`):仅单行 `show`/`display`,拒绝 shell 与破坏性命令
``` - 异步 exec / layout job:先返回 job id,再用轮询工具查询
开发者在 netx 仓库根目录也可 `pip install -e .`(含 API);MCP 仍推荐只装 `packages/netx-mcp`。 ## 版本
--- `initialize.serverInfo.version` 使用 netx 包版本(`pyproject.toml` / `netx-ops`)。
## 2. 环境变量 无状态模式下不主动推送 `notifications/tools/list_changed`;客户端应在需要时重新 `tools/list`。
| 变量 | 必填 | 默认 | 说明 | ## 相关文档
|------|------|------|------|
| `NETX_API_URL` | 否 | `http://127.0.0.1:8890` | netx REST 根地址,可指向远端 |
| `NETX_API_TOKEN` | 否 | 空 | Bearer;空则自动读 `data/auth/mcp_token`(API 首次启动生成) |
| `NETX_MCP_TOKEN_FILE` | 否 | `data/auth/mcp_token` | 默认 token 文件路径 |
| `NETX_LANG` | 否 | `zh` | `zh` / `en`,影响 API 文案 |
| `NETX_NE_EXEC_MAX_COMMANDS` | 否 | `5` | `execManagedNe` 单次最多命令数(硬上限 50);API 与 MCP 需同设 |
| `NETX_NE_EXEC_ASYNC_MIN_NES` | 否 | `4` | 批量 NE 数 ≥ 此值时自动走后台 job(`0`=仅 `async=true`) |
| `NETX_NE_EXEC_JOB_DIR` | 否 | `data/ne_exec_jobs` | 后台 exec job 状态目录 |
| `NETX_NE_EXEC_JOB_MAX_CONCURRENT` | 否 | `3` | 同时运行的 exec job 上限 |
本机默认端口时 **可不设任何变量**。启用登录后,先启动一次 netx API,会生成 `data/auth/mcp_token`;MCP 会自动带上该 token。若要把 token 写进 Cursor 配置: - [MCP_TOPOLOGY.md](./MCP_TOPOLOGY.md) — topology 组工具说明
- [MCP_BIZMONITOR.md](./MCP_BIZMONITOR.md) — bizMonitor 组工具说明
```json
"NETX_API_TOKEN": "nxt_从文件复制的内容"
```
---
## 3. 在 Cursor / Claude Desktop 中配置
复制仓库中的 [`mcp.json`](../mcp.json)(或 [`packages/netx-mcp/mcp.json`](../packages/netx-mcp/mcp.json))到客户端 MCP 配置,例如 Cursor:`.cursor/mcp.json`。
```json
{
"mcpServers": {
"netx": {
"command": "python",
"args": ["-m", "netx_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
}
}
}
}
```
(可选)显式设置 `"NETX_API_TOKEN": "nxt_..."`;不设则读仓库/`cwd` 下的 `data/auth/mcp_token`。
保存后 **重启 Cursor/客户端**,使 MCP 子进程重新拉起。
---
## 4. 在 oclaw Admin 中安装
与 Cursor **同一份** `mcpServers` JSON 即可,无需转成别的格式。
1. 完成上文 **§1**(用 **oclaw 同机同一个 `python`** 安装 `netx-mcp`)。
2. Admin → MCP → 粘贴 `mcp.json` 全文 → 点击 **Install from JSON**(安装状态在下方一行小字)。
3. **Health** → **Sync Tools**(完整 scope 下应看到 **15** 个工具)。
4. 在 **MCP 专家绑定** 中为 ops 专家勾选 `server_id=netx`。
更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库:
`oclaw/docs/NETX_MCP_INTEGRATION.md`。
可选:oclaw 专用字段展开版 [`mcp_install_payload.json`](../mcp_install_payload.json)(与 `mcp.json` 等价)。
**Skills(真源)**:仓库根 [`skills/`](../skills/README.md) — `netx-ops` / `netx-topology` (ops = NMS + managed CLI)。MCP 包不再带 skills 镜像;Cursor 直接指 `netx/skills/`。dsh-netxops 发版前可 `sync-skills-from-netx.ps1`。oclaw 旧 playbook 不再维护。
**拓扑画布 MCP**(独立安装/绑定)见 [`MCP_TOPOLOGY.md`](./MCP_TOPOLOGY.md)(`server_id=netx-topology`)。
---
## 5. 暴露的工具
模型面使用通用 **Nms** 命名(当前适配器仍为 zte-ume,HTTP 仍 `/v1/ume/*`)。**0.3.0** 起不再暴露 `*Ume*` 工具名。
| 类别 | 工具名 |
|------|--------|
| NMS 告警 | `queryNmsAlarms`, `aggregateNmsAlarms`, `runNmsDiagnostics` |
| NMS 网元 | `queryNmsNeInventory`, `getNmsNe` |
| NMS 原始/SQL | `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms` |
| common(CLI + 路径) | `listManagedNe`, `getManagedNe`, `execManagedNe`, `getNeExecJob`, `listCliTargets`, `findTopologyPaths` |
参数使用 `nms_ne_id` / `nms_ne_ids`,旧 `ume_*` 模型参数已删除;`getManagedNe` 只接受 `ne_id`。拓扑 Fabric / 画布工具在 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**。oclaw 中名称带前缀:`mcp__netx__<toolName>`;DSH:`netx__<toolName>`。
**业务监控 / 割接分析**:独立 [`netx-bizmonitor-mcp`](./MCP_BIZMONITOR.md) 提供 8 个工具,需 `biz-monitor:read`;netxops 的 bizMonitor 组使用相同 stem。三组统一配置见根 [`mcp.all.json`](../mcp.all.json),不需要安装 netxops。
**CLI 安全边界**:独立 MCP 强制单行只读 `show` / `display`,可用只读输出过滤;配置、shell 操作符、重定向和多行脚本在 HTTP 前拒绝。后端开放 exec_policy 或 capability 的脚本提示也不能解除这个 MCP 门禁,Linux/RouterOS 原生 CLI 当前未暴露。异步 job_id 仅表示开始,按 poll_after_ms 有界轮询;terminal 后检查 result.ok,批量还须检查 summary.failed 和每条 results[].ok,不能只凭作业 succeeded 宣称全部成功。
---
## 6. 更新 MCP
MCP 代码在 **`packages/netx-mcp`**(版本见 `packages/netx-mcp/pyproject.toml`)。更新步骤:
### 6.1 重装 `netx-mcp` 包
**远程 MCP 机 / 无本地 netx 仓库**(不必 `git pull`,每次更新执行一条即可):
```powershell
pip install --upgrade "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp"
```
**本机有 netx 仓库(开发)**:
```powershell
cd D:\project\chatgpt\netx
git pull
pip install -e ./packages/netx-mcp
```
确认版本(可选):
```powershell
pip show netx-mcp
```
### 6.2 重启 MCP 宿主
| 宿主 | 操作 |
|------|------|
| **Cursor / Claude** | 完全退出客户端后重开,或重载 MCP |
| **oclaw** | 重启 gateway/主进程;Admin 对 `netx` 再点 **Health** → **Sync Tools** |
仅改 `NETX_API_URL` 等 env 时,同样需重启 MCP 子进程(或 oclaw 整进程)。
### 6.3 无需改配置的情况
- 只改 **netx API** 业务逻辑、未改 MCP 工具名/参数:重装 `netx-mcp` 可选;API 部署后 MCP 自动走新 API。
- 改了 **工具列表或 JSON schema**:必须重装 `netx-mcp` 并在宿主 **Sync Tools**。
### 6.4 从旧入口迁移
| 旧方式 | 新方式 |
|--------|--------|
| `python -m netx_api.mcp` | `python -m netx_mcp`(唯一 Python 模块入口) |
| `NETX_MCP_MODE=db` 直连库 | 已废弃;使用 HTTP 模式 |
旧入口 `netx_api.mcp`、`netx_api/mcp_server.py` 和 `NETX_MCP_MODE=db` 已删除。请安装独立 `netx-mcp` 包并迁移配置;不再保留兼容委托。旧 DB 模式独有的导入告警查询/聚合/批次/诊断、同步触发、历史告警查询仍有 HTTP API,但不属于当前 ops 15 工具。
---
## 7. 自检
**API:**
```powershell
curl http://127.0.0.1:8890/health
```
**MCP 工具列表(需已 `pip install -e packages/netx-mcp`):**
```powershell
cd D:\project\chatgpt\netx
python -m pytest packages/netx-mcp/tests/test_mcp_http.py -q
```
**手动 stdio(PowerShell 示例):**
```powershell
$env:NETX_API_URL = "http://127.0.0.1:8890"
$p = Start-Process python -ArgumentList "-m","netx_mcp" -RedirectStandardInput pipe -RedirectStandardOutput pipe -NoNewWindow -PassThru
# 向 stdin 写入一行 JSON-RPC initialize / tools/list(见 packages/netx-mcp/tests)
```
---
## 8. 常见问题
| 现象 | 处理 |
|------|------|
| `ModuleNotFoundError: netx_mcp` | 用 **oclaw 同机同一个 `python.exe`** 执行 pip 安装(见 §1 验证) |
| **oclaw Admin 点安装「没反应」** | 1) 必须点 **Install from JSON**(只粘贴不点按钮无效)<br>2) 看安装区下方 **install status** 一行字(应出现 `[json] installing...` 或错误)<br>3) 浏览器 F12 → Console 是否有红色报错<br>4) 确认 JSON 为完整 `mcpServers` 或 `mcp_install_payload.json` 格式 |
| oclaw preflight `mcp_python_module_missing` | `python` 在 PATH 里但无 `netx_mcp`;对该 python 执行 git+pip 安装 |
| 工具调用连不上 API | 检查 `NETX_API_URL`、防火墙、远端 API 是否启动 |
| Windows 乱码 / JSON 解析失败 | 配置里保留 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`(见 `mcp.json`) |
| oclaw 工具数为 0 | Admin **Health → Sync Tools**;确认 `server_id=netx` 已绑定 ops 专家 |
| 旧脚本路径或模块不存在 | 安装 `packages/netx-mcp`,配置改为 `"args": ["-m", "netx_mcp"]` |
---
## 相关文件
| 文件 | 用途 |
|------|------|
| [`packages/netx-mcp/`](../packages/netx-mcp/) | MCP 实现与 `pyproject.toml` |
| [`mcp.json`](../mcp.json) | Cursor / oclaw 粘贴用配置 |
| [`mcp_install_payload.json`](../mcp_install_payload.json) | oclaw 字段展开版(可选) |
| [`packages/netx-mcp/README.md`](../packages/netx-mcp/README.md) | 子包速查 |

View file

@ -1,41 +1,9 @@
# 割接与业务监控 MCP # netx MCP · bizMonitor 组
`netx-bizmonitor-mcp` 独立提供 bizMonitor 的 8 个只读工具。不安装 netxops 也能读取已有项目、批次、看板、红票、差异和采集原文。它只依赖 Python 3.11+ 与 httpx,通过 HTTP 使用正在运行的 netx。 割接与业务状态只读工具(8 个),通过同一远程端点提供:
## 三组一起配置 ```text
http://127.0.0.1:8890/mcp?groups=bizMonitor
在运行 MCP 客户端的 Python 环境分别安装:
```powershell
python -m pip install -e ./packages/netx-mcp
python -m pip install -e ./packages/netx-topology-mcp
python -m pip install -e ./packages/netx-bizmonitor-mcp
``` ```
将根 [mcp.all.json](../mcp.all.json) 的三个 server 放入客户端 MCP 配置。也可以不安装包,分别将该包的 `src` 绝对路径设为对应 server 的 `PYTHONPATH`。三个模块分别为 `netx_mcp`、`netx_topology_mcp`、`netx_bizmonitor_mcp`;console scripts 属于各独立包,安装 API 本体不会提供一个缺少依赖的 MCP 假入口。 鉴权与总览见 [MCP.md](./MCP.md)。需要 `biz-monitor:read`。全部工具 `readOnlyHint=true`。
统一设置 `NETX_API_URL`。凭据优先用 `NETX_API_TOKEN`,否则读取 `NETX_MCP_TOKEN_FILE`,再尝试工作目录 `data/auth/mcp_token`。客户端工作目录不确定时,给 `NETX_MCP_TOKEN_FILE` 配置绝对路径。不要把真实令牌提交到仓库。
业务组需 `biz-monitor:read`;ops 需 `alarms:read`、`ne:read`、`ne:exec`,SQL 另需 `sql:query`;topology 读取需 `ne:read`,建图/布局需 `ne:write`。默认 bootstrap token 不含 SQL 和拓扑写权限,因此工具数量会按 scope 缩减;有完整权限时分别列出 **15、16、8** 个工具。
## 入口与证据链
MCP 使用裸名;netxops 使用 `netx__` 加相同名称。
1. `listBizMonitors`:无 ID 的入口,取得 project_id/task_id。
2. `getBizMonitorContext`:读取模板、归一化、端口映射、两侧任务和基线。
3. `listBizMonitorBatches(project_id,include_runs=true)`:取得割接批次与 run_id;也可 `kind=runs,batch_id=...`。
4. `getBizMonitorBoard(batch_id)`:检查完成状态、缺失数据和统计。
5. `listBizMonitorReds(project_id)` / `getBizMonitorDiffs(run_id,color=red)`:逐页取得红项和证据。`q` 自动映射后端 `kw`。
6. `getBizCollectBatch`:采集批次的命令目录;需要时由 `listBizMonitorBatches(task_id)` 找采集批次。
7. `getBizCollectCommandRaw(batch_id,command_id)`:两侧原文取证。这里使用采集 batch_id,不能传割接 batch_id。
`old_key/new_key` 保留设备原始 A/B;`match_*` 和归一化映射用于内部配对。原文按需获取,可能很大;凭据会脱敏,不把内部匹配键冒充设备原始端口。
本组不会创建任务/模板、启动采集、执行 evaluate、关闭红票。没有批次时不能得出“割接通过”。完整行为和参数见 [包 README](../packages/netx-bizmonitor-mcp/README.md) 与 [skill](../skills/biz-monitor/netx-biz-monitor/SKILL.md)。
## 可复现验收
`scripts/mcp_acceptance.py` 启动当前 worktree 的真实 stdio MCP,记录去敏 JSONL;仅允许本机 8890 或隔离样例 8891。工具源目录可用 `--source-root` 显式指定,不会静默导入主仓库的 editable 包。
没有现场割接数据时,可用 `scripts/seed_mcp_acceptance.py` 在 `agent-wt/netx-mcp-lead/artifacts/` 的新 acceptance SQLite 文件生成明确标注的离线样例,真实运行业务评价,再通过本地 API/MCP 验证读取链路。样例不代表现场故障;不要将样例库替换应用库。脚本拒绝覆盖、外部路径、网络连接和监听端口。

View file

@ -1,141 +1,11 @@
# netx Topology MCP — 安装与更新 # netx MCP · topology 组
与告警/CLI 的 [`netx-mcp`](./MCP.md) **分开**的 stdio MCP,只提供 **拓扑树、画布(views)、Fabric 边/邻接**。Agent 可只装本包,或与 `netx` 并存。 拓扑树、画布(views)、Fabric 与布局工具,通过同一远程端点提供:
``` ```text
MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → /v1/topology/* http://127.0.0.1:8890/mcp?groups=topology
``` ```
| 组件 | 说明 | 鉴权与总览见 [MCP.md](./MCP.md)。需要 `ne:read` / `ne:write` 等 scope(写工具含新建文件夹、改节点、布局写入等)。
|------|------|
| **netx API** | 需已启动,拓扑数据在服务端 |
| **netx-topology-mcp** | 轻量 HTTP 客户端,与宿主同机 |
--- 布局算法在 netx 进程内执行(原 `netx-topology-mcp` 逻辑已迁入 `netx_api/mcp_remote/topology/`)。异步布局 job 仍返回 job id 后轮询。
## 1. 安装
```powershell
cd D:\project\chatgpt\netx
pip install -e ./packages/netx-topology-mcp
python -c "import netx_topology_mcp; print('ok')"
python -m netx_topology_mcp
```
从 GitHub:
```powershell
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-topology-mcp"
```
环境变量与 [`MCP.md`](./MCP.md) 相同:`NETX_API_URL`、`NETX_API_TOKEN` / `data/auth/mcp_token`、`NETX_LANG`。
---
## 2. Cursor / oclaw 配置
独立服务器 id:`netx-topology`(不要与 `netx` 混在同一个 command 里)。
```json
{
"mcpServers": {
"netx-topology": {
"command": "python",
"args": ["-m", "netx_topology_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_API_TOKEN": "nxt_your_key_with_ne_write",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
}
}
}
}
```
画图必须设 `NETX_API_TOKEN`(网页新建 Key,默认含 `ne:write`)。未设置时回退读 `data/auth/mcp_token`(只读+CLI,**无写工具**)。改 Key 权限后 Sync Tools / 约 45s 内 scopes 会刷新。
样本:[`packages/netx-topology-mcp/mcp.json`](../packages/netx-topology-mcp/mcp.json)。
与告警 MCP 并存时,把两个 server 都放进 `mcpServers` 即可;未勾选/未安装的不会加载工具。
oclaw:Install from JSON → Health → Sync Tools(完整 scope 下应看到 **16** 个工具,含 `layoutTopologyView` / `sinkTopologyDualUnits` / `queryTopologyFabricNodes`)→ 专家绑定勾选 `server_id=netx-topology`。
Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topology` 或重载窗口(须重启 stdio 进程)。可选在 env 加 `PYTHONPATH=.../packages/netx-topology-mcp/src` 强制最新源码。
配套 Agent Skill(dual_unit 排水 → 扫角/polish → 手拖):[`skills/topology/netx-topology/SKILL.md`](../skills/topology/netx-topology/SKILL.md)。Cursor / oclaw 读 skill 后再调 MCP。
---
## 3. 工具一览
### 目录模型
- 顶级为导航「根」;其下唯一「根图」/ `Root map` 为画布(physical view)。
- 子区域即画布(创建时自动 physical view)。
- 文件夹 `ne_count`:子树去重 Fabric 网元数(来源无关)。
### 读
| 工具 | 作用 |
|------|------|
| `getTopologyTree` | 「根 / 根图 / 子区域」树 + views + `ne_count`(找画布用这个,勿再 listViews) |
| `getTopologyView` | 单图(节点+边+坐标);`detail=summary\|full` |
| `queryTopologyFabricNodes` | Fabric 库存:`mode=summary\|list\|search`(有 `q` 默认 search;list 支持 `region_folder_id`) |
| `classifyTopologyFabricNodes` | **分级打标**:`match`→`tag(level\|role, dry_run)`→`tag`;`level` 为 major.minor(0 外部…3 接入);`role` 预设仍可用。再 `addTopologyViewNodes` 上区域画布。**勿用切片建图** |
| `queryTopologyNeighborhood` | 邻域:compact nodes + `links[]`(NE 对,非端口) |
| `queryTopologyEdges` | 默认 **adjacency**:`links[{a,b,link_count}]`;画布一对网元一条线。`detail=ports` 才给端口行 |
| `analyzeTopologyViewLayout` | 布图验收(只读):`verdict` + score;`score_profile=auto\|default\|eye`(大稀疏斜边→eye);贴边=节点命中∪hits/link;util 甜区下限 0.08;`detail=structure` / `hotspots\|blocks\|both` |
### 写(只动画布,不污染 Fabric)
| 工具 | 作用 |
|------|------|
| `createTopologyFolder` | **唯一建图入口**:顶级→「根」+「根图」;根图下→**子区域**;返回 `view_id` |
| `addTopologyViewNodes` | **优先**传 `keyword`/`role`/`vendor`/`link_status` + `limit`/`offset`,由 API 筛选落点;也可 `fabric_node_ids`。拒绝 managed/UME。返回摘要。 |
| `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 |
| `sinkTopologyDualUnits` | 根图 dual_units 分批沉入子区域;默认 `layout_batch`→`layout_dual_unit`→块扫挂载;日常禁 `until_empty` |
| `copyTopologyViewNodes` | 一键克隆画布成员+坐标到另一画布(`clear_target` 可选);源画布不动,测沙箱用 |
| `layoutTopologyView` `move_nodes` | 指定 `fabric_node_ids` 从 `source_view_id`→`view_id`(默认同移出源);对调两 view 回迁;`park=true` 扫角停靠 |
| `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 |
| `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|clear_edge_hits\|pull_far_chains\|compact_bbox\|polish_crossings\|fix_overlaps\|untangle\|straighten_channels\|level_bands\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。眼 sink 主路径:`until_limit(crossing→total)` → `clear_edge_hits` → `pull_far_chains` → `compact_bbox` → 手拖;禁对眼跑 polish/fix_overlaps/untangle/round。stall 且 moved≈0 即算法到头。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` |
| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布;区域画布务必传 `region_folder_id`,读 `out_of_region_skipped` |
**推荐流水线:** `getTopologyTree` →(可选)`createTopologyFolder` 取 `view_id` → `addTopologyViewNodes` →(可选)邻居投影 / 布局。
**硬顶 2000:目标 ≤2000 画一张图即可**;physical 根图/区域默认 `max_nodes=2000`,custom 仍按角色软顶。Agent 默认 `links[]` 邻接;布图按邻接与 Skill 中的经验摆点。
**刻意不提供:** 手工建链、`populate`、删 Fabric / 删整图。
写操作需要 token 具备 `ne:write`;只读为 `ne:read`。
**权限怎么开:** 网页 **系统 → API Key**(`/api-keys`)。新建默认已含 `ne:write`(可关掉);已有 Key 在 **操作 → 改权限**。把明文配到 `NETX_API_TOKEN` 后重启 MCP / Sync Tools。仓库自动生成的 `data/auth/mcp_token` 仍是只读+CLI(无写),需要画图请另建 Key 或改权限。
**前端能否看着画:** 在拓扑页左侧树或右侧浏览区点 **「实时同步」**(默认关闭;**不需要先打开某张图**)。开启后树约每 5 秒、已打开的图约每 3 秒拉取,可看到 MCP 新建区域/画布并往上加点。有未保存本地拖动时不会覆盖你的编辑。
---
## 4. 与 netx-mcp 的关系
| 包 | server_id | 职责 |
|----|-----------|------|
| `netx-mcp` | `netx` | NMS 告警、网元和只读 CLI(**15** 工具) |
| `netx-topology-mcp` | `netx-topology` | topology 组 15 工具及额外 sinkTopologyDualUnits(共 **16**) |
| `netx-bizmonitor-mcp` | `netx-bizmonitor` | 割接 / 业务状态只读分析(**8** 工具) |
显式后台保存:`layoutTopologyView(view_id=...,action=layout,recipe=compact,mode=apply,params={background:true})`。用 `layoutTopologyView(action=job_status,params={job_id:...})` 轮询,成功后 getTopologyView 回读。任务是 MCP 本地子进程与落盘 JSON,没有 HTTP layout job API;跨目录或机器不能轮询同一 job。失败后可能已有部分写入,先回读,不盲重试。布局保存成功与评分诊断分别判断;评分校准另行安排,本轮不改算法/阈值。
`queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。
---
## 5. 更新
```powershell
cd <netx 仓库>
git pull
pip install -e ./packages/netx-topology-mcp
```
然后重启 MCP 宿主,并 Sync Tools。

View file

@ -1,33 +1,27 @@
{ {
"mcpServers": { "mcpServers": {
"netx": { "netx": {
"command": "python", "url": "http://127.0.0.1:8890/mcp",
"args": ["-m", "netx_mcp"], "headers": {
"env": { "Authorization": "Bearer ${NETX_API_TOKEN}"
"NETX_API_URL": "http://127.0.0.1:8890", }
"NETX_LANG": "zh", },
"PYTHONIOENCODING": "utf-8", "netx-ops": {
"PYTHONUTF8": "1" "url": "http://127.0.0.1:8890/mcp?groups=ops",
"headers": {
"Authorization": "Bearer ${NETX_API_TOKEN}"
} }
}, },
"netx-topology": { "netx-topology": {
"command": "python", "url": "http://127.0.0.1:8890/mcp?groups=topology",
"args": ["-m", "netx_topology_mcp"], "headers": {
"env": { "Authorization": "Bearer ${NETX_API_TOKEN}"
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
} }
}, },
"netx-bizmonitor": { "netx-bizmonitor": {
"command": "python", "url": "http://127.0.0.1:8890/mcp?groups=bizMonitor",
"args": ["-m", "netx_bizmonitor_mcp"], "headers": {
"env": { "Authorization": "Bearer ${NETX_API_TOKEN}"
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
} }
} }
} }

View file

@ -1,23 +1,9 @@
{ {
"mcpServers": { "mcpServers": {
"netx": { "netx": {
"command": "python", "url": "http://127.0.0.1:8890/mcp",
"args": ["-m", "netx_mcp"], "headers": {
"env": { "Authorization": "Bearer ${NETX_API_TOKEN}"
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
}
},
"netx-topology": {
"command": "python",
"args": ["-m", "netx_topology_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
} }
} }
} }

View file

@ -1,19 +1,17 @@
{ {
"source_type": "local", "source_type": "remote",
"source_ref": "netx-mcp", "source_ref": "netx-remote-mcp",
"server_id": "netx", "server_id": "netx",
"version": "0.2.0", "version": "0.4.19",
"entry_command": "python", "transport": "http",
"entry_args": ["-m", "netx_mcp"], "url": "http://127.0.0.1:8890/mcp",
"headers": {
"Authorization": "Bearer ${NETX_API_TOKEN}"
},
"env_schema": { "env_schema": {
"NETX_API_URL": {
"type": "string",
"description": "netx REST API base URL (no trailing slash)",
"default": "http://127.0.0.1:8890"
},
"NETX_API_TOKEN": { "NETX_API_TOKEN": {
"type": "string", "type": "string",
"description": "Optional Bearer token when netx API auth is enabled" "description": "Bearer token (MCP token file or user JWT)"
}, },
"NETX_LANG": { "NETX_LANG": {
"type": "string", "type": "string",

View file

@ -1,19 +1,17 @@
{ {
"source_type": "local", "source_type": "remote",
"source_ref": "netx-topology-mcp", "source_ref": "netx-remote-mcp",
"server_id": "netx-topology", "server_id": "netx-topology",
"version": "0.1.0", "version": "0.4.19",
"entry_command": "python", "transport": "http",
"entry_args": ["-m", "netx_topology_mcp"], "url": "http://127.0.0.1:8890/mcp?groups=topology",
"headers": {
"Authorization": "Bearer ${NETX_API_TOKEN}"
},
"env_schema": { "env_schema": {
"NETX_API_URL": {
"type": "string",
"description": "netx REST API base URL (no trailing slash)",
"default": "http://127.0.0.1:8890"
},
"NETX_API_TOKEN": { "NETX_API_TOKEN": {
"type": "string", "type": "string",
"description": "Optional Bearer token when netx API auth is enabled" "description": "Bearer token (MCP token file or user JWT)"
}, },
"NETX_LANG": { "NETX_LANG": {
"type": "string", "type": "string",

View file

@ -1,14 +1,13 @@
"""HTTP auth gate + request audit middleware.""" """HTTP auth gate + request audit middleware (pure ASGI — safe with Mount /mcp)."""
from __future__ import annotations from __future__ import annotations
import logging import logging
import time import time
from typing import Callable
from starlette.middleware.base import BaseHTTPMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse
from starlette.types import ASGIApp, Message, Receive, Scope, Send
from .auth_deps import resolve_user_from_token from .auth_deps import resolve_user_from_token
from .auth_scopes import has_scope, required_scope_for_request from .auth_scopes import has_scope, required_scope_for_request
@ -35,11 +34,12 @@ _PUBLIC_PREFIXES = (
"/assets", "/assets",
) )
# Authenticated API / infra only. Everything else (SPA routes, favicon, …) is public.
def _is_api_auth_path(path: str) -> bool: def _is_api_auth_path(path: str) -> bool:
p = str(path or "") p = str(path or "")
if p.startswith("/v1/"): if p.startswith("/v1/"):
return True return True
# /mcp has its own Bearer gate (mcp_remote.mount.McpAuthMiddleware).
if p in ("/metrics", "/metrics/json", "/v1/metrics/json", "/openapi.json"): if p in ("/metrics", "/metrics/json", "/v1/metrics/json", "/openapi.json"):
return True return True
if p.startswith("/docs") or p.startswith("/redoc"): if p.startswith("/docs") or p.startswith("/redoc"):
@ -47,7 +47,6 @@ def _is_api_auth_path(path: str) -> bool:
return False return False
# While must_change_password is true, only these authenticated endpoints are allowed.
_PASSWORD_CHANGE_ALLOW = frozenset( _PASSWORD_CHANGE_ALLOW = frozenset(
{ {
("GET", "/v1/auth/me"), ("GET", "/v1/auth/me"),
@ -74,12 +73,16 @@ def _is_public(path: str) -> bool:
return True return True
if any(p.startswith(pref) for pref in _PUBLIC_PREFIXES): if any(p.startswith(pref) for pref in _PUBLIC_PREFIXES):
return True return True
# Bundled UI routes (/login, /topology, …) must load without a token.
if not _is_api_auth_path(p): if not _is_api_auth_path(p):
return True return True
return False return False
def _is_mcp_path(path: str) -> bool:
p = str(path or "")
return p == "/mcp" or p.startswith("/mcp/")
def _client_ip(request: Request) -> str: def _client_ip(request: Request) -> str:
from .client_ip import resolve_client_ip from .client_ip import resolve_client_ip
@ -104,18 +107,37 @@ def _action_for(method: str, path: str) -> str:
return f"http.{m.lower()}" return f"http.{m.lower()}"
class AuthAuditMiddleware(BaseHTTPMiddleware): class AuthAuditMiddleware:
async def dispatch(self, request: Request, call_next: Callable) -> Response: """Pure ASGI auth + audit. BaseHTTPMiddleware breaks Streamable HTTP under Mount."""
if request.method.upper() == "OPTIONS":
return await call_next(request)
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
request = Request(scope, receive)
method = request.method.upper()
path = request.url.path path = request.url.path
if not bool(settings.auth_enabled) or _is_public(path):
return await call_next(request)
# WebSocket upgrades are authenticated inside the WS endpoint. # MCP is authenticated inside the mounted sub-app; never wrap its body.
if _is_mcp_path(path):
await self.app(scope, receive, send)
return
if method == "OPTIONS":
await self.app(scope, receive, send)
return
if not bool(settings.auth_enabled) or _is_public(path):
await self.app(scope, receive, send)
return
if path.startswith("/v1/webcrt/") and path.endswith("/ws"): if path.startswith("/v1/webcrt/") and path.endswith("/ws"):
return await call_next(request) await self.app(scope, receive, send)
return
token = "" token = ""
auth = str(request.headers.get("authorization") or "").strip() auth = str(request.headers.get("authorization") or "").strip()
@ -125,26 +147,27 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
from .auth_cookies import read_access_cookie from .auth_cookies import read_access_cookie
token = read_access_cookie(request) token = read_access_cookie(request)
# Query-string access_token is no longer accepted (leaks via logs/proxies).
db = SessionLocal() db = SessionLocal()
try: try:
resolved = resolve_user_from_token(db, token) if token else None resolved = resolve_user_from_token(db, token) if token else None
if resolved is None: if resolved is None:
# Do not audit anonymous 401s — SPA fan-out floods the log. await JSONResponse(status_code=401, content={"detail": "unauthorized"})(
# Intentional auth ops (login/logout/login_failed) are written in auth_router. scope, receive, send
return JSONResponse(status_code=401, content={"detail": "unauthorized"}) )
return
user, via, scopes, token_id, jti = resolved user, via, scopes, token_id, jti = resolved
if bool(getattr(user, "must_change_password", False)): if bool(getattr(user, "must_change_password", False)):
allow_key = (request.method.upper(), path.rstrip("/") if len(path) > 1 else path) allow_key = (method, path.rstrip("/") if len(path) > 1 else path)
if allow_key not in _PASSWORD_CHANGE_ALLOW: if allow_key not in _PASSWORD_CHANGE_ALLOW:
return JSONResponse( await JSONResponse(
status_code=403, status_code=403,
content={"detail": "password_change_required"}, content={"detail": "password_change_required"},
) )(scope, receive, send)
need = required_scope_for_request(request.method, path) return
need = required_scope_for_request(method, path)
if need and not has_scope(scopes, need): if need and not has_scope(scopes, need):
return JSONResponse( await JSONResponse(
status_code=403, status_code=403,
content={ content={
"detail": { "detail": {
@ -153,7 +176,8 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
"granted": sorted(scopes), "granted": sorted(scopes),
} }
}, },
) )(scope, receive, send)
return
request.state.auth_user = user request.state.auth_user = user
request.state.auth_via = via request.state.auth_via = via
request.state.auth_scopes = scopes request.state.auth_scopes = scopes
@ -164,22 +188,33 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
auth_via = via auth_via = via
except Exception: except Exception:
_log.exception("auth middleware failure path=%s", path) _log.exception("auth middleware failure path=%s", path)
return JSONResponse(status_code=500, content={"detail": "auth_middleware_error"}) await JSONResponse(status_code=500, content={"detail": "auth_middleware_error"})(
scope, receive, send
)
return
finally: finally:
db.close() db.close()
started = time.perf_counter() started = time.perf_counter()
response = await call_next(request) status_code = 0
async def send_wrapper(message: Message) -> None:
nonlocal status_code
if message["type"] == "http.response.start":
status_code = int(message.get("status") or 0)
await send(message)
await self.app(scope, receive, send_wrapper)
try: try:
from .audit_async import enqueue_audit from .audit_async import enqueue_audit
enqueue_audit( enqueue_audit(
action=_action_for(request.method, path), action=_action_for(method, path),
actor_user_id=actor_id, actor_user_id=actor_id,
actor_username=actor_name, actor_username=actor_name,
method=request.method, method=method,
path=path, path=path,
status_code=int(response.status_code), status_code=int(status_code or 0),
client_ip=_client_ip(request), client_ip=_client_ip(request),
user_agent=str(request.headers.get("user-agent") or "")[:512], user_agent=str(request.headers.get("user-agent") or "")[:512],
detail={ detail={
@ -189,4 +224,3 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
) )
except Exception: except Exception:
_log.exception("audit enqueue after request failed path=%s", path) _log.exception("audit enqueue after request failed path=%s", path)
return response

View file

@ -218,6 +218,11 @@ class Settings(BaseSettings):
bootstrap_admin_password: str = "admin123" bootstrap_admin_password: str = "admin123"
# Written on first boot for MCP; path relative to cwd / absolute # Written on first boot for MCP; path relative to cwd / absolute
auth_mcp_token_file: str = "data/auth/mcp_token" auth_mcp_token_file: str = "data/auth/mcp_token"
# Comma-separated Host allow-list for MCP DNS-rebinding guard.
# Empty (default) = disable DNS-rebinding protection (Bearer auth is the gate;
# needed for LAN IP / reverse-proxy Host headers). Example:
# NETX_MCP_ALLOWED_HOSTS=localhost,127.0.0.1,netx.example.com,netx.example.com:*
mcp_allowed_hosts: str = ""
# Cap non-revoked API keys (MCP / DSH / scripts). 0 = unlimited. # Cap non-revoked API keys (MCP / DSH / scripts). 0 = unlimited.
auth_api_token_max_count: int = 20 auth_api_token_max_count: int = 20
# Comma-separated peers allowed to supply X-Forwarded-For / X-Real-IP # Comma-separated peers allowed to supply X-Forwarded-For / X-Real-IP

View file

@ -52,13 +52,17 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
from .app_startup import run_api_startup from .app_startup import run_api_startup
from .app_shutdown import shutdown_runtime from .app_shutdown import shutdown_runtime
from .dsh_alarm_hub import bind_event_loop from .dsh_alarm_hub import bind_event_loop
from .mcp_remote.mount import mcp_lifespan_cm
run_api_startup() # Pass the live FastAPI app so /mcp ASGI is rebound to a fresh session manager
bind_event_loop(asyncio.get_running_loop()) # (TestClient cycles + tests that clear mcp context must not leave a stale app).
try: async with mcp_lifespan_cm(host_app=_app):
yield run_api_startup()
finally: bind_event_loop(asyncio.get_running_loop())
shutdown_runtime(reason="api_lifespan") try:
yield
finally:
shutdown_runtime(reason="api_lifespan")
app = FastAPI( app = FastAPI(
@ -95,8 +99,11 @@ def health() -> dict[str, str]:
return {"status": "ok"} return {"status": "ok"}
from .mcp_remote.mount import mount_mcp # noqa: E402
from .ui_static import mount_ui_if_present # noqa: E402 from .ui_static import mount_ui_if_present # noqa: E402
mount_mcp(app)
_UI_MOUNTED = mount_ui_if_present(app) _UI_MOUNTED = mount_ui_if_present(app)
if not _UI_MOUNTED: if not _UI_MOUNTED:
@ -109,6 +116,7 @@ if not _UI_MOUNTED:
"frontend_url": settings.frontend_url, "frontend_url": settings.frontend_url,
"api_health": "/health", "api_health": "/health",
"api_status": "/v1/integrations/status", "api_status": "/v1/integrations/status",
"mcp": "/mcp",
} }

View file

@ -0,0 +1 @@
"""Built-in remote MCP (Streamable HTTP) for netx."""

View file

@ -0,0 +1,285 @@
"""In-process ASGI client used by MCP tool handlers (no TCP loopback)."""
from __future__ import annotations
import json
import os
import re
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
from urllib.parse import quote
import httpx
from .context import get_app, get_request_context
_PROTOCOL_KEY_ZH_TO_EN: dict[str, str] = {
"其他": "Other",
"时钟": "Clock",
"OTN/光": "OTN/Optical",
"电源": "Power",
}
_SECRET_KEYS = frozenset(
{
"password",
"passwd",
"pwd",
"secret",
"token",
"api_token",
"api_key",
"authorization",
"access_token",
"refresh_token",
"private_key",
"client_secret",
"ssh_password",
"enable_password",
"hop_password",
"hop_password_enc",
"password_enc",
"password_ciphertext",
"hop_password_ciphertext",
"credential_secret",
"secret_key",
"credentials",
}
)
def quote_ne_id(ne_id: str) -> str:
return quote(str(ne_id or "").strip(), safe="")
def api_token() -> str:
from pathlib import Path
ctx = get_request_context()
if ctx and ctx.token and ctx.token != "disabled":
return ctx.token
token = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").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 lang_query_params() -> dict[str, str]:
ctx = get_request_context()
if ctx is not None:
lang = str(ctx.lang or "zh").strip().lower()
else:
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
if lang.startswith("en"):
return {"lang": "en"}
return {}
def localize_payload(data: dict[str, Any]) -> dict[str, Any]:
ctx = get_request_context()
if ctx is not None:
lang = str(ctx.lang or "zh").strip().lower()
else:
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
if not lang.startswith("en"):
return data
proto = data.get("protocol_summary")
if isinstance(proto, list):
for row in proto:
if isinstance(row, dict):
k = str(row.get("key") or "")
if k in _PROTOCOL_KEY_ZH_TO_EN:
row["key"] = _PROTOCOL_KEY_ZH_TO_EN[k]
return data
def redact(value: Any, *, tokens: tuple[str, ...] | None = None) -> Any:
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 _headers() -> dict[str, str]:
h = {"accept": "application/json"}
tok = api_token()
if tok:
h["authorization"] = f"Bearer {tok}"
return h
def _default_timeout() -> float:
try:
return float(os.getenv("NETX_HTTP_TIMEOUT") or 120.0)
except (TypeError, ValueError):
return 120.0
def _client(timeout: float) -> httpx.Client:
app = get_app()
if app is None:
base = (
os.getenv("NETX_API_URL")
or os.getenv("OCLAW_NETX_BASE_URL")
or "http://127.0.0.1:8890"
).rstrip("/")
return httpx.Client(base_url=base, timeout=timeout, trust_env=False)
return httpx.Client(
transport=httpx.ASGITransport(app=app),
base_url="http://mcp.internal",
timeout=timeout,
trust_env=False,
)
def http_json(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
timeout: float | None = None,
) -> dict[str, Any]:
# When not attached to the FastAPI app, require an explicit outbound token
# (remote MCP itself authenticates at /mcp; this covers unit tests / fallback).
if get_app() is None and not api_token():
return {
"ok": False,
"error": "netx_token_missing",
"hint": "设置 NETX_API_TOKEN、NETX_MCP_TOKEN_FILE,或工作目录 data/auth/mcp_token。",
}
merged: dict[str, Any] = dict(lang_query_params())
if params:
merged.update(params)
to = float(timeout) if timeout is not None else _default_timeout()
tok = api_token()
headers = {"accept": "application/json"}
if tok:
headers["authorization"] = f"Bearer {tok}"
try:
with _client(to) as client:
resp = client.request(
method,
path,
params=merged or None,
json=body,
headers=headers,
)
text = resp.text
if not resp.is_success:
return {
"ok": False,
"error": f"netx_http_{resp.status_code}",
"detail": text[:800],
"hint": "检查 API 地址、令牌 scope 和资源 ID。",
}
data = resp.json() if text else {}
if isinstance(data, dict):
data = localize_payload(data)
data = redact(data, tokens=(tok,))
else:
data = {"raw": data}
return {"ok": True, "data": data}
except Exception as exc:
return {
"ok": False,
"error": "netx_request_failed",
"detail": str(exc)[:800],
"hint": "检查 API 连通性及 JSON 响应。",
}
def http_post_json(path: str, body: dict[str, Any], *, timeout: float = 180.0) -> dict[str, Any]:
return http_json("POST", path, body=body, timeout=timeout)
def http_json_many(
requests: list[dict[str, Any]],
*,
max_workers: int = 12,
timeout: float | None = None,
) -> list[dict[str, Any]]:
if not requests:
return []
if len(requests) == 1:
r0 = requests[0]
out = http_json(
str(r0.get("method") or "GET"),
str(r0.get("path") or ""),
params=r0.get("params") if isinstance(r0.get("params"), dict) else None,
body=r0.get("body") if isinstance(r0.get("body"), dict) else None,
timeout=timeout,
)
if "key" in r0:
out = {**out, "key": r0.get("key")}
return [out]
to = float(timeout) if timeout is not None else _default_timeout()
workers = max(1, min(int(max_workers), len(requests)))
results: list[dict[str, Any] | None] = [None] * len(requests)
def _one(idx: int, req: dict[str, Any]) -> tuple[int, dict[str, Any]]:
out = http_json(
str(req.get("method") or "GET"),
str(req.get("path") or ""),
params=req.get("params") if isinstance(req.get("params"), dict) else None,
body=req.get("body") if isinstance(req.get("body"), dict) else None,
timeout=to,
)
if "key" in req:
out = {**out, "key": req.get("key")}
return idx, out
with ThreadPoolExecutor(max_workers=workers) as pool:
futs = [pool.submit(_one, i, r) for i, r in enumerate(requests)]
for fut in as_completed(futs):
idx, out = fut.result()
results[idx] = out
return [r if isinstance(r, dict) else {"ok": False, "error": "parallel_slot_empty"} for r in results]
def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]:
out: dict[str, Any] = {
"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]
}
if is_error:
out["isError"] = True
return out
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
result = redact(result) if isinstance(result, dict) else result
data = result.get("data") if isinstance(result, dict) else None
if (
not isinstance(result, dict)
or not result.get("ok")
or (isinstance(data, dict) and data.get("ok") is False)
):
return mcp_text_result(result, is_error=True)
return mcp_text_result(result)

View file

@ -6,7 +6,7 @@ import math
from typing import Any from typing import Any
from urllib.parse import quote from urllib.parse import quote
from .http_client import http_json, mcp_from_handler_result from netx_api.mcp_remote.asgi_client import http_json, mcp_from_handler_result
class _InvalidArgument(ValueError): class _InvalidArgument(ValueError):

View file

@ -0,0 +1,52 @@
"""Per-request context for built-in remote MCP (token, scopes, groups, lang)."""
from __future__ import annotations
from contextvars import ContextVar
from dataclasses import dataclass, field
from typing import Any
@dataclass
class McpRequestContext:
token: str = ""
scopes: frozenset[str] | None = None
groups: frozenset[str] = field(default_factory=frozenset)
lang: str = "zh"
user_id: str = ""
auth_via: str = ""
_CTX: ContextVar[McpRequestContext | None] = ContextVar("netx_mcp_request_ctx", default=None)
_ASGI_APP: ContextVar[Any] = ContextVar("netx_mcp_asgi_app", default=None)
# Process-wide FastAPI app reference for in-process ASGI calls (set at mount).
_APP_REF: Any = None
def set_app(app: Any) -> None:
global _APP_REF
_APP_REF = app
def get_app() -> Any:
return _ASGI_APP.get() or _APP_REF
def set_request_context(ctx: McpRequestContext | None) -> Any:
return _CTX.set(ctx)
def reset_request_context(token: Any) -> None:
_CTX.reset(token)
def get_request_context() -> McpRequestContext | None:
return _CTX.get()
def require_request_context() -> McpRequestContext:
ctx = _CTX.get()
if ctx is None or not ctx.token:
raise RuntimeError("mcp_request_context_missing")
return ctx

View file

@ -0,0 +1,316 @@
"""Mount Streamable HTTP MCP at /mcp on the FastAPI app."""
from __future__ import annotations
import os
from contextlib import asynccontextmanager
from typing import Any, AsyncIterator
from urllib.parse import urlparse
from mcp.server.transport_security import TransportSecuritySettings
from starlette.applications import Starlette
from starlette.routing import Mount
from starlette.types import ASGIApp, Receive, Scope, Send
from ..auth_deps import resolve_user_from_token
from ..config import settings
from ..db import SessionLocal
from . import server as server_mod
from .context import McpRequestContext, get_app, reset_request_context, set_app, set_request_context
from .registry import parse_groups_param
from .server import build_mcp_server
_MCP_ASGI: ASGIApp | None = None
def _mcp_transport_security() -> TransportSecuritySettings:
"""Host allow-list for MCP DNS-rebinding guard.
Bearer auth is the real gate. Default: disable DNS-rebinding protection so
LAN IPs / reverse-proxy Host headers work for remote MCP. Set
``NETX_MCP_ALLOWED_HOSTS`` (comma-separated, supports ``host:*``) to turn
protection back on with an explicit list.
"""
raw = str(getattr(settings, "mcp_allowed_hosts", "") or "").strip()
if not raw:
return TransportSecuritySettings(enable_dns_rebinding_protection=False)
hosts: list[str] = []
for part in raw.split(","):
h = part.strip()
if h:
hosts.append(h)
# Always keep loopback + ASGI test hosts when an allow-list is configured.
for extra in (
"localhost",
"localhost:*",
"127.0.0.1",
"127.0.0.1:*",
"[::1]",
"[::1]:*",
"test",
"test:*",
"testserver",
"testserver:*",
"mcp.internal",
"mcp.internal:*",
):
if extra not in hosts:
hosts.append(extra)
# Include configured bind / frontend hosts when they look like hostnames.
for candidate in (settings.host, getattr(settings, "frontend_url", "")):
try:
parsed = urlparse(str(candidate) if "://" in str(candidate) else f"http://{candidate}")
hostname = (parsed.hostname or "").strip()
except Exception:
hostname = ""
if hostname and hostname not in hosts:
hosts.append(hostname)
hosts.append(f"{hostname}:*")
return TransportSecuritySettings(
enable_dns_rebinding_protection=True,
allowed_hosts=hosts,
)
def _header_map(scope: Scope) -> dict[str, str]:
out: dict[str, str] = {}
for key, value in scope.get("headers") or []:
out[key.decode("latin-1").lower()] = value.decode("latin-1")
return out
def _bearer_from_scope(scope: Scope) -> str:
auth = str(_header_map(scope).get("authorization") or "").strip()
if auth.lower().startswith("bearer "):
return auth[7:].strip()
return ""
def _groups_from_scope(scope: Scope) -> frozenset[str] | None:
qs = (scope.get("query_string") or b"").decode("latin-1")
from urllib.parse import parse_qs
values = parse_qs(qs).get("groups")
if not values:
return parse_groups_param(None)
return parse_groups_param(values[0])
def _resolve_lang_from_scope(scope: Scope) -> str:
env = str(os.getenv("NETX_LANG") or "").strip().lower()
if env.startswith("en"):
return "en"
if env.startswith("zh"):
return "zh"
al = str(_header_map(scope).get("accept-language") or "").strip().lower()
if al.startswith("en"):
return "en"
return "zh"
async def _drain_body(receive: Receive) -> None:
while True:
message = await receive()
if message["type"] != "http.request" or not message.get("more_body"):
return
async def _send_json(scope: Scope, receive: Receive, send: Send, *, status: int, payload: dict) -> None:
from starlette.responses import JSONResponse
await JSONResponse(payload, status_code=status)(scope, receive, send)
class McpAuthMiddleware:
"""Pure ASGI Bearer gate wrapping only the MCP sub-app."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
method = (scope.get("method") or "GET").upper()
if method == "OPTIONS":
await self.app(scope, receive, send)
return
groups = _groups_from_scope(scope)
lang = _resolve_lang_from_scope(scope)
if not bool(settings.auth_enabled):
ctx = McpRequestContext(
token="disabled",
scopes=None,
groups=groups,
lang=lang,
auth_via="disabled",
)
token = set_request_context(ctx)
try:
await self.app(scope, receive, send)
finally:
reset_request_context(token)
return
raw = _bearer_from_scope(scope)
if not raw:
await _drain_body(receive)
await _send_json(
scope,
receive,
send,
status=401,
payload={"error": "unauthorized", "detail": "missing_bearer_token"},
)
return
db = SessionLocal()
try:
resolved = resolve_user_from_token(db, raw)
finally:
db.close()
if resolved is None:
await _drain_body(receive)
await _send_json(
scope,
receive,
send,
status=401,
payload={"error": "unauthorized", "detail": "invalid_token"},
)
return
user, via, scopes, _api_token_id, _jti = resolved
ctx = McpRequestContext(
token=raw,
scopes=frozenset(str(s) for s in scopes),
groups=groups,
lang=lang,
user_id=str(getattr(user, "id", "") or ""),
auth_via=via,
)
token = set_request_context(ctx)
try:
await self.app(scope, receive, send)
finally:
reset_request_context(token)
class _McpSlashRewrite:
"""Rewrite bare ``/mcp`` → ``/mcp/`` so Starlette Mount does not 307 POST."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] == "http" and scope.get("path") == "/mcp":
scope = dict(scope)
scope["path"] = "/mcp/"
raw = scope.get("raw_path")
if isinstance(raw, (bytes, bytearray)) and raw == b"/mcp":
scope["raw_path"] = b"/mcp/"
await self.app(scope, receive, send)
class _PreferChunkedEncoding:
"""Strip ``Content-Length`` so the ASGI server emits proper chunked framing.
Some WinSock LSPs (observed: path matching ``*/mcp*``) rewrite
``Content-Length`` responses into ``Transfer-Encoding: chunked`` **without**
framing the body. Pre-chunked responses pass through. This is not a response
buffer/rewrite hack — it only drops CL so h11/uvicorn chunk-encode correctly.
"""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
async def send_wrapper(message: dict) -> None:
if message["type"] == "http.response.start":
headers = [
(k, v)
for k, v in (message.get("headers") or [])
if k.lower() != b"content-length"
]
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_wrapper)
def _build_streamable_asgi() -> ASGIApp:
app = server_mod.mcp_server.streamable_http_app(
streamable_http_path="/",
stateless_http=True,
json_response=True,
transport_security=_mcp_transport_security(),
)
if isinstance(app, Starlette):
app.add_middleware(McpAuthMiddleware)
# Outermost on the sub-app: survive refresh_mcp_for_lifespan remounts.
return _PreferChunkedEncoding(app)
def build_mcp_asgi_app(*, force_new: bool = False) -> ASGIApp:
"""Stateless Streamable HTTP ASGI app mounted at ``/mcp``."""
global _MCP_ASGI
if _MCP_ASGI is not None and not force_new:
return _MCP_ASGI
_MCP_ASGI = _build_streamable_asgi()
return _MCP_ASGI
def ensure_mcp_session_manager() -> None:
"""Create streamable app so ``mcp_server.session_manager`` exists for lifespan."""
build_mcp_asgi_app()
def _replace_mcp_mount(host_app: Any, mcp_asgi: ASGIApp) -> None:
for route in getattr(host_app, "routes", []) or []:
if isinstance(route, Mount) and str(getattr(route, "path", "")) == "/mcp":
route.app = mcp_asgi
return
def refresh_mcp_for_lifespan(host_app: Any | None = None) -> Any:
"""Rebuild MCP Server + Streamable app (session manager can only run() once)."""
global _MCP_ASGI
server_mod.mcp_server = build_mcp_server()
_MCP_ASGI = None
mcp_asgi = build_mcp_asgi_app(force_new=True)
app = host_app or get_app()
if app is not None:
_replace_mcp_mount(app, mcp_asgi)
app.state.mcp_server = server_mod.mcp_server
app.state.mcp_asgi = mcp_asgi
set_app(app)
return server_mod.mcp_server
@asynccontextmanager
async def mcp_lifespan_cm(*, host_app: Any | None = None) -> AsyncIterator[None]:
"""Enter a fresh MCP session manager for this lifespan cycle."""
mcp = refresh_mcp_for_lifespan(host_app if host_app is not None else get_app())
async with mcp.session_manager.run():
yield
def mount_mcp(app: Any) -> None:
"""``app.mount('/mcp', ...)`` + slash rewrite; auth is on the sub-app only."""
set_app(app)
mcp_asgi = build_mcp_asgi_app()
app.mount("/mcp", mcp_asgi)
# Outermost: clients may POST http://host:port/mcp without a trailing slash.
app.add_middleware(_McpSlashRewrite)
app.state.mcp_server = server_mod.mcp_server
app.state.mcp_mounted = True
app.state.mcp_asgi = mcp_asgi

View file

@ -1,4 +1,4 @@
"""netx-mcp: stdio MCP → netx HTTP API.""" """Ops MCP tools (in-process handlers for built-in /mcp)."""
from .http_tools import HTTP_MCP_TOOLS, call_http_tool from .http_tools import HTTP_MCP_TOOLS, call_http_tool

View file

@ -10,7 +10,7 @@ import os
from typing import Any, Callable from typing import Any, Callable
from .cli_guard import validate_readonly_cli from .cli_guard import validate_readonly_cli
from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id from netx_api.mcp_remote.asgi_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id
_EXEC_MAX_COMMANDS_CAP = 50 _EXEC_MAX_COMMANDS_CAP = 50
_EXEC_MAX_COMMANDS_DEFAULT = 5 _EXEC_MAX_COMMANDS_DEFAULT = 5

View file

@ -0,0 +1,146 @@
"""Unified MCP tool registry: schemas, scopes, groups, annotations."""
from __future__ import annotations
from typing import Any, Callable
from mcp.types import Tool, ToolAnnotations
from .bizmonitor.http_tools import (
HTTP_MCP_TOOLS as BIZ_TOOLS,
TOOL_REQUIRED_SCOPE as BIZ_SCOPES,
call_http_tool as call_biz_tool,
)
from .ops.http_tools import (
HTTP_MCP_TOOLS as OPS_TOOLS,
TOOL_REQUIRED_SCOPE as OPS_SCOPES,
call_http_tool as call_ops_tool,
)
from .topology.http_tools import (
HTTP_MCP_TOOLS as TOPO_TOOLS,
TOOL_REQUIRED_SCOPE as TOPO_SCOPES,
call_http_tool as call_topo_tool,
)
GROUP_OPS = "ops"
GROUP_TOPOLOGY = "topology"
GROUP_BIZ = "bizMonitor"
VALID_GROUPS = frozenset({GROUP_OPS, GROUP_TOPOLOGY, GROUP_BIZ})
# Write / destructive hints (task contract).
_OPS_WRITE = frozenset({"execManagedNe"})
_TOPO_WRITE = frozenset(
{
"createTopologyFolder",
"addTopologyViewNodes",
"removeTopologyViewNodes",
"sinkTopologyDualUnits",
"copyTopologyViewNodes",
"updateTopologyViewPositions",
"projectTopologyNeighbors",
"classifyTopologyFabricNodes",
"layoutTopologyView",
}
)
_TOPO_DESTRUCTIVE = frozenset({"removeTopologyViewNodes"})
def _enrich(tool: dict[str, Any], *, group: str) -> dict[str, Any]:
name = str(tool.get("name") or "")
out = dict(tool)
if group == GROUP_OPS:
read_only = name not in _OPS_WRITE
destructive = False
elif group == GROUP_TOPOLOGY:
read_only = name not in _TOPO_WRITE
destructive = name in _TOPO_DESTRUCTIVE
else:
read_only = True
destructive = False
annotations = dict(out.get("annotations") or {})
annotations.setdefault("readOnlyHint", read_only)
annotations.setdefault("destructiveHint", destructive)
if read_only:
annotations.setdefault("idempotentHint", True)
out["annotations"] = annotations
meta = dict(out.get("_meta") or out.get("meta") or {})
netx = dict(meta.get("netx") or {})
netx["group"] = group
meta["netx"] = netx
out["_meta"] = meta
out["_group"] = group
return out
ALL_TOOLS: list[dict[str, Any]] = (
[_enrich(t, group=GROUP_OPS) for t in OPS_TOOLS]
+ [_enrich(t, group=GROUP_TOPOLOGY) for t in TOPO_TOOLS]
+ [_enrich(t, group=GROUP_BIZ) for t in BIZ_TOOLS]
)
TOOL_REQUIRED_SCOPE: dict[str, str] = {}
TOOL_REQUIRED_SCOPE.update(OPS_SCOPES)
TOOL_REQUIRED_SCOPE.update(TOPO_SCOPES)
TOOL_REQUIRED_SCOPE.update(BIZ_SCOPES)
TOOL_GROUP: dict[str, str] = {str(t["name"]): str(t["_group"]) for t in ALL_TOOLS}
_HANDLERS: dict[str, Callable[[str, dict[str, Any]], dict[str, Any]]] = {
**{str(t["name"]): call_ops_tool for t in OPS_TOOLS},
**{str(t["name"]): call_topo_tool for t in TOPO_TOOLS},
**{str(t["name"]): call_biz_tool for t in BIZ_TOOLS},
}
def parse_groups_param(raw: str | None) -> frozenset[str]:
if raw is None or not str(raw).strip():
return frozenset()
parts = {p.strip() for p in str(raw).split(",") if p.strip()}
return frozenset(p for p in parts if p in VALID_GROUPS)
def tools_for_request(
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
) -> list[dict[str, Any]]:
"""Filter by scopes and optional groups. scopes=None advertises all (offline)."""
wanted_groups = groups or frozenset()
if scopes is None:
scoped = list(ALL_TOOLS)
else:
granted = {str(s).strip().lower() for s in scopes if str(s).strip()}
if not granted:
return []
scoped = []
for tool in ALL_TOOLS:
name = str(tool.get("name") or "")
need = TOOL_REQUIRED_SCOPE.get(name)
if need is None or need in granted:
scoped.append(tool)
if not wanted_groups:
return scoped
return [t for t in scoped if t.get("_group") in wanted_groups]
def to_mcp_tool(tool: dict[str, Any]) -> Tool:
ann = tool.get("annotations") or {}
return Tool(
name=str(tool["name"]),
description=str(tool.get("description") or ""),
input_schema=tool.get("inputSchema") or {"type": "object", "properties": {}},
annotations=ToolAnnotations(
read_only_hint=bool(ann.get("readOnlyHint", True)),
destructive_hint=bool(ann.get("destructiveHint", False))
if not ann.get("readOnlyHint", True)
else None,
idempotent_hint=ann.get("idempotentHint"),
),
meta=tool.get("_meta") or tool.get("meta"),
)
def call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
handler = _HANDLERS.get(str(name or "").strip())
if handler is None:
raise ValueError(f"unknown tool: {name}")
return handler(name, args)

View file

@ -0,0 +1,116 @@
"""Low-level MCP Server exposing ops/topology/bizMonitor tools over Streamable HTTP."""
from __future__ import annotations
import json
from importlib.metadata import version as pkg_version
from typing import Any
from mcp.server import Server, ServerRequestContext
from mcp.types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
)
from .context import get_request_context
from .registry import TOOL_REQUIRED_SCOPE, call_tool, to_mcp_tool, tools_for_request
def netx_version() -> str:
try:
return pkg_version("netx-ops")
except Exception:
pass
try:
from pathlib import Path
import tomllib
root = Path(__file__).resolve().parents[2]
data = tomllib.loads((root / "pyproject.toml").read_text(encoding="utf-8"))
return str(data.get("project", {}).get("version") or "0.0.0")
except Exception:
return "0.0.0"
def _scopes_and_groups() -> tuple[frozenset[str] | None, frozenset[str]]:
ctx = get_request_context()
if ctx is None:
return None, frozenset()
return ctx.scopes, ctx.groups
async def _on_list_tools(
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> ListToolsResult:
scopes, groups = _scopes_and_groups()
tools = [to_mcp_tool(t) for t in tools_for_request(scopes, groups)]
return ListToolsResult(tools=tools)
async def _on_call_tool(
ctx: ServerRequestContext,
params: CallToolRequestParams,
) -> CallToolResult:
name = str(params.name or "")
scopes, groups = _scopes_and_groups()
advertised = {str(t["name"]) for t in tools_for_request(scopes, groups)}
if name not in advertised:
need = TOOL_REQUIRED_SCOPE.get(name)
msg = (
f"insufficient_scope:{need}. "
"Ask a netx admin to grant this scope on the API token; "
"for sql:query prefer aggregateNmsAlarms/queryNmsAlarmsRaw instead."
if need
else f"unknown or filtered tool: {name}"
)
return CallToolResult(
content=[TextContent(type="text", text=msg)],
is_error=True,
)
args = params.arguments if isinstance(params.arguments, dict) else {}
try:
# Handlers are sync and may nest ASGI calls; run off the event loop thread
# so BaseHTTPMiddleware on the outer MCP request cannot deadlock.
import anyio
result = await anyio.to_thread.run_sync(call_tool, name, dict(args))
except Exception as exc:
result = {
"content": [{"type": "text", "text": str(exc)}],
"isError": True,
}
content_raw = result.get("content") if isinstance(result, dict) else None
texts: list[TextContent] = []
if isinstance(content_raw, list):
for item in content_raw:
if isinstance(item, dict) and item.get("type") == "text":
texts.append(TextContent(type="text", text=str(item.get("text") or "")))
if not texts:
texts = [TextContent(type="text", text=json.dumps(result, ensure_ascii=False))]
is_error = bool(isinstance(result, dict) and result.get("isError"))
return CallToolResult(content=texts, is_error=is_error)
def build_mcp_server() -> Server[Any]:
return Server(
"netx",
version=netx_version(),
instructions=(
"netx remote MCP: ops / topology / bizMonitor tools. "
"Filter with ?groups=ops,topology,bizMonitor. "
"Authorize with Bearer MCP token or user JWT."
),
on_list_tools=_on_list_tools,
on_call_tool=_on_call_tool,
)
# Singleton used by mount; streamable_http_app() must be called once so session_manager exists.
mcp_server = build_mcp_server()

View file

@ -5,18 +5,18 @@ from __future__ import annotations
import sys import sys
from typing import Any, Callable from typing import Any, Callable
from netx_topology_mcp.http_client import http_json, http_json_many, mcp_from_handler_result from netx_api.mcp_remote.asgi_client import http_json, http_json_many, mcp_from_handler_result
from netx_topology_mcp.layout_jobs import ( from netx_api.mcp_remote.topology.layout_jobs import (
cancel_job, cancel_job,
job_public, job_public,
raise_if_cancelled, raise_if_cancelled,
report_progress, report_progress,
start_job, start_job,
) )
from netx_topology_mcp.layout_sight import build_sight from netx_api.mcp_remote.topology.layout_sight import build_sight
from netx_topology_mcp.layout_stats import analyze_layout_stats from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
from netx_topology_mcp.layout_structure import analyze_graph_structure from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph from netx_api.mcp_remote.topology.layout_tool import list_layout_catalog, run_layout_on_graph
def _data(out: dict[str, Any]) -> dict[str, Any]: def _data(out: dict[str, Any]) -> dict[str, Any]:
@ -632,7 +632,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
# compose_views / compose_orbit: load staging canvases, inject blocks. # compose_views / compose_orbit: load staging canvases, inject blocks.
if action in {"compose_views", "compose_orbit"}: if action in {"compose_views", "compose_orbit"}:
from netx_topology_mcp.layout_ops.compose_views import compose_params_from_overrides from netx_api.mcp_remote.topology.layout_ops.compose_views import compose_params_from_overrides
knobs = compose_params_from_overrides(overrides) knobs = compose_params_from_overrides(overrides)
src_ids = list(knobs.get("source_view_ids") or []) src_ids = list(knobs.get("source_view_ids") or [])
@ -774,7 +774,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
"untangle", "untangle",
"polish_crossings", "polish_crossings",
}: }:
from netx_topology_mcp.layout_ops.compose_views import compose_params_from_overrides from netx_api.mcp_remote.topology.layout_ops.compose_views import compose_params_from_overrides
knobs = compose_params_from_overrides(overrides) knobs = compose_params_from_overrides(overrides)
src_ids = list(knobs.get("source_view_ids") or []) src_ids = list(knobs.get("source_view_ids") or [])
@ -1147,11 +1147,11 @@ def _view_membership_ids(view_id: str) -> tuple[dict[str, Any], set[str], list[d
def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]: def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]:
"""Rank hub territories on source for non-dual move_nodes(park) batches.""" """Rank hub territories on source for non-dual move_nodes(park) batches."""
from netx_topology_mcp.layout_ops.suggest_sink_hubs import ( from netx_api.mcp_remote.topology.layout_ops.suggest_sink_hubs import (
pick_batch, pick_batch,
suggest_sink_hub_batches, suggest_sink_hub_batches,
) )
from netx_topology_mcp.layout_structure import analyze_graph_structure from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
source_view_id = str( source_view_id = str(
args.get("source_view_id") or args.get("view_id") or "" args.get("source_view_id") or args.get("view_id") or ""
@ -1314,12 +1314,12 @@ def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]:
def _align_topology_reference(args: dict[str, Any]) -> dict[str, Any]: def _align_topology_reference(args: dict[str, Any]) -> dict[str, Any]:
"""Map reference canvas geometry onto target via shared fabric_node_ids.""" """Map reference canvas geometry onto target via shared fabric_node_ids."""
from netx_topology_mcp.layout_ops.align_reference import ( from netx_api.mcp_remote.topology.layout_ops.align_reference import (
align_reference_params_from_overrides, align_reference_params_from_overrides,
align_to_reference, align_to_reference,
) )
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_stats import analyze_layout_stats from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
view_id = str(args.get("view_id") or "").strip() view_id = str(args.get("view_id") or "").strip()
overrides = args.get("params") if isinstance(args.get("params"), dict) else {} overrides = args.get("params") if isinstance(args.get("params"), dict) else {}
@ -2527,9 +2527,9 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
runs layout_dual_unit per unit before parking (old staging flow). Global runs layout_dual_unit per unit before parking (old staging flow). Global
polish still via layoutTopologyView on the sink. polish still via layoutTopologyView on the sink.
""" """
from netx_topology_mcp.layout_ops.dual_units import find_dual_portal_units from netx_api.mcp_remote.topology.layout_ops.dual_units import find_dual_portal_units
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.sink_dual_units import ( from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import (
batch_node_ids, batch_node_ids,
layout_and_pack_batch, layout_and_pack_batch,
leftover_batch_ids, leftover_batch_ids,
@ -3149,7 +3149,7 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
**{k: base[k] for k in ("source_view_id", "view_id", "move_ids")}, **{k: base[k] for k in ("source_view_id", "view_id", "move_ids")},
} }
from netx_topology_mcp.layout_ops.sink_dual_units import ( from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import (
merge_view_links, merge_view_links,
park_positions, park_positions,
) )

View file

@ -10,14 +10,14 @@ from pathlib import Path
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="netx_topology_mcp.layout_job_worker") parser = argparse.ArgumentParser(prog="netx_api.mcp_remote.topology.layout_job_worker")
parser.add_argument("--job-id", required=True) parser.add_argument("--job-id", required=True)
args = parser.parse_args(argv) args = parser.parse_args(argv)
job_id = str(args.job_id).strip() job_id = str(args.job_id).strip()
if not job_id: if not job_id:
return 2 return 2
from netx_topology_mcp.layout_jobs import ( from netx_api.mcp_remote.topology.layout_jobs import (
_JobCancelled, _JobCancelled,
_args_path, _args_path,
_read_job_disk, _read_job_disk,
@ -68,7 +68,7 @@ def main(argv: list[str] | None = None) -> int:
raise _JobCancelled() raise _JobCancelled()
# Late import: pulls http_tools + layout stack. # Late import: pulls http_tools + layout stack.
from netx_topology_mcp.http_tools import _layout_topology_view from netx_api.mcp_remote.topology.http_tools import _layout_topology_view
result = _layout_topology_view(tool_args) result = _layout_topology_view(tool_args)
finish_job(job_id, result=result, cancelled=is_cancelled()) finish_job(job_id, result=result, cancelled=is_cancelled())

View file

@ -42,7 +42,7 @@ def _job_dir() -> Path:
if env: if env:
p = Path(env) p = Path(env)
else: else:
# packages/netx-topology-mcp/src/netx_topology_mcp → repo data/ # netx_api/mcp_remote/topology → repo data/
here = Path(__file__).resolve() here = Path(__file__).resolve()
repo = here.parents[4] # .../netx repo = here.parents[4] # .../netx
p = repo / "data" / "runtime" / "layout_jobs" p = repo / "data" / "runtime" / "layout_jobs"
@ -407,7 +407,7 @@ def start_job(
try: try:
proc = subprocess.Popen( # noqa: S603 — controlled argv proc = subprocess.Popen( # noqa: S603 — controlled argv
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id], [sys.executable, "-m", "netx_api.mcp_remote.topology.layout_job_worker", "--job-id", job_id],
env=env, env=env,
stdin=subprocess.DEVNULL, stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stdout=subprocess.DEVNULL,

View file

@ -12,8 +12,8 @@ Scoped / block ops:
Soft partition: layout_ops.partition (hub BFS + optional igraph). Soft partition: layout_ops.partition (hub BFS + optional igraph).
""" """
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.partition import ( from netx_api.mcp_remote.topology.layout_ops.partition import (
igraph_available, igraph_available,
pack_block_centers, pack_block_centers,
pack_soft_blocks, pack_soft_blocks,
@ -21,7 +21,7 @@ from netx_topology_mcp.layout_ops.partition import (
partition_soft_blocks, partition_soft_blocks,
resolve_block_mode, resolve_block_mode,
) )
from netx_topology_mcp.layout_ops.recipe import ( from netx_api.mcp_remote.topology.layout_ops.recipe import (
RECIPES, RECIPES,
REGISTRY, REGISTRY,
agg_rings_v1_passes, agg_rings_v1_passes,
@ -31,19 +31,19 @@ from netx_topology_mcp.layout_ops.recipe import (
smd_corridor_unstick_v1_passes, smd_corridor_unstick_v1_passes,
smd_corridor_v1_passes, smd_corridor_v1_passes,
) )
from netx_topology_mcp.layout_ops.rings import build_ring_skeleton from netx_api.mcp_remote.topology.layout_ops.rings import build_ring_skeleton
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
from netx_topology_mcp.layout_ops.scope import list_blocks, select_scope from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks, select_scope
from netx_topology_mcp.layout_ops.score import score_state from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.compose_views import ( from netx_api.mcp_remote.topology.layout_ops.compose_views import (
compose_into_state, compose_into_state,
strip_pack_blocks, strip_pack_blocks,
) )
from netx_topology_mcp.layout_ops.dual_units import ( from netx_api.mcp_remote.topology.layout_ops.dual_units import (
find_dual_portal_units, find_dual_portal_units,
layout_dual_unit, layout_dual_unit,
) )
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
__all__ = [ __all__ = [
"LayoutParams", "LayoutParams",

View file

@ -14,10 +14,10 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.graph_util import bbox from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _dist(a: tuple[float, float], b: tuple[float, float]) -> float: def _dist(a: tuple[float, float], b: tuple[float, float]) -> float:

View file

@ -26,18 +26,18 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings, count_edge_crossings,
top_crossing_nodes, top_crossing_nodes,
) )
from netx_topology_mcp.layout_ops.orbit_sweep import ( from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
_MAX_JUMP_CAP, _MAX_JUMP_CAP,
_far_field_guides, _far_field_guides,
_polar_grid, _polar_grid,
) )
from netx_topology_mcp.layout_ops.ring_faces import extract_ring_faces from netx_api.mcp_remote.topology.layout_ops.ring_faces import extract_ring_faces
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_topology_quality import extract_chain_paths from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
# Compact-first: tight packs before full-length (fits crowded pockets). # Compact-first: tight packs before full-length (fits crowded pockets).
# Floor must clear icon+caption; smaller steps cause member self-overlap → apply reject. # Floor must clear icon+caption; smaller steps cause member self-overlap → apply reject.
@ -266,11 +266,11 @@ def _bundle_ok(
nn_floor: float, nn_floor: float,
) -> bool: ) -> bool:
"""Gate: no footprint invade (members∪outsiders) and nn_floor vs outsiders.""" """Gate: no footprint invade (members∪outsiders) and nn_floor vs outsiders."""
from netx_topology_mcp.layout_ops.orbit_sweep import ( from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
_box, _box,
_centers_may_overlap, _centers_may_overlap,
) )
from netx_topology_mcp.layout_metrics import node_footprint from netx_api.mcp_remote.topology.layout_metrics import node_footprint
# Apply refuses any footprint overlap — members must clear each other too. # Apply refuses any footprint overlap — members must clear each other too.
mem_list = [n for n in members if n in trial] mem_list = [n for n in members if n in trial]
@ -639,7 +639,7 @@ def apply_bundle_pick(
) )
# Minimize-expand must not leave footprint overlaps (apply gate). # Minimize-expand must not leave footprint overlaps (apply gate).
from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
if _has_any_footprint_overlap(trial, state.names): if _has_any_footprint_overlap(trial, state.names):
return OpResult( return OpResult(
@ -697,7 +697,7 @@ def rank_bundles_by_hotspots(
"""Prefer bundles whose tip / members participate in crossings.""" """Prefer bundles whose tip / members participate in crossings."""
if not bundles: if not bundles:
return [] return []
from netx_topology_mcp.layout_metrics import crossing_participation from netx_api.mcp_remote.topology.layout_metrics import crossing_participation
_n, node_hit = crossing_participation(pos, links)[:2] _n, node_hit = crossing_participation(pos, links)[:2]
hot = { hot = {

View file

@ -6,15 +6,15 @@ nodes sit on deg≤2 corridors.
from __future__ import annotations from __future__ import annotations
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.channels import extract_channels from netx_api.mcp_remote.topology.layout_ops.channels import extract_channels
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.pin_beam import pin_beam_rigid from netx_api.mcp_remote.topology.layout_ops.pin_beam import pin_beam_rigid
from netx_topology_mcp.layout_ops.ring_faces import eject_intruders, extract_ring_faces from netx_api.mcp_remote.topology.layout_ops.ring_faces import eject_intruders, extract_ring_faces
from netx_topology_mcp.layout_ops.skeleton import build_skeleton from netx_api.mcp_remote.topology.layout_ops.skeleton import build_skeleton
from netx_topology_mcp.layout_ops.soft_petals import soft_petals_greedy from netx_api.mcp_remote.topology.layout_ops.soft_petals import soft_petals_greedy
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.transforms import normalize_origin from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
def build_channel_metro_skeleton( def build_channel_metro_skeleton(

View file

@ -10,8 +10,8 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from netx_topology_mcp.layout_ops.state import LayoutState from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
from netx_topology_mcp.layout_topology_quality import extract_chain_paths from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
def order_stubs_crossing_aware( def order_stubs_crossing_aware(
@ -244,9 +244,9 @@ def straighten_channels_greedy(
Prefer fewer global crossings; when crossings stay equal (incl. already 0), Prefer fewer global crossings; when crossings stay equal (incl. already 0),
accept H/V / L modes that raise path axis credit (metro look). accept H/V / L modes that raise path axis credit (metro look).
""" """
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.state import LayoutParams, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, OpResult
params = params or LayoutParams() params = params or LayoutParams()
step_px = float(step if step is not None else max(getattr(params, "pitch", 180.0), 160.0)) step_px = float(step if step is not None else max(getattr(params, "pitch", 180.0), 160.0))
@ -279,7 +279,7 @@ def straighten_channels_greedy(
} }
) )
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress
for _pass in range(max(1, int(max_passes))): for _pass in range(max(1, int(max_passes))):
raise_if_cancelled() raise_if_cancelled()

View file

@ -10,7 +10,7 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
EDGE_CLEARANCE_THR, EDGE_CLEARANCE_THR,
REC_CENTER_DX, REC_CENTER_DX,
REC_CENTER_DY, REC_CENTER_DY,
@ -19,7 +19,7 @@ from netx_topology_mcp.layout_metrics import (
node_footprint, node_footprint,
point_segment_dist, point_segment_dist,
) )
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_AXIS_TOL = 8.0 _AXIS_TOL = 8.0

View file

@ -12,13 +12,13 @@ from __future__ import annotations
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings, count_edge_crossings,
compute_edge_clearance, compute_edge_clearance,
) )
from netx_topology_mcp.layout_ops.graph_util import bbox from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _anchor( def _anchor(

View file

@ -17,11 +17,11 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings, count_edge_crossings,
segments_properly_intersect, segments_properly_intersect,
) )
from netx_topology_mcp.layout_ops.compose_views import ( from netx_api.mcp_remote.topology.layout_ops.compose_views import (
ComposeBlock, ComposeBlock,
_membership_counts, _membership_counts,
_portal_grow_order, _portal_grow_order,
@ -30,7 +30,7 @@ from netx_topology_mcp.layout_ops.compose_views import (
_rigid_align_to_world, _rigid_align_to_world,
compose_params_from_overrides, compose_params_from_overrides,
) )
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_DEFAULT_ANGLE_STEP = 30 _DEFAULT_ANGLE_STEP = 30
_DEFAULT_RADII = (0.75, 1.0, 1.25, 1.55, 1.95) _DEFAULT_RADII = (0.75, 1.0, 1.25, 1.55, 1.95)
@ -581,7 +581,7 @@ def compose_orbit_into_state(
out.meta = dict(out.meta or {}) out.meta = dict(out.meta or {})
out.meta["compose_views"] = meta out.meta["compose_views"] = meta
try: try:
from netx_topology_mcp.layout_ops.mass_field import ( from netx_api.mcp_remote.topology.layout_ops.mass_field import (
attach_mass_to_compose_meta, attach_mass_to_compose_meta,
build_mass_field, build_mass_field,
) )

View file

@ -17,8 +17,8 @@ from collections import defaultdict
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
DEFAULT_SLOT_PAD = 600.0 DEFAULT_SLOT_PAD = 600.0
@ -733,7 +733,7 @@ def compose_into_state(
out.meta = dict(out.meta or {}) out.meta = dict(out.meta or {})
out.meta["compose_views"] = meta out.meta["compose_views"] = meta
try: try:
from netx_topology_mcp.layout_ops.mass_field import ( from netx_api.mcp_remote.topology.layout_ops.mass_field import (
attach_mass_to_compose_meta, attach_mass_to_compose_meta,
build_mass_field, build_mass_field,
) )

View file

@ -10,23 +10,23 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings, count_edge_crossings,
crossings_involving_node, crossings_involving_node,
) )
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_ops.orbit_sweep import ( from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
_diversify_top, _diversify_top,
_eval_candidate, _eval_candidate,
_incident_stretch, _incident_stretch,
) )
from netx_topology_mcp.layout_ops.rigid_units import ( from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
frozen_ids_for_protect, frozen_ids_for_protect,
groups_from_compose_meta, groups_from_compose_meta,
shrink_long_corridors, shrink_long_corridors,
) )
from netx_topology_mcp.layout_ops.score import score_state from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_MAX_PULL = 900.0 _MAX_PULL = 900.0
_MAX_PULL_CAP = 2200.0 _MAX_PULL_CAP = 2200.0

View file

@ -17,22 +17,22 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.compose_orbit import orbit_pack_blocks from netx_api.mcp_remote.topology.layout_ops.compose_orbit import orbit_pack_blocks
from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, _rigid_align_to_world from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock, _rigid_align_to_world
from netx_topology_mcp.layout_ops.dual_units import ( from netx_api.mcp_remote.topology.layout_ops.dual_units import (
DualUnit, DualUnit,
beautify_dual_unit_positions, beautify_dual_unit_positions,
classify_dual_unit, classify_dual_unit,
find_dual_portal_units, find_dual_portal_units,
) )
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.mass_field import ( from netx_api.mcp_remote.topology.layout_ops.mass_field import (
attach_mass_to_compose_meta, attach_mass_to_compose_meta,
build_mass_field, build_mass_field,
) )
from netx_topology_mcp.layout_ops.mass_merge import mass_merge_round from netx_api.mcp_remote.topology.layout_ops.mass_merge import mass_merge_round
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _unit_links( def _unit_links(

View file

@ -17,11 +17,11 @@ from collections import deque
from dataclasses import dataclass, field from dataclasses import dataclass, field
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.min_rings import cover_hub_paths from netx_api.mcp_remote.topology.layout_ops.min_rings import cover_hub_paths
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.sugiyama import _find_two_portal_ring_groups from netx_api.mcp_remote.topology.layout_ops.sugiyama import _find_two_portal_ring_groups
@dataclass @dataclass
@ -756,7 +756,7 @@ def _uncross_unit(
preserve_side: bool = True, preserve_side: bool = True,
) -> dict[str, tuple[float, float]]: ) -> dict[str, tuple[float, float]]:
"""Greedy: nudge free endpoints to kill crossings without flipping eye sides.""" """Greedy: nudge free endpoints to kill crossings without flipping eye sides."""
from netx_topology_mcp.layout_metrics import segments_properly_intersect from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
out = dict(pos) out = dict(pos)
deg: dict[str, int] = {} deg: dict[str, int] = {}

View file

@ -9,7 +9,7 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _deg(st: LayoutState) -> dict[str, int]: def _deg(st: LayoutState) -> dict[str, int]:

View file

@ -21,13 +21,13 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_ops.rigid_units import ( from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
frozen_ids_for_protect, frozen_ids_for_protect,
groups_from_compose_meta, groups_from_compose_meta,
) )
from netx_topology_mcp.layout_ops.score import score_state from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
_COORD_ABS_MAX = 1.0e6 _COORD_ABS_MAX = 1.0e6
_DEFAULT_ITERS = 12 _DEFAULT_ITERS = 12
@ -299,7 +299,7 @@ def force_densify_round(
damping: float = 0.85, damping: float = 0.85,
) -> OpResult: ) -> OpResult:
"""Run damped attract/repulse densify with semi-rigid dual-unit bodies.""" """Run damped attract/repulse densify with semi-rigid dual-unit bodies."""
from netx_topology_mcp.layout_jobs import ( from netx_api.mcp_remote.topology.layout_jobs import (
raise_if_cancelled, raise_if_cancelled,
report_progress, report_progress,
touch_heartbeat, touch_heartbeat,
@ -384,7 +384,7 @@ def force_densify_round(
best_meta_util = util_proxy0 best_meta_util = util_proxy0
best_iter = 0 best_iter = 0
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
for it in range(n_iters): for it in range(n_iters):
raise_if_cancelled() raise_if_cancelled()

View file

@ -4,7 +4,7 @@ from __future__ import annotations
from collections import deque from collections import deque
from netx_topology_mcp.layout_ops.level_util import infer_layer from netx_api.mcp_remote.topology.layout_ops.level_util import infer_layer
__all__ = [ __all__ = [
"infer_layer", "infer_layer",
@ -147,9 +147,9 @@ def spine_backbone(
def build_state_from_nodes_edges( def build_state_from_nodes_edges(
nodes: list[dict], edges: list[dict] nodes: list[dict], edges: list[dict]
) -> "LayoutState": # noqa: F821 ) -> "LayoutState": # noqa: F821
from netx_topology_mcp.layout_ops.state import LayoutState from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
from netx_topology_mcp.layout_metrics import collapse_links from netx_api.mcp_remote.topology.layout_metrics import collapse_links
from netx_topology_mcp.layout_ops.level_util import _parse_level from netx_api.mcp_remote.topology.layout_ops.level_util import _parse_level
names: dict[str, str] = {} names: dict[str, str] = {}
layers: dict[str, str] = {} layers: dict[str, str] = {}

View file

@ -16,10 +16,10 @@ import math
from collections import deque from collections import deque
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings, segments_properly_intersect from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings, segments_properly_intersect
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.partition import pick_hub_seeds from netx_api.mcp_remote.topology.layout_ops.partition import pick_hub_seeds
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _ang(pos: dict[str, tuple[float, float]], hub: str, nid: str) -> float: def _ang(pos: dict[str, tuple[float, float]], hub: str, nid: str) -> float:

View file

@ -7,10 +7,10 @@ from collections import defaultdict
from dataclasses import replace from dataclasses import replace
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint from netx_api.mcp_remote.topology.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint
from netx_topology_mcp.layout_ops.scope import select_scope from netx_api.mcp_remote.topology.layout_ops.scope import select_scope
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.transforms import ( from netx_api.mcp_remote.topology.layout_ops.transforms import (
explode_clusters, explode_clusters,
pack_utilization, pack_utilization,
resolve_overlaps, resolve_overlaps,
@ -119,7 +119,7 @@ def sprawled_component_scopes(
max_size: int = 80, max_size: int = 80,
) -> list[set[str]]: ) -> list[set[str]]:
"""Graph components with low util — skip huge ones (those need layout, not local pack).""" """Graph components with low util — skip huge ones (those need layout, not local pack)."""
from netx_topology_mcp.layout_ops.graph_util import bbox, connected_components from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox, connected_components
comps = connected_components(set(state.positions), state.adj) comps = connected_components(set(state.positions), state.adj)
out: list[set[str]] = [] out: list[set[str]] = []
@ -197,7 +197,7 @@ def fix_overlaps_local(
state: LayoutState, params: LayoutParams | None = None state: LayoutState, params: LayoutParams | None = None
) -> OpResult: ) -> OpResult:
"""Pull apart only overlapping nodes (+1-hop). No global scale/pack.""" """Pull apart only overlapping nodes (+1-hop). No global scale/pack."""
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
params = params or LayoutParams() params = params or LayoutParams()
st = state.copy() st = state.copy()
@ -276,7 +276,7 @@ def fix_overlaps_local(
def _block_util(state: LayoutState, block: set[str]) -> float: def _block_util(state: LayoutState, block: set[str]) -> float:
from netx_topology_mcp.layout_ops.graph_util import bbox from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
if len(block) < 2: if len(block) < 2:
return 1.0 return 1.0
@ -313,7 +313,7 @@ def relax_hotspots(
st, hops=1, dense_min=4, close_thr=100.0, target_util=params.target_util st, hops=1, dense_min=4, close_thr=100.0, target_util=params.target_util
) )
if not scopes: if not scopes:
from netx_topology_mcp.layout_ops.scope import list_blocks from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks
scopes = [b for b in list_blocks(st, mode="component") if 4 <= len(b) <= 64] scopes = [b for b in list_blocks(st, mode="component") if 4 <= len(b) <= 64]

View file

@ -6,7 +6,7 @@ import math
import re import re
from typing import Any from typing import Any
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_ROLE_TO_LAYER = { _ROLE_TO_LAYER = {

View file

@ -9,8 +9,8 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_ops.dual_units import DualUnit, find_dual_portal_units from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit, find_dual_portal_units
from netx_topology_mcp.layout_ops.state import LayoutState from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
# role -> (attract, repulse, mass) # role -> (attract, repulse, mass)
_ROLE_NODE: dict[str, tuple[float, float, float]] = { _ROLE_NODE: dict[str, tuple[float, float, float]] = {

View file

@ -15,13 +15,13 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.force_densify import ( from netx_api.mcp_remote.topology.layout_ops.force_densify import (
_MAX_EDGE_PULL, _MAX_EDGE_PULL,
_bbox_area, _bbox_area,
_spatial_bins, _spatial_bins,
) )
from netx_topology_mcp.layout_ops.mass_field import ( from netx_api.mcp_remote.topology.layout_ops.mass_field import (
build_mass_field, build_mass_field,
capture_pass, capture_pass,
core_centroid, core_centroid,
@ -30,9 +30,9 @@ from netx_topology_mcp.layout_ops.mass_field import (
group_effective_mass, group_effective_mass,
groups_from_mass_or_rigid, groups_from_mass_or_rigid,
) )
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect
from netx_topology_mcp.layout_ops.score import score_state from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
_COORD_ABS_MAX = 1.0e6 _COORD_ABS_MAX = 1.0e6
_STEP_CAP = { _STEP_CAP = {
@ -599,7 +599,7 @@ def mass_merge_round(
Islands = dual-unit groups; soft global gravity keeps the **cloud of Islands = dual-unit groups; soft global gravity keeps the **cloud of
islands** roughly round (repulsion + edge traction), not a strip. islands** roughly round (repulsion + edge traction), not a strip.
""" """
from netx_topology_mcp.layout_jobs import ( from netx_api.mcp_remote.topology.layout_jobs import (
raise_if_cancelled, raise_if_cancelled,
report_progress, report_progress,
touch_heartbeat, touch_heartbeat,

View file

@ -12,8 +12,8 @@ import math
from collections import defaultdict, deque from collections import defaultdict, deque
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _all_simple_paths( def _all_simple_paths(

View file

@ -11,7 +11,7 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings, count_edge_crossings,
crossing_participation, crossing_participation,
crossing_participation_full, crossing_participation_full,
@ -20,7 +20,7 @@ from netx_topology_mcp.layout_metrics import (
top_crossing_edges, top_crossing_edges,
top_crossing_nodes, top_crossing_nodes,
) )
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
_MAX_JUMP = 900.0 _MAX_JUMP = 900.0
# Long fabric bridges on giant metros often exceed 5k; cap must leave room for # Long fabric bridges on giant metros often exceed 5k; cap must leave room for
@ -201,7 +201,7 @@ def _resolve_frozen(
return set() return set()
frozen: set[str] = set(frozen_ids or ()) frozen: set[str] = set(frozen_ids or ())
if not frozen: if not frozen:
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect
frozen = frozen_ids_for_protect(st, protect_rigid) frozen = frozen_ids_for_protect(st, protect_rigid)
return frozen return frozen
@ -548,7 +548,7 @@ def _local_clearance_hits(
endpoint_t: float = 0.05, endpoint_t: float = 0.05,
) -> int: ) -> int:
"""Fast hit count affected by moving ``nid`` (O(E + N·deg), not O(N·E)).""" """Fast hit count affected by moving ``nid`` (O(E + N·deg), not O(N·E))."""
from netx_topology_mcp.layout_metrics import point_segment_dist from netx_api.mcp_remote.topology.layout_metrics import point_segment_dist
if nid not in pos: if nid not in pos:
return 0 return 0
@ -1188,7 +1188,7 @@ def _until_limit_queue(
base_hits = max(hits, prev[0] if prev else 0) base_hits = max(hits, prev[0] if prev else 0)
scored[nid] = (base_hits, deg, 1, prev[3] if prev else 0) scored[nid] = (base_hits, deg, 1, prev[3] if prev else 0)
if include_clearance: if include_clearance:
from netx_topology_mcp.layout_metrics import compute_edge_clearance from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance
ec = compute_edge_clearance(pos, links, names=names, top_n=24) ec = compute_edge_clearance(pos, links, names=names, top_n=24)
if not ec.get("edge_clearance_skipped"): if not ec.get("edge_clearance_skipped"):
@ -1302,7 +1302,7 @@ def orbit_sweep_until_limit(
while len(moves) < max_moves_i and stall < stall_lim: while len(moves) < max_moves_i and stall < stall_lim:
rounds += 1 rounds += 1
try: try:
from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat
touch_heartbeat() touch_heartbeat()
raise_if_cancelled() raise_if_cancelled()
@ -1389,12 +1389,12 @@ def orbit_sweep_until_limit(
break break
if not applied and use_bundle and not bundle_exhausted: if not applied and use_bundle and not bundle_exhausted:
from netx_topology_mcp.layout_ops.bundle_orbit import ( from netx_api.mcp_remote.topology.layout_ops.bundle_orbit import (
bundle_orbit_until_progress, bundle_orbit_until_progress,
) )
try: try:
from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat
touch_heartbeat() touch_heartbeat()
raise_if_cancelled() raise_if_cancelled()

View file

@ -12,16 +12,16 @@ from __future__ import annotations
from collections import deque from collections import deque
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
REC_CENTER_DX, REC_CENTER_DX,
REC_CENTER_DY, REC_CENTER_DY,
count_edge_crossings, count_edge_crossings,
node_footprint, node_footprint,
) )
from netx_topology_mcp.layout_ops.channels import extract_channels from netx_api.mcp_remote.topology.layout_ops.channels import extract_channels
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.transforms import normalize_origin from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
_TOL_PX = 8.0 _TOL_PX = 8.0
@ -39,7 +39,7 @@ def _segment_clear(
skip: set[str] | None = None, skip: set[str] | None = None,
) -> bool: ) -> bool:
"""True if no other node sits on the open segment a—b (edge occlusion).""" """True if no other node sits on the open segment a—b (edge occlusion)."""
from netx_topology_mcp.layout_metrics import point_segment_dist from netx_api.mcp_remote.topology.layout_metrics import point_segment_dist
if a not in pos or b not in pos: if a not in pos or b not in pos:
return True return True
@ -930,7 +930,7 @@ def _place_component(
if _segment_clear(pos, a, b, thr=40.0): if _segment_clear(pos, a, b, thr=40.0):
continue continue
# Collect obstacles on open segment # Collect obstacles on open segment
from netx_topology_mcp.layout_metrics import point_segment_dist from netx_api.mcp_remote.topology.layout_metrics import point_segment_dist
pa, pb = pos[a], pos[b] pa, pb = pos[a], pos[b]
obstacles = [] obstacles = []
@ -1086,7 +1086,7 @@ def build_ortho_metro_skeleton(
st = separate_overlaps_ortho(st, params).state st = separate_overlaps_ortho(st, params).state
# Nodes must not sit on non-incident H/V trunks (edge occlusion). # Nodes must not sit on non-incident H/V trunks (edge occlusion).
from netx_topology_mcp.layout_ops.clear_edge_hits import clear_edge_hits from netx_api.mcp_remote.topology.layout_ops.clear_edge_hits import clear_edge_hits
side = max(float(params.side), REC_CENTER_DY) side = max(float(params.side), REC_CENTER_DY)
clr = clear_edge_hits( clr = clear_edge_hits(
@ -1114,7 +1114,7 @@ def build_ortho_metro_skeleton(
else: else:
diag_n += 1 diag_n += 1
cross = count_edge_crossings(st.positions, st.links) cross = count_edge_crossings(st.positions, st.links)
from netx_topology_mcp.layout_metrics import compute_edge_clearance from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance
clr_m = compute_edge_clearance(st.positions, st.links, names=st.names, thr=40.0) clr_m = compute_edge_clearance(st.positions, st.links, names=st.names, thr=40.0)
st.meta = dict(st.meta or {}) st.meta = dict(st.meta or {})

View file

@ -17,8 +17,8 @@ from collections import defaultdict, deque
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any, Iterable from typing import Any, Iterable
from netx_topology_mcp.layout_ops.graph_util import connected_components from netx_api.mcp_remote.topology.layout_ops.graph_util import connected_components
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
# Preferred soft-block step when packing centers (px). # Preferred soft-block step when packing centers (px).
_BLOCK_GAP = 420.0 _BLOCK_GAP = 420.0
@ -437,7 +437,7 @@ def partition_report(state: LayoutState, *, mode: str = "soft") -> dict[str, Any
"tip": ( "tip": (
"Soft blocks for init only: lay each hub territory / leftover with our " "Soft blocks for init only: lay each hub territory / leftover with our "
"petal/spine algorithms; use center_hint to pack blocks. " "petal/spine algorithms; use center_hint to pack blocks. "
"igraph Leiden/FR are optional — install netx-topology-mcp[layout]." "igraph Leiden/FR are optional extras when available."
), ),
} }

View file

@ -5,12 +5,12 @@ from __future__ import annotations
from collections import deque from collections import deque
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.partition import partition_soft_blocks from netx_api.mcp_remote.topology.layout_ops.partition import partition_soft_blocks
from netx_topology_mcp.layout_ops.score import score_state from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.transforms import normalize_origin from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
def _hops_to_cores( def _hops_to_cores(

View file

@ -14,7 +14,7 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings, count_edge_crossings,
crossings_after_node_move, crossings_after_node_move,
crossings_involving_node, crossings_involving_node,
@ -23,8 +23,8 @@ from netx_topology_mcp.layout_metrics import (
segments_properly_intersect, segments_properly_intersect,
top_crossing_edges, top_crossing_edges,
) )
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.untangle import untangle_crossings from netx_api.mcp_remote.topology.layout_ops.untangle import untangle_crossings
def park_phantom_nodes(state: LayoutState) -> set[str]: def park_phantom_nodes(state: LayoutState) -> set[str]:
@ -76,7 +76,7 @@ def _rotate_about(
def frozen_portals_from_state( def frozen_portals_from_state(
state: LayoutState, portal_ids: list[str] | None = None state: LayoutState, portal_ids: list[str] | None = None
) -> set[str]: ) -> set[str]:
from netx_topology_mcp.layout_ops.rigid_units import ( from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
_hub_portals, _hub_portals,
frozen_ids_for_protect, frozen_ids_for_protect,
) )
@ -174,7 +174,7 @@ def _large_graph_budget(n_links: int) -> dict[str, Any]:
def _has_petal_dual_eye(state: LayoutState) -> bool: def _has_petal_dual_eye(state: LayoutState) -> bool:
"""True when a multi-corridor dual-unit covers a large share of the canvas.""" """True when a multi-corridor dual-unit covers a large share of the canvas."""
try: try:
from netx_topology_mcp.layout_ops.dual_units import ( from netx_api.mcp_remote.topology.layout_ops.dual_units import (
classify_dual_unit, classify_dual_unit,
find_dual_portal_units, find_dual_portal_units,
) )
@ -202,7 +202,7 @@ def press_hot_edges(
max_sweeps: int | None = None, max_sweeps: int | None = None,
) -> OpResult: ) -> OpResult:
"""Rotate non-portal ends of top crossing edges about the other endpoint.""" """Rotate non-portal ends of top crossing edges about the other endpoint."""
from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat
del params del params
st = state.copy() st = state.copy()
@ -620,7 +620,7 @@ def polish_crossings(
Petal dual-unit eyes: straighten stays off unless ``straighten=true`` — Petal dual-unit eyes: straighten stays off unless ``straighten=true`` —
channel flattening destroys parallel-lane eye geometry. channel flattening destroys parallel-lane eye geometry.
""" """
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress
params = params or LayoutParams() params = params or LayoutParams()
st = state.copy() st = state.copy()
@ -631,7 +631,7 @@ def polish_crossings(
eye_portals: list[str] = [] eye_portals: list[str] = []
if preserve: if preserve:
try: try:
from netx_topology_mcp.layout_ops.dual_units import ( from netx_api.mcp_remote.topology.layout_ops.dual_units import (
classify_dual_unit, classify_dual_unit,
find_dual_portal_units, find_dual_portal_units,
) )
@ -685,7 +685,7 @@ def polish_crossings(
moved: set[str] = set() moved: set[str] = set()
if do_straighten: if do_straighten:
from netx_topology_mcp.layout_ops.channels import straighten_channels_greedy from netx_api.mcp_remote.topology.layout_ops.channels import straighten_channels_greedy
raise_if_cancelled() raise_if_cancelled()
report_progress("polish_straighten", pct=48.0, message=f"x0={start}") report_progress("polish_straighten", pct=48.0, message=f"x0={start}")

View file

@ -11,11 +11,11 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings, compute_edge_clearance from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings, compute_edge_clearance
from netx_topology_mcp.layout_ops.graph_util import bbox from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_topology_quality import extract_chain_paths from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
def _anchor(pos: dict[str, tuple[float, float]], frozen: set[str]) -> tuple[float, float]: def _anchor(pos: dict[str, tuple[float, float]], frozen: set[str]) -> tuple[float, float]:

View file

@ -5,15 +5,15 @@ from __future__ import annotations
from dataclasses import asdict from dataclasses import asdict
from typing import Any, Callable from typing import Any, Callable
from netx_topology_mcp.layout_ops.score import score_op, score_state from netx_api.mcp_remote.topology.layout_ops.score import score_op, score_state
from netx_topology_mcp.layout_ops.scope import list_blocks, map_blocks, select_scope from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks, map_blocks, select_scope
from netx_topology_mcp.layout_ops.sides import place_side_branches from netx_api.mcp_remote.topology.layout_ops.sides import place_side_branches
from netx_topology_mcp.layout_ops.skeleton import build_skeleton from netx_api.mcp_remote.topology.layout_ops.skeleton import build_skeleton
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
from netx_topology_mcp.layout_ops.rings import build_ring_skeleton from netx_api.mcp_remote.topology.layout_ops.rings import build_ring_skeleton
from netx_topology_mcp.layout_ops.partition import pack_soft_blocks from netx_api.mcp_remote.topology.layout_ops.partition import pack_soft_blocks
from netx_topology_mcp.layout_ops.transforms import ( from netx_api.mcp_remote.topology.layout_ops.transforms import (
enforce_min_gap, enforce_min_gap,
explode_clusters, explode_clusters,
normalize_origin, normalize_origin,
@ -234,7 +234,7 @@ def _expand_foreach(
min_size = int(kwargs.get("min_size") or 2) min_size = int(kwargs.get("min_size") or 2)
blocks = list_blocks(st, mode=mode) blocks = list_blocks(st, mode=mode)
# Record resolved mode (auto → hub_territory|component) for agents/trace. # Record resolved mode (auto → hub_territory|component) for agents/trace.
from netx_topology_mcp.layout_ops.partition import resolve_block_mode from netx_api.mcp_remote.topology.layout_ops.partition import resolve_block_mode
resolved_mode = resolve_block_mode(st, mode) if mode == "auto" else mode resolved_mode = resolve_block_mode(st, mode) if mode == "auto" else mode
st = st.copy() st = st.copy()

View file

@ -14,12 +14,12 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.compose_orbit import crossings_touching from netx_api.mcp_remote.topology.layout_ops.compose_orbit import crossings_touching
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
from netx_topology_mcp.layout_ops.rigid_units import groups_from_compose_meta from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_compose_meta
from netx_topology_mcp.layout_ops.score import score_state from netx_api.mcp_remote.topology.layout_ops.score import score_state
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
_COORD_ABS_MAX = 1.0e6 _COORD_ABS_MAX = 1.0e6
_DEFAULT_ANGLE_STEP = 30 _DEFAULT_ANGLE_STEP = 30
@ -277,7 +277,7 @@ def rigid_orbit_round(
max_accepts: int = 16, max_accepts: int = 16,
) -> OpResult: ) -> OpResult:
"""Greedy bridge-orbit densify over top rigid groups.""" """Greedy bridge-orbit densify over top rigid groups."""
from netx_topology_mcp.layout_jobs import ( from netx_api.mcp_remote.topology.layout_jobs import (
raise_if_cancelled, raise_if_cancelled,
report_progress, report_progress,
touch_heartbeat, touch_heartbeat,

View file

@ -10,9 +10,9 @@ import math
from collections import defaultdict from collections import defaultdict
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.graph_util import connected_components from netx_api.mcp_remote.topology.layout_ops.graph_util import connected_components
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
# Reject / ignore coords that would blow util (phantom region markers, bugs). # Reject / ignore coords that would blow util (phantom region markers, bugs).
_COORD_ABS_MAX = 1.0e6 _COORD_ABS_MAX = 1.0e6

View file

@ -9,9 +9,9 @@ import math
from dataclasses import dataclass from dataclasses import dataclass
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import segments_properly_intersect from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
from netx_topology_mcp.layout_ops.state import LayoutState from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
from netx_topology_mcp.layout_topology_quality import ( from netx_api.mcp_remote.topology.layout_topology_quality import (
RING_MAX_LEN, RING_MAX_LEN,
find_short_chordless_cycles, find_short_chordless_cycles,
) )

View file

@ -7,9 +7,9 @@
from __future__ import annotations from __future__ import annotations
from netx_topology_mcp.layout_ops.min_rings import build_min_ring_skeleton from netx_api.mcp_remote.topology.layout_ops.min_rings import build_min_ring_skeleton
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
from netx_topology_mcp.layout_ops.sugiyama import build_sugiyama_layout from netx_api.mcp_remote.topology.layout_ops.sugiyama import build_sugiyama_layout
def build_ring_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult: def build_ring_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult:

View file

@ -4,8 +4,8 @@ from __future__ import annotations
from typing import Any, Iterable from typing import Any, Iterable
from netx_topology_mcp.layout_ops.graph_util import bbox, connected_components from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox, connected_components
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
def active_nodes(state: LayoutState) -> set[str]: def active_nodes(state: LayoutState) -> set[str]:
@ -41,7 +41,7 @@ def list_blocks(
ids = set(state.positions.keys()) ids = set(state.positions.keys())
m = (mode or "component").strip().lower() m = (mode or "component").strip().lower()
if m == "auto": if m == "auto":
from netx_topology_mcp.layout_ops.partition import resolve_block_mode from netx_api.mcp_remote.topology.layout_ops.partition import resolve_block_mode
m = resolve_block_mode(state, "auto") m = resolve_block_mode(state, "auto")
if m == "component": if m == "component":
@ -58,7 +58,7 @@ def list_blocks(
blocks.append(rest) blocks.append(rest)
return blocks return blocks
if m in {"hub_territory", "leiden", "soft"}: if m in {"hub_territory", "leiden", "soft"}:
from netx_topology_mcp.layout_ops.partition import ( from netx_api.mcp_remote.topology.layout_ops.partition import (
partition_soft_blocks, partition_soft_blocks,
soft_blocks_as_sets, soft_blocks_as_sets,
) )

View file

@ -4,8 +4,8 @@ from __future__ import annotations
from typing import Any from typing import Any
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
from netx_topology_mcp.layout_stats import analyze_layout_stats from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
def score_state( def score_state(

View file

@ -4,8 +4,8 @@ from __future__ import annotations
import math import math
from netx_topology_mcp.layout_ops.graph_util import spine_backbone from netx_api.mcp_remote.topology.layout_ops.graph_util import spine_backbone
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def place_side_branches( def place_side_branches(

View file

@ -7,8 +7,8 @@ from __future__ import annotations
from typing import Any from typing import Any
from netx_topology_mcp.layout_ops.dual_units import DualUnit, layout_dual_unit from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit, layout_dual_unit
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState
def _portal_share_counts(units: list[DualUnit]) -> dict[str, int]: def _portal_share_counts(units: list[DualUnit]) -> dict[str, int]:
@ -271,14 +271,14 @@ def orbit_attach_to_sink(
Scores candidates by (partial crossings, footprint hits, bridge length) Scores candidates by (partial crossings, footprint hits, bridge length)
against the existing sink world — same spirit as compose_orbit hang-block. against the existing sink world — same spirit as compose_orbit hang-block.
""" """
from netx_topology_mcp.layout_ops.compose_orbit import ( from netx_api.mcp_remote.topology.layout_ops.compose_orbit import (
_orbit_candidates_one_portal, _orbit_candidates_one_portal,
_orbit_candidates_orphan, _orbit_candidates_orphan,
_orbit_candidates_two_portal, _orbit_candidates_two_portal,
_pick_best, _pick_best,
_thin_candidates, _thin_candidates,
) )
from netx_topology_mcp.layout_ops.compose_views import ( from netx_api.mcp_remote.topology.layout_ops.compose_views import (
_rank_shared_pivots, _rank_shared_pivots,
_rigid_align_to_world, _rigid_align_to_world,
) )

View file

@ -5,14 +5,14 @@ from __future__ import annotations
from collections import defaultdict, deque from collections import defaultdict, deque
from itertools import product from itertools import product
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.graph_util import ( from netx_api.mcp_remote.topology.layout_ops.graph_util import (
bbox, bbox,
connected_components, connected_components,
order_ans, order_ans,
spine_backbone, spine_backbone,
) )
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _place_component_tutte( def _place_component_tutte(

View file

@ -6,11 +6,11 @@ import math
from collections import deque from collections import deque
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.channels import order_stubs_crossing_aware from netx_api.mcp_remote.topology.layout_ops.channels import order_stubs_crossing_aware
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
from netx_topology_mcp.layout_ops.partition import partition_soft_blocks, pick_hub_seeds from netx_api.mcp_remote.topology.layout_ops.partition import partition_soft_blocks, pick_hub_seeds
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _longest_path( def _longest_path(

View file

@ -14,14 +14,14 @@ from __future__ import annotations
import math import math
from collections import deque from collections import deque
from netx_topology_mcp.layout_metrics import count_edge_crossings from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
from netx_topology_mcp.layout_ops.graph_util import ( from netx_api.mcp_remote.topology.layout_ops.graph_util import (
bbox, bbox,
chain_order, chain_order,
connected_components, connected_components,
order_ans, order_ans,
) )
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _nearest_an( def _nearest_an(
n: str, ans: list[str], adj: dict[str, set[str]], names: dict[str, str] n: str, ans: list[str], adj: dict[str, set[str]], names: dict[str, str]
@ -947,7 +947,7 @@ def _stub_crosses_ring(
order: list[str], order: list[str],
) -> bool: ) -> bool:
"""True if stub—portal properly intersects a ring edge (not at portal).""" """True if stub—portal properly intersects a ring edge (not at portal)."""
from netx_topology_mcp.layout_metrics import segments_properly_intersect from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
if stub not in pos or portal not in pos or len(order) < 2: if stub not in pos or portal not in pos or len(order) < 2:
return False return False
@ -1130,7 +1130,7 @@ def _foreign_edge_crossings(
"""Crossings of unit-internal edges by foreign edges (through the unit).""" """Crossings of unit-internal edges by foreign edges (through the unit)."""
if len(unit_nodes) < 2 or not edges: if len(unit_nodes) < 2 or not edges:
return 0 return 0
from netx_topology_mcp.layout_metrics import segments_properly_intersect from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
internal = [(a, b) for a, b in edges if a in unit_nodes and b in unit_nodes] internal = [(a, b) for a, b in edges if a in unit_nodes and b in unit_nodes]
foreign = [ foreign = [

View file

@ -6,15 +6,15 @@ import math
from collections import defaultdict from collections import defaultdict
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
ICON_SIZE, ICON_SIZE,
REC_CENTER_DX, REC_CENTER_DX,
REC_CENTER_DY, REC_CENTER_DY,
node_footprint, node_footprint,
) )
from netx_topology_mcp.layout_ops.graph_util import bbox from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
from netx_topology_mcp.layout_ops.scope import active_nodes, movable_nodes from netx_api.mcp_remote.topology.layout_ops.scope import active_nodes, movable_nodes
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def select_pins( def select_pins(

View file

@ -6,14 +6,14 @@ import math
import random import random
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
count_edge_crossings, count_edge_crossings,
crossing_participation, crossing_participation,
crossings_involving_node, crossings_involving_node,
node_footprint, node_footprint,
top_crossing_nodes, top_crossing_nodes,
) )
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]): def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]):
@ -166,13 +166,13 @@ def untangle_crossings(
moved: set[str] = set() moved: set[str] = set()
frozen: set[str] = set(frozen_ids or ()) frozen: set[str] = set(frozen_ids or ())
if not frozen and protect_rigid not in (False, "false", "off", "none", "0"): if not frozen and protect_rigid not in (False, "false", "off", "none", "0"):
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect
frozen = frozen_ids_for_protect(st, protect_rigid) frozen = frozen_ids_for_protect(st, protect_rigid)
faces = None faces = None
pierce0 = 0 pierce0 = 0
if protect_rings: if protect_rings:
from netx_topology_mcp.layout_ops.ring_faces import ( from netx_api.mcp_remote.topology.layout_ops.ring_faces import (
count_ring_pierces, count_ring_pierces,
extract_ring_faces, extract_ring_faces,
) )
@ -182,7 +182,7 @@ def untangle_crossings(
focus: set[str] = {str(x) for x in (focus_ids or []) if str(x)} focus: set[str] = {str(x) for x in (focus_ids or []) if str(x)}
hit: dict[str, int] = {} hit: dict[str, int] = {}
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress
rounds_total = max(1, int(max_rounds)) rounds_total = max(1, int(max_rounds))
for _round in range(rounds_total): for _round in range(rounds_total):
@ -265,7 +265,7 @@ def untangle_crossings(
if g2 >= cur_c: if g2 >= cur_c:
continue continue
if faces is not None: if faces is not None:
from netx_topology_mcp.layout_ops.ring_faces import count_ring_pierces from netx_api.mcp_remote.topology.layout_ops.ring_faces import count_ring_pierces
pierce1 = int( pierce1 = int(
count_ring_pierces(trial, links, faces).get("pierce_crossings") count_ring_pierces(trial, links, faces).get("pierce_crossings")
@ -287,7 +287,7 @@ def untangle_crossings(
else: else:
hit.pop(node, None) hit.pop(node, None)
if faces is not None: if faces is not None:
from netx_topology_mcp.layout_ops.ring_faces import count_ring_pierces from netx_api.mcp_remote.topology.layout_ops.ring_faces import count_ring_pierces
pierce0 = int( pierce0 = int(
count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0 count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0

View file

@ -9,7 +9,7 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
collapse_links, collapse_links,
segments_properly_intersect, segments_properly_intersect,
top_crossing_edges, top_crossing_edges,
@ -88,7 +88,7 @@ def list_crossings(
adj[a].add(b) adj[a].add(b)
adj[b].add(a) adj[b].add(a)
from netx_topology_mcp.layout_ops.orbit_sweep import orbit_lite_suggest from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_lite_suggest
pos_xy = {k: (v[0], v[1]) for k, v in pos.items()} pos_xy = {k: (v[0], v[1]) for k, v in pos.items()}
names = {k: v[2] for k, v in pos.items()} names = {k: v[2] for k, v in pos.items()}
@ -134,7 +134,7 @@ def list_crossings(
# Fallback: when no drag_candidates (all hot nodes are high-degree hubs), # Fallback: when no drag_candidates (all hot nodes are high-degree hubs),
# suggest from edge_clearance.top and crossing top_nodes regardless of degree. # suggest from edge_clearance.top and crossing top_nodes regardless of degree.
if not candidates: if not candidates:
from netx_topology_mcp.layout_metrics import compute_edge_clearance from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance
ec = compute_edge_clearance(pos_xy, links, names=names, top_n=5) ec = compute_edge_clearance(pos_xy, links, names=names, top_n=5)
for eh in (ec.get("top_edge_hits") or [])[:5]: for eh in (ec.get("top_edge_hits") or [])[:5]:

View file

@ -9,7 +9,7 @@ from __future__ import annotations
import math import math
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
REC_CENTER_DX, REC_CENTER_DX,
REC_CENTER_DY, REC_CENTER_DY,
analyze_positions, analyze_positions,
@ -698,12 +698,12 @@ def analyze_layout_stats(
pos[fid] = (x, y) pos[fid] = (x, y)
names[fid] = str(n.get("name") or n.get("label") or fid) names[fid] = str(n.get("name") or n.get("label") or fid)
from netx_topology_mcp.layout_metrics import ( from netx_api.mcp_remote.topology.layout_metrics import (
collapse_links, collapse_links,
compute_edge_axis, compute_edge_axis,
compute_edge_clearance, compute_edge_clearance,
) )
from netx_topology_mcp.layout_topology_quality import ( from netx_api.mcp_remote.topology.layout_topology_quality import (
compute_chain_cohesion, compute_chain_cohesion,
compute_topology_quality, compute_topology_quality,
) )

View file

@ -15,8 +15,8 @@ from collections import defaultdict, deque
from statistics import mean, median from statistics import mean, median
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import collapse_links from netx_api.mcp_remote.topology.layout_metrics import collapse_links
from netx_topology_mcp.layout_ops.graph_util import infer_layer from netx_api.mcp_remote.topology.layout_ops.graph_util import infer_layer
def _pct(xs: list[int], p: float) -> float: def _pct(xs: list[int], p: float) -> float:
@ -543,9 +543,9 @@ def analyze_graph_structure(
soft_blocks: dict[str, Any] = {"block_count": 0, "blocks": [], "igraph": False} soft_blocks: dict[str, Any] = {"block_count": 0, "blocks": [], "igraph": False}
dual_units: dict[str, Any] = {"unit_count": 0, "units": []} dual_units: dict[str, Any] = {"unit_count": 0, "units": []}
try: try:
from netx_topology_mcp.layout_ops.dual_units import dual_units_report from netx_api.mcp_remote.topology.layout_ops.dual_units import dual_units_report
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
from netx_topology_mcp.layout_ops.partition import partition_report from netx_api.mcp_remote.topology.layout_ops.partition import partition_report
st = build_state_from_nodes_edges(nodes, edges) st = build_state_from_nodes_edges(nodes, edges)
soft_mode = "soft" if advice.get("decompose_soft_blocks") else "hub_territory" soft_mode = "soft" if advice.get("decompose_soft_blocks") else "hub_territory"

View file

@ -5,59 +5,59 @@ from __future__ import annotations
from dataclasses import replace from dataclasses import replace
from typing import Any from typing import Any
from netx_topology_mcp.layout_ops import ( from netx_api.mcp_remote.topology.layout_ops import (
LayoutParams, LayoutParams,
build_state_from_nodes_edges, build_state_from_nodes_edges,
positions_for_api, positions_for_api,
run_recipe, run_recipe,
score_state, score_state,
) )
from netx_topology_mcp.layout_ops.hotspots import ( from netx_api.mcp_remote.topology.layout_ops.hotspots import (
fix_overlaps_local, fix_overlaps_local,
hotspot_scopes, hotspot_scopes,
overlapping_nodes, overlapping_nodes,
) )
from netx_topology_mcp.layout_ops.recipe import RECIPES from netx_api.mcp_remote.topology.layout_ops.recipe import RECIPES
from netx_topology_mcp.layout_ops.state import LayoutState from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
from netx_topology_mcp.layout_ops.transforms import normalize_origin from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
from netx_topology_mcp.layout_ops.channels import ( from netx_api.mcp_remote.topology.layout_ops.channels import (
straighten_channels_greedy, straighten_channels_greedy,
straighten_params_from_overrides, straighten_params_from_overrides,
) )
from netx_topology_mcp.layout_ops.dual_units import ( from netx_api.mcp_remote.topology.layout_ops.dual_units import (
dual_unit_params_from_overrides, dual_unit_params_from_overrides,
layout_dual_unit, layout_dual_unit,
) )
from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_membership
from netx_topology_mcp.layout_ops.untangle import ( from netx_api.mcp_remote.topology.layout_ops.untangle import (
untangle_crossings, untangle_crossings,
untangle_params_from_overrides, untangle_params_from_overrides,
) )
from netx_topology_mcp.layout_ops.press_crossings import ( from netx_api.mcp_remote.topology.layout_ops.press_crossings import (
park_phantom_nodes, park_phantom_nodes,
polish_crossings, polish_crossings,
press_params_from_overrides, press_params_from_overrides,
) )
from netx_topology_mcp.layout_ops.clear_edge_hits import ( from netx_api.mcp_remote.topology.layout_ops.clear_edge_hits import (
clear_edge_hits, clear_edge_hits,
clear_edge_params_from_overrides, clear_edge_params_from_overrides,
) )
from netx_topology_mcp.layout_ops.compact_bbox import ( from netx_api.mcp_remote.topology.layout_ops.compact_bbox import (
compact_bbox, compact_bbox,
compact_bbox_params_from_overrides, compact_bbox_params_from_overrides,
) )
from netx_topology_mcp.layout_ops.pull_far_chains import ( from netx_api.mcp_remote.topology.layout_ops.pull_far_chains import (
pull_far_chains, pull_far_chains,
pull_far_chains_params_from_overrides, pull_far_chains_params_from_overrides,
) )
from netx_topology_mcp.layout_ops.orbit_sweep import ( from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
apply_orbit_pick, apply_orbit_pick,
orbit_params_from_overrides, orbit_params_from_overrides,
orbit_sweep_node, orbit_sweep_node,
orbit_sweep_round, orbit_sweep_round,
orbit_sweep_until_limit, orbit_sweep_until_limit,
) )
from netx_topology_mcp.layout_ops.level_util import ( from netx_api.mcp_remote.topology.layout_ops.level_util import (
apply_level_bands, apply_level_bands,
level_bands_params_from_overrides, level_bands_params_from_overrides,
) )
@ -385,7 +385,7 @@ def run_layout_on_graph(
# layout; local jumps are capped inside untangle_crossings. # layout; local jumps are capped inside untangle_crossings.
st = normalize_origin(op.state, base_params).state st = normalize_origin(op.state, base_params).state
# Overlap crush must not erase untangle gains. # Overlap crush must not erase untangle gains.
from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx
x_before_fix = _cx(st.positions, st.links) x_before_fix = _cx(st.positions, st.links)
st2, fix_meta = _ensure_zero_overlap(st, base_params) st2, fix_meta = _ensure_zero_overlap(st, base_params)
@ -673,7 +673,7 @@ def run_layout_on_graph(
max_eject_degree=int(knobs.get("max_eject_degree") or 5), max_eject_degree=int(knobs.get("max_eject_degree") or 5),
) )
st = normalize_origin(op.state, base_params).state st = normalize_origin(op.state, base_params).state
from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx
x0 = _cx(st.positions, st.links) x0 = _cx(st.positions, st.links)
st2, fix_meta = _ensure_zero_overlap(st, base_params) st2, fix_meta = _ensure_zero_overlap(st, base_params)
@ -814,7 +814,7 @@ def run_layout_on_graph(
) )
st = normalize_origin(op.state, base_params).state st = normalize_origin(op.state, base_params).state
# Overlap crush must not erase crossing gains. # Overlap crush must not erase crossing gains.
from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx
x0 = _cx(st.positions, st.links) x0 = _cx(st.positions, st.links)
st2, fix_meta = _ensure_zero_overlap(st, base_params) st2, fix_meta = _ensure_zero_overlap(st, base_params)
@ -888,7 +888,7 @@ def run_layout_on_graph(
def list_layout_catalog() -> dict[str, Any]: def list_layout_catalog() -> dict[str, Any]:
from netx_topology_mcp import NETX_MCP_REV, __version__ from netx_api.mcp_remote.topology import NETX_MCP_REV, __version__
return { return {
"version": __version__, "version": __version__,

View file

@ -10,7 +10,7 @@ import math
from collections import defaultdict, deque from collections import defaultdict, deque
from typing import Any from typing import Any
from netx_topology_mcp.layout_metrics import segments_properly_intersect from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
# Turn from 180° beyond this → kink (degrees). # Turn from 180° beyond this → kink (degrees).
CHAIN_KINK_DEG = 40.0 CHAIN_KINK_DEG = 40.0

View file

@ -1,62 +0,0 @@
# 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

@ -1,16 +0,0 @@
{
"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

@ -1,18 +0,0 @@
[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

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

View file

@ -1,100 +0,0 @@
"""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

@ -1,97 +0,0 @@
"""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

@ -1,6 +0,0 @@
"""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

@ -1,71 +0,0 @@
# netx-mcp
轻量 **stdio MCP** 客户端,通过 HTTP 调用 [netx](../../README.md) REST API。装在 Cursor、oclaw、Claude Desktop 等 **MCP 宿主所在机器**;`NETX_API_URL` 可指向本机或远端 API。
**安装、更新、各宿主配置、排错** → 仓库主文档 **[docs/MCP.md](../../docs/MCP.md)**(请优先阅读)。
**Skills(唯一真源在仓库根 [`skills/`](../../skills/README.md))** — 本包不镜像 skills。
## 速查:安装
```powershell
# 在 netx 仓库根目录
pip install -e ./packages/netx-mcp
python -c "import netx_mcp; print('ok')"
```
要求 **Python 3.11+**,且 netx API 已运行(`GET /health`)。
## 速查:更新
```powershell
cd <netx 仓库>
git pull
pip install -e ./packages/netx-mcp
```
然后 **重启 MCP 宿主**(Cursor 重开;oclaw 重启后在 Admin 对 `netx` 执行 Health → Sync Tools)。
## 运行
```powershell
$env:NETX_API_URL = "http://127.0.0.1:8890"
python -m netx_mcp
# 或: netx-mcp
```
## 配置
[`mcp.json`](./mcp.json) — `command: python`,`args: ["-m", "netx_mcp"]`,`env` 见文件。
## 工具(15)— skill 组 **ops**(`netx-ops`)
**NMS**(模型面通用名;当前适配器 zte-ume,REST 仍 `/v1/ume/*`):
`queryNmsAlarms`, `aggregateNmsAlarms`, `runNmsDiagnostics`, `queryNmsNeInventory`, `getNmsNe`, `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms`
**Managed CLI + paths**(问「能否登录」必须走这里,不要只查 inventory):
`listManagedNe`, `getManagedNe`, `execManagedNe`, `getNeExecJob`, `listCliTargets`, `findTopologyPaths`
0.3.1:`getNmsNe` 可直接传 host_name/IP;输出统一 `nms_ne_id` / `source=nms`;参数类型错误返回工具错误而非协议崩溃。
参数使用 `nms_ne_id` / `nms_ne_ids`,路径端点使用 `from_nms_ne_id` / `to_nms_ne_id`;`getManagedNe` 只接受 `ne_id`。旧 `ume_*`、`managed_ne_id`、`id`、告警 `ne_name` 别名已删除,未知参数会返回工具错误。REST 适配器内部的 `ume_*` 字段不变。
NMS 查询(含 inventory)按当前 HTTP 权限规则需要 `alarms:read`;托管资源、CLI 目标和路径查询需要 `ne:read`;执行与轮询需要 `ne:exec`;SQL 需要 `sql:query`。所有工具返回递归裁剪结构化口令、令牌等凭据,但 CLI 原文输出仍需调用者判断是否敏感。
拓扑画布 / Fabric → skill **topology** / [`netx-topology-mcp`](../netx-topology-mcp)(见 [docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md))。
业务监控 / 割接 → [`netx-bizmonitor-mcp`](../netx-bizmonitor-mcp)。三组统一配置见根 `mcp.all.json`,均不依赖 netxops。
本包的 `execManagedNe` 独立强制只读 show/display;即使后端设备开放 shell,也拒绝配置、命令拼接、重定向和多行脚本。getManagedNe.capability 描述后端能力,不能作为 MCP 脚本许可;Linux/RouterOS 原生 CLI 当前未暴露。异步 ack 返回 `poll_after_ms=5000`;单台核对 terminal/result.ok,批量还须核对 summary.failed=0 和每个 results[].ok。作业 succeeded 仍可能有单台失败,须分别报告,勿整批重跑已成功设备。失败 job 作为工具错误返回,保留原始失败证据。
同 IP 多条纳管记录须按厂商/驱动选 ID。connect_status 为历史连接测试状态,不替代当前执行结果;有正常业务正文才算查询通过,不能把空正文或仅命令头当作成功。后端的 Cisco IOS SSH 驱动使用 CR 换行并等待命令回显和完整提示符;不影响其它厂商或 Telnet 的默认换行。
## Breaking (0.3.0)
模型工具名从 `*Ume*` 改为 `*Nms*`(与 dsh-netxops 对齐)。请更新 skills / 提示词并重启 MCP 宿主。
## 入口
只保留独立 **netx-mcp** 包入口:`python -m netx_mcp` / `netx-mcp`。旧 `netx_api.mcp`、`netx_api/mcp_server.py` 和直连数据库模式已删除;配置需迁移到本包。
旧 DB 模式的导入告警查询/聚合/批次/诊断、同步触发和历史告警查询未纳入本包 15 工具,相关 HTTP API 仍保留。

View file

@ -1,5 +0,0 @@
# Reminder: skills live at repo root
Canonical playbooks: [`../../skills/`](../../skills/README.md)
This package does **not** ship a `skills/` mirror. Point Cursor (or other hosts) at the repo `skills/` tree after clone.

View file

@ -1,14 +0,0 @@
{
"mcpServers": {
"netx": {
"command": "python",
"args": ["-m", "netx_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1"
}
}
}
}

View file

@ -1,20 +0,0 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "netx-mcp"
version = "0.3.1"
description = "stdio MCP server for netx REST API (NMS alarms, managed NE CLI)"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"httpx>=0.27.0",
]
[project.scripts]
netx-mcp = "netx_mcp.server:main"
[tool.setuptools.packages.find]
where = ["src"]

View file

@ -1,4 +0,0 @@
from netx_mcp.server import main
if __name__ == "__main__":
main()

View file

@ -1,128 +0,0 @@
"""HTTP client for netx REST API (used by stdio MCP server)."""
from __future__ import annotations
import json
import os
from typing import Any
from urllib.parse import quote
import httpx
_PROTOCOL_KEY_ZH_TO_EN: dict[str, str] = {
"其他": "Other",
"时钟": "Clock",
"OTN/光": "OTN/Optical",
"电源": "Power",
}
def api_base_url() -> str:
raw = (
os.getenv("NETX_API_URL")
or os.getenv("OCLAW_NETX_BASE_URL")
or "http://127.0.0.1:8890"
)
return str(raw or "").strip().rstrip("/")
def api_headers() -> dict[str, str]:
h = {"accept": "application/json"}
tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip()
if not tok:
# Lab default written by netx API bootstrap: data/auth/mcp_token
candidates = [
os.getenv("NETX_MCP_TOKEN_FILE", "").strip(),
"data/auth/mcp_token",
os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "auth", "mcp_token"),
]
for raw in candidates:
if not raw:
continue
path = os.path.abspath(raw)
try:
if os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
tok = fh.read().strip()
if tok:
break
except Exception:
continue
if tok:
h["authorization"] = f"Bearer {tok}"
return h
def lang_query_params() -> dict[str, str]:
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
if lang.startswith("en"):
return {"lang": "en"}
return {}
def localize_payload(data: dict[str, Any]) -> dict[str, Any]:
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
if not lang.startswith("en"):
return data
proto = data.get("protocol_summary")
if isinstance(proto, list):
for row in proto:
if isinstance(row, dict):
k = str(row.get("key") or "")
if k in _PROTOCOL_KEY_ZH_TO_EN:
row["key"] = _PROTOCOL_KEY_ZH_TO_EN[k]
return data
def http_json(method: str, path: str, *, params: dict[str, Any] | None = None, timeout: float = 45.0) -> dict[str, Any]:
url = f"{api_base_url()}{path}"
merged: dict[str, Any] = dict(lang_query_params())
if params:
merged.update(params)
try:
with httpx.Client(timeout=timeout, trust_env=False) as client:
resp = client.request(method, url, params=merged or None, headers=api_headers())
text = resp.text
if not resp.is_success:
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
data = resp.json() if text else {}
if isinstance(data, dict):
data = localize_payload(data)
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
except Exception as exc:
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
def http_post_json(path: str, body: dict[str, Any], *, timeout: float = 180.0) -> dict[str, Any]:
url = f"{api_base_url()}{path}"
try:
with httpx.Client(timeout=timeout, trust_env=False) as client:
resp = client.post(url, json=body, headers=api_headers())
text = resp.text
if not resp.is_success:
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
data = resp.json() if text else {}
if isinstance(data, dict):
data = localize_payload(data)
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
except Exception as exc:
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]:
out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if is_error:
out["isError"] = True
return out
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
# API envelopes may nest a failure: {"ok": true, "data": {"ok": false, ...}} (e.g. job_not_found).
data = result.get("data")
if not result.get("ok") or (isinstance(data, dict) and data.get("ok") is False):
return mcp_text_result(result, is_error=True)
return mcp_text_result(result)
def quote_ne_id(ne_id: str) -> str:
return quote(str(ne_id or "").strip(), safe="")

View file

@ -1,146 +0,0 @@
"""netx stdio MCP server (HTTP client to netx REST API).
Environment:
- ``NETX_API_URL``: netx REST base URL (default ``http://127.0.0.1:8890``)
- ``NETX_API_TOKEN``: optional Bearer token
- ``NETX_LANG``: ``zh`` or ``en`` for localized API responses
"""
from __future__ import annotations
import json
import sys
from typing import Any
from netx_mcp.http_client import http_json
from netx_mcp.http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
def _ensure_utf8_stdio() -> None:
"""MCP stdio must be UTF-8; Windows defaults to GBK and breaks the host reader."""
for stream in (sys.stdin, sys.stdout, sys.stderr):
if stream is None or not hasattr(stream, "reconfigure"):
continue
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def _ok(rid: Any, result: dict[str, Any]) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
sys.stdout.flush()
def _err(rid: Any, code: int, message: str) -> None:
sys.stdout.write(
json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False)
+ "\n"
)
sys.stdout.flush()
_UNSET = object()
def _version() -> str:
try:
from importlib.metadata import version
return version("netx-mcp")
except Exception:
return "0.0.0"
def _fetch_scopes() -> list[str] | None:
"""Return granted scopes from /v1/auth/me, or None if the call fails (show all tools)."""
try:
envelope = http_json("GET", "/v1/auth/me")
if not isinstance(envelope, dict) or not envelope.get("ok"):
return None
data = envelope.get("data")
if not isinstance(data, dict):
return None
scopes = data.get("scopes")
if isinstance(scopes, list):
return [str(s) for s in scopes]
user = data.get("user")
if isinstance(user, dict) and isinstance(user.get("scopes"), list):
return [str(s) for s in user["scopes"]]
except Exception:
return None
return None
def run_stdio_loop() -> None:
cached_scopes: list[str] | None | object = _UNSET
def scopes() -> list[str] | None:
nonlocal cached_scopes
if cached_scopes is _UNSET:
cached_scopes = _fetch_scopes()
return cached_scopes # type: ignore[return-value]
for line in sys.stdin:
raw = line.strip()
if not raw:
continue
try:
req = json.loads(raw)
except Exception:
continue
rid = req.get("id")
is_notification = "id" not in req
method = str(req.get("method") or "")
params = req.get("params") if isinstance(req.get("params"), dict) else {}
try:
if method == "initialize":
_ok(
rid,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {}},
"serverInfo": {"name": "netx-mcp", "version": _version(), "mode": "http"},
},
)
continue
if is_notification:
# JSON-RPC: notifications (initialized, cancelled, ...) never get a response.
continue
if method == "ping":
_ok(rid, {})
continue
if method == "tools/list":
_ok(rid, {"tools": tools_for_scopes(scopes())})
continue
if method == "tools/call":
name = str(params.get("name") or "")
need = TOOL_REQUIRED_SCOPE.get(name)
granted = scopes()
if need and granted is not None and need not in {str(s).lower() for s in granted}:
_err(
rid,
-32001,
(
f"insufficient_scope:{need}. "
"Ask a netx admin to grant this scope on the API token; "
"for sql:query prefer aggregateNmsAlarms/queryNmsAlarmsRaw instead."
),
)
continue
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
_ok(rid, call_http_tool(name, args))
continue
_err(rid, -32601, f"method not found: {method}")
except Exception as exc:
_err(rid, -32000, str(exc))
def main() -> None:
_ensure_utf8_stdio()
run_stdio_loop()
if __name__ == "__main__":
main()

View file

@ -1,72 +0,0 @@
# netx-topology-mcp
独立的 **stdio MCP**,只暴露 netx **拓扑画布 / Fabric** 能力,与告警/CLI 的 [`netx-mcp`](../netx-mcp) 分开安装,方便 Agent 按需启用。
```
MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → netx API /v1/topology/*
```
详细说明 → **[docs/MCP_TOPOLOGY.md](../../docs/MCP_TOPOLOGY.md)**
配套 Skill → **[`skills/topology/netx-topology`](../../skills/topology/netx-topology/SKILL.md)**(画图流水线与硬规则)
## 目录模型
- 顶级「根」为导航;其下「根图」/ `Root map` 为画布。
- **只**用 `createTopologyFolder`:顶级建根+根图;根图下建**子区域**(各带 physical `view_id`)。
- 不提供 `createTopologyView`(禁止同目录另挂 custom 交付图)。
## 安装
```powershell
cd D:\project\chatgpt\netx
pip install -e ./packages/netx-topology-mcp
# optional: igraph for soft-block Leiden / block-center FR (layout still ours)
pip install -e "./packages/netx-topology-mcp[layout]"
python -c "import netx_topology_mcp; print('ok')"
```
GitHub:
```powershell
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-topology-mcp"
```
## 配置
复制 [`mcp.json`](./mcp.json) 到 Cursor / oclaw(`server_id=netx-topology`),可与 `netx` 同时存在。
画图请在 env 里设带 `ne:write` 的 `NETX_API_TOKEN`(不要只靠仓库 `mcp_token`)。`getTopologyView` / `projectTopologyNeighbors` 默认 `detail=summary`(坐标抽样 + `links[]` 邻接);`queryTopologyEdges` 默认 adjacency(一对网元一条线),避免整图/端口边占满上下文。
## 工具(16)
覆盖 netxops `topology` 清单全部 15 项,另提供 `sinkTopologyDualUnits`。netxops 工具名前缀为 `netx__`,独立 MCP 不带此前缀。
| 类别 | 工具 |
|------|------|
| 树/区域/画布 | `getTopologyTree`, `createTopologyFolder`(唯一建图,返回 view_id), `getTopologyView` |
| 画图 | `addTopologyViewNodes` / `remove…` / `sinkTopologyDualUnits`(分批沉入+默认可先 layout_dual_unit) / `copyTopologyViewNodes`(一键克隆测沙箱) / `update…Positions`(**优先筛选**,API 自选 id;也可 id 列表), `projectTopologyNeighbors` |
| Fabric 只读 | `queryTopologyFabricNodes`(summary\|list\|search), `queryTopologyNeighborhood`, `queryTopologyEdges` |
| Fabric 分类 | `classifyTopologyFabricNodes`(match/tag/patch/unmatched/preview_rules/apply_rules/list_rules) |
| 自动布图 | `layoutTopologyView`(小图 `compact`/`corridor`/`rings`;巨图:`sinkTopologyDualUnits`→`orbit_sweep`→`polish_crossings`→`clear_edge_hits`) |
| 布图验收/规划 | `analyzeTopologyViewLayout`(score;`detail=structure` 定重心;`both`=structure+手拖 sight) |
**安全约束:** MCP **不会**创建 Fabric 占位节点、**不会**写手工链路;画布只能引用已存在的 fabric 节点。
画布节点增删和位置布局只接受 `keyword/role/vendor/link_status` 筛选。按 `region_folder_id/level/level_major` 画图时,先用 `queryTopologyFabricNodes(mode=list)` 分页查询,再将返回的 `id` 作为 `fabric_node_ids` 传入画布工具。`projectTopologyNeighbors(dry_run=true)` 可预览邻居投影而不保存。
## 自动布局与异步任务
1. `layoutTopologyView({view_id, action:"layout", recipe:"compact", mode:"preview"})` 预览;`mode:"apply"` 才保存坐标。
2. 需要后台计算时,调用 `layoutTopologyView({view_id, action:"layout", recipe:"compact", mode:"apply", params:{background:true}})`,记录返回的 `job_id`。布局/局部优化计算支持显式后台;`move_nodes` 和 `align_reference` 按各自流程同步执行。
3. 调用 `layoutTopologyView({action:"job_status", params:{job_id}})`,直到 `status` 为 `done/error/cancelled`。`running/cancelling` 仍需轮询;`stale=true` 仅表示心跳过期,不能视为完成。成功必须检查 `ok=true`、`status=done`、`result.applied=true`。
4. `getTopologyView({view_id, detail:"full"})` 回读 HTTP API 持久化后的坐标。`job_status` 的结果会裁剪大字段,不替代完整画布回读。
5. 需要取消时调用 `layoutTopologyView({action:"job_cancel", params:{job_id}})`;取消为协作式,仍需轮询终态。已经写入的坐标不会因取消自动回滚。
部分大图优化会自动转后台:`polish_crossings/orbit_sweep/clear_edge_hits/compact_bbox/pull_far_chains/layout_dual_unit` 在节点数达到 600 时;`orbit_sweep(until_limit=true)` 的 apply 总是后台。`params.sync=true` 可强制同步。
任务 JSON 默认位于仓库 `data/runtime/layout_jobs`;用绝对路径 `NETX_LAYOUT_JOB_DIR` 可指定共享目录。后台子进程和任务文件能跨 MCP 主进程重启,轮询方必须访问同一目录;这不是 HTTP 后端任务接口,也不支持跨机器自动共享。历史回收仅清理终态,保留运行中的任务。
HTTP 200 内的 `ok=false` 会作为 MCP `isError=true` 返回。分批写入遇错即停;复制/移动/下沉若保存目标坐标失败,会保留源成员并报告已增加的目标成员。最终回读失败返回 `view_readback_failed` 和 `write_completed=true`,此时应先检查画布再重试。多次 HTTP 写入不构成事务,已完成的部分不会自动撤销。
只使用工具 schema 中公开的动作。旧动作别名 `resolve_overlaps/sink_nodes/align_to_reference`、查询模式 `stats/count/find`、分类动作 `find/bulk_tag/assign/preview/apply/rules`、详情别名 `raw/graph/full`(边查询的 full)已移除;规范动作分别见 `layoutTopologyView(catalog=true)` 与各工具 schema。

View file

@ -1,5 +0,0 @@
# Reminder: skills live at repo root
Canonical topology playbook: [`../../skills/topology/netx-topology/`](../../skills/topology/netx-topology/SKILL.md)
This package does **not** ship a `skills/` mirror.

View file

@ -1,16 +0,0 @@
{
"mcpServers": {
"netx-topology": {
"command": "python",
"args": ["-m", "netx_topology_mcp"],
"env": {
"NETX_API_URL": "http://127.0.0.1:8890",
"NETX_API_TOKEN": "nxt_replace_with_api_key_ne_write",
"NETX_LANG": "zh",
"PYTHONIOENCODING": "utf-8",
"PYTHONUTF8": "1",
"PYTHONPATH": "D:/project/chatgpt/netx/packages/netx-topology-mcp/src"
}
}
}
}

View file

@ -1,29 +0,0 @@
[build-system]
requires = ["setuptools>=68", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "netx-topology-mcp"
version = "0.1.20"
description = "stdio MCP server for netx topology canvas (views / fabric / draw)"
readme = "README.md"
requires-python = ">=3.11"
license = { text = "MIT" }
dependencies = [
"httpx>=0.27.0",
]
[project.optional-dependencies]
layout = [
"igraph>=0.11.0",
]
dev = [
"pytest>=8.0",
"igraph>=0.11.0",
]
[project.scripts]
netx-topology-mcp = "netx_topology_mcp.server:main"
[tool.setuptools.packages.find]
where = ["src"]

View file

@ -1,4 +0,0 @@
from netx_topology_mcp.server import main
if __name__ == "__main__":
main()

View file

@ -1,152 +0,0 @@
"""HTTP client for netx REST API (topology MCP)."""
from __future__ import annotations
import json
import os
from concurrent.futures import ThreadPoolExecutor, as_completed
from typing import Any
import httpx
def api_base_url() -> str:
raw = (
os.getenv("NETX_API_URL")
or os.getenv("OCLAW_NETX_BASE_URL")
or "http://127.0.0.1:8890"
)
return str(raw or "").strip().rstrip("/")
def api_headers() -> dict[str, str]:
h = {"accept": "application/json"}
tok = (os.getenv("NETX_API_TOKEN") or os.getenv("OCLAW_NETX_API_TOKEN") or "").strip()
if not tok:
candidates = [
os.getenv("NETX_MCP_TOKEN_FILE", "").strip(),
"data/auth/mcp_token",
os.path.join(os.path.dirname(__file__), "..", "..", "..", "data", "auth", "mcp_token"),
]
for raw in candidates:
if not raw:
continue
path = os.path.abspath(raw)
try:
if os.path.isfile(path):
with open(path, encoding="utf-8") as fh:
tok = fh.read().strip()
if tok:
break
except Exception:
continue
if tok:
h["authorization"] = f"Bearer {tok}"
return h
def lang_query_params() -> dict[str, str]:
lang = str(os.getenv("NETX_LANG") or "zh").strip().lower()
if lang.startswith("en"):
return {"lang": "en"}
return {}
def _default_timeout() -> float:
try:
return float(os.getenv("NETX_HTTP_TIMEOUT") or 120.0)
except (TypeError, ValueError):
return 120.0
def http_json(
method: str,
path: str,
*,
params: dict[str, Any] | None = None,
body: dict[str, Any] | None = None,
timeout: float | None = None,
) -> dict[str, Any]:
url = f"{api_base_url()}{path}"
merged: dict[str, Any] = dict(lang_query_params())
if params:
merged.update(params)
to = float(timeout) if timeout is not None else _default_timeout()
try:
with httpx.Client(timeout=to, trust_env=False) as client:
resp = client.request(
method,
url,
params=merged or None,
json=body,
headers=api_headers(),
)
text = resp.text
if not resp.is_success:
return {"ok": False, "error": f"netx_http_{resp.status_code}", "detail": text[:800]}
data = resp.json() if text else {}
return {"ok": True, "data": data if isinstance(data, dict) else {"raw": data}}
except Exception as exc:
return {"ok": False, "error": "netx_request_failed", "detail": str(exc)[:800]}
def http_json_many(
requests: list[dict[str, Any]],
*,
max_workers: int = 12,
timeout: float | None = None,
) -> list[dict[str, Any]]:
"""Run many HTTP calls in parallel; preserve input order in results.
Each request: ``{method, path, params?, body?, key?}``.
"""
if not requests:
return []
if len(requests) == 1:
r0 = requests[0]
out = http_json(
str(r0.get("method") or "GET"),
str(r0.get("path") or ""),
params=r0.get("params") if isinstance(r0.get("params"), dict) else None,
body=r0.get("body") if isinstance(r0.get("body"), dict) else None,
timeout=timeout,
)
if "key" in r0:
out = {**out, "key": r0.get("key")}
return [out]
to = float(timeout) if timeout is not None else _default_timeout()
workers = max(1, min(int(max_workers), len(requests)))
results: list[dict[str, Any] | None] = [None] * len(requests)
def _one(idx: int, req: dict[str, Any]) -> tuple[int, dict[str, Any]]:
out = http_json(
str(req.get("method") or "GET"),
str(req.get("path") or ""),
params=req.get("params") if isinstance(req.get("params"), dict) else None,
body=req.get("body") if isinstance(req.get("body"), dict) else None,
timeout=to,
)
if "key" in req:
out = {**out, "key": req.get("key")}
return idx, out
with ThreadPoolExecutor(max_workers=workers) as pool:
futs = [pool.submit(_one, i, r) for i, r in enumerate(requests)]
for fut in as_completed(futs):
idx, out = fut.result()
results[idx] = out
return [r if isinstance(r, dict) else {"ok": False, "error": "parallel_slot_empty"} for r in results]
def mcp_text_result(payload: Any, *, is_error: bool = False) -> dict[str, Any]:
out: dict[str, Any] = {"content": [{"type": "text", "text": json.dumps(payload, ensure_ascii=False)}]}
if is_error:
out["isError"] = True
return out
def mcp_from_handler_result(result: dict[str, Any]) -> dict[str, Any]:
if not result.get("ok"):
return mcp_text_result(result, is_error=True)
return mcp_text_result(result)

View file

@ -1,131 +0,0 @@
"""netx topology stdio MCP server (HTTP client to netx REST API).
Environment:
- ``NETX_API_URL``: netx REST base URL (default ``http://127.0.0.1:8890``)
- ``NETX_API_TOKEN``: optional Bearer token
- ``NETX_LANG``: ``zh`` or ``en``
"""
from __future__ import annotations
import json
import sys
import time
from typing import Any
from netx_topology_mcp.http_client import http_json
from netx_topology_mcp.http_tools import TOOL_REQUIRED_SCOPE, call_http_tool, tools_for_scopes
# Re-fetch /v1/auth/me scopes so API Key permission edits become visible without
# restarting the whole MCP host for too long. tools/list always refreshes.
_SCOPE_TTL_SEC = 45.0
def _ensure_utf8_stdio() -> None:
for stream in (sys.stdin, sys.stdout, sys.stderr):
if stream is None or not hasattr(stream, "reconfigure"):
continue
try:
stream.reconfigure(encoding="utf-8", errors="replace")
except Exception:
pass
def _ok(rid: Any, result: dict[str, Any]) -> None:
sys.stdout.write(json.dumps({"jsonrpc": "2.0", "id": rid, "result": result}, ensure_ascii=False) + "\n")
sys.stdout.flush()
def _err(rid: Any, code: int, message: str) -> None:
sys.stdout.write(
json.dumps({"jsonrpc": "2.0", "id": rid, "error": {"code": code, "message": message}}, ensure_ascii=False)
+ "\n"
)
sys.stdout.flush()
_UNSET = object()
def _fetch_scopes() -> list[str] | None:
try:
envelope = http_json("GET", "/v1/auth/me")
if not isinstance(envelope, dict) or not envelope.get("ok"):
return None
data = envelope.get("data")
if not isinstance(data, dict):
return None
scopes = data.get("scopes")
if isinstance(scopes, list):
return [str(s) for s in scopes]
user = data.get("user")
if isinstance(user, dict) and isinstance(user.get("scopes"), list):
return [str(s) for s in user["scopes"]]
except Exception:
return None
return None
def run_stdio_loop() -> None:
cached_scopes: list[str] | None | object = _UNSET
cached_at = 0.0
def scopes(*, force: bool = False) -> list[str] | None:
nonlocal cached_scopes, cached_at
now = time.monotonic()
stale = cached_scopes is _UNSET or (now - cached_at) >= _SCOPE_TTL_SEC
if force or stale:
cached_scopes = _fetch_scopes()
cached_at = now
return cached_scopes # type: ignore[return-value]
for line in sys.stdin:
raw = line.strip()
if not raw:
continue
try:
req = json.loads(raw)
except Exception:
continue
rid = req.get("id")
method = str(req.get("method") or "")
params = req.get("params") if isinstance(req.get("params"), dict) else {}
try:
if method == "initialize":
_ok(
rid,
{
"protocolVersion": "2024-11-05",
"capabilities": {"tools": {"listChanged": True}},
"serverInfo": {"name": "netx-topology-mcp", "version": "0.1.2", "mode": "http"},
},
)
continue
if method == "notifications/initialized":
continue
if method == "tools/list":
_ok(rid, {"tools": tools_for_scopes(scopes(force=True))})
continue
if method == "tools/call":
name = str(params.get("name") or "")
need = TOOL_REQUIRED_SCOPE.get(name)
granted = scopes()
if need and granted is not None and need not in {str(s).lower() for s in granted}:
_err(rid, -32001, f"insufficient_scope:{need}")
continue
args = params.get("arguments") if isinstance(params.get("arguments"), dict) else {}
_ok(rid, call_http_tool(name, args))
continue
_err(rid, -32601, f"method not found: {method}")
except Exception as exc:
_err(rid, -32000, str(exc))
def main() -> None:
_ensure_utf8_stdio()
run_stdio_loop()
if __name__ == "__main__":
main()

View file

@ -1,262 +0,0 @@
"""Background layout jobs + slim payload helpers."""
from __future__ import annotations
import time
from unittest.mock import patch
from netx_topology_mcp.http_tools import _layout_topology_view, _slim_layout_payload
from netx_topology_mcp.layout_jobs import (
cancel_job,
get_job,
is_cancelled,
job_public,
report_progress,
start_job,
)
def test_terminal_job_survives_cache_reset_and_reports_failure(tmp_path) -> None:
from netx_topology_mcp import layout_jobs as lj
lj._write_job_disk({"job_id": "persisted", "status": "running", "started_at": time.time()})
lj.finish_job("persisted", result={"ok": False, "error": "backend_write_failed", "applied": False})
lj._JOBS.clear()
out = _layout_topology_view({"action": "job_status", "params": {"job_id": "persisted"}})
assert out["ok"] is False
assert out["status"] == "error"
assert out["error"] == "backend_write_failed"
assert out["result"]["applied"] is False
def test_spawn_failure_is_persisted_as_terminal_error() -> None:
from netx_topology_mcp import layout_jobs as lj
with patch.object(lj.subprocess, "Popen", side_effect=OSError("spawn denied")):
jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"})
lj._JOBS.clear()
out = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}})
assert out["ok"] is False
assert out["status"] == "error"
assert out["error"].startswith("worker_start_failed:")
assert not lj._args_path(jid).exists()
def test_fast_worker_terminal_progress_is_not_overwritten() -> None:
from types import SimpleNamespace
from netx_topology_mcp import layout_jobs as lj
def spawn(argv, **kwargs):
jid = argv[-1]
lj.finish_job(jid, result={"ok": True, "applied": True})
return SimpleNamespace(pid=123)
with patch.object(lj.subprocess, "Popen", side_effect=spawn):
jid = start_job(action="layout", view_id="v", tool_args={"view_id": "v"})
out = lj.job_public(jid)
assert out["status"] == "done"
assert out["progress"]["phase"] == "done"
assert out["progress"]["pct"] == 100.0
def test_pruning_preserves_active_jobs() -> None:
from netx_topology_mcp import layout_jobs as lj
lj._write_job_disk({"job_id": "active", "status": "running"})
lj._write_job_disk({"job_id": "completed", "status": "done"})
with patch.object(lj, "_MAX_JOBS", 1):
lj._prune_disk()
assert lj._read_job_disk("active") is not None
assert lj._read_job_disk("completed") is None
def test_start_job_and_poll() -> None:
def runner() -> dict:
report_progress("mid", pct=50.0, message="halfway", step=1, total_steps=2)
time.sleep(0.05)
return {"ok": True, "action": "compose_views", "applied": True, "node_count": 3}
jid = start_job(action="compose_views", view_id="v1", runner=runner, meta={"n": 1})
assert jid
saw_progress = False
for _ in range(40):
pub = job_public(jid)
assert pub is not None
assert "elapsed_ms" in pub
assert "progress" in pub
assert "heartbeat_age_ms" in pub
if (pub.get("progress") or {}).get("phase") == "mid":
saw_progress = True
if pub["status"] != "running":
break
time.sleep(0.05)
assert saw_progress
job = get_job(jid)
assert job is not None
assert job["status"] == "done"
assert job["result"]["applied"] is True
st = _layout_topology_view({"action": "job_status", "params": {"job_id": jid}})
assert st.get("ok") is True
assert st.get("status") == "done"
assert st.get("result", {}).get("applied") is True
assert isinstance(st.get("progress"), dict)
def test_cancel_job_cooperative() -> None:
started = time.time()
def runner() -> dict:
report_progress("work", pct=10.0, message="before sleep")
for _ in range(40):
if is_cancelled():
from netx_topology_mcp.layout_jobs import raise_if_cancelled
raise_if_cancelled()
time.sleep(0.05)
return {"ok": True, "applied": True}
jid = start_job(action="polish_crossings", view_id="v1", runner=runner)
time.sleep(0.08)
out = _layout_topology_view({"action": "job_cancel", "params": {"job_id": jid}})
assert out.get("ok") is True
assert out.get("cancel_requested") is True
for _ in range(40):
pub = job_public(jid)
assert pub is not None
if pub["status"] == "cancelled":
break
time.sleep(0.05)
pub = job_public(jid)
assert pub is not None
assert pub["status"] == "cancelled"
assert time.time() - started < 3.0
def test_slim_layout_payload_drops_guide() -> None:
fat = {
"ok": True,
"action": "compose_views",
"node_count": 1200,
"guide": {"how_to_read": "x" * 200},
"tried": [1, 2, 3],
"params_used": {"lane": 1},
"crossing": {
"status": "fail",
"score": 0,
"edge_crossings": 9,
"crossings_per_link": 0.1,
"top_nodes": [{"id": "a"}] * 20,
"top_edges": [{"id": "e"}] * 20,
"tip": "long",
},
"summary": {"total": 40},
}
slim = _slim_layout_payload(fat)
assert slim.get("slim") is True
assert "guide" not in slim
assert "tried" not in slim
assert len(slim["crossing"]["top_nodes"]) <= 5
def test_compose_views_is_not_public() -> None:
# compose_* was retired from the public surface (dual-unit drain is the main path).
out = _layout_topology_view(
{
"view_id": "full",
"action": "compose_views",
"mode": "apply",
"params": {"source_view_ids": [f"s{i:02d}" for i in range(12)]},
}
)
assert out.get("ok") is False
assert out.get("error") == "unknown_action:compose_views"
def test_orbit_background_returns_job_id() -> None:
graph = {
"ok": True,
"nodes": [
{"fabric_node_id": f"n{i}", "x": float(i * 100), "y": 0.0, "label": f"N{i}"}
for i in range(6)
],
"edges": [
{"id": f"e{i}", "a_node_id": f"n{i}", "b_node_id": f"n{i + 1}"} for i in range(5)
],
}
def fake_start(**kwargs):
assert kwargs["action"] == "orbit_sweep"
assert kwargs["view_id"] == "full"
tool_args = kwargs.get("tool_args")
assert isinstance(tool_args, dict)
# The worker re-runs the same call synchronously.
assert tool_args["params"].get("_force_sync") is True
assert "background" not in tool_args["params"]
return "abc123job"
with patch("netx_topology_mcp.http_tools.http_json", return_value={"ok": True, "data": graph}), patch(
"netx_topology_mcp.http_tools.start_job", side_effect=fake_start
):
out = _layout_topology_view(
{
"view_id": "full",
"action": "orbit_sweep",
"mode": "apply",
"params": {"background": True},
}
)
assert out.get("ok") is True, out
assert out.get("status") == "running"
assert out.get("job_id") == "abc123job"
assert out.get("applied") is False
def test_job_status_missing() -> None:
out = _layout_topology_view({"action": "job_status", "params": {}})
assert out.get("ok") is False
assert out.get("error") == "job_id_required"
def test_job_status_unknown() -> None:
out = _layout_topology_view(
{"action": "job_status", "params": {"job_id": "does-not-exist"}}
)
assert out.get("ok") is False
assert out.get("error") == "job_not_found"
def test_cancel_missing() -> None:
out = cancel_job("missing-job")
assert out.get("ok") is False
assert out.get("error") == "job_not_found"
def test_soft_stale_keeps_running_and_cancelable() -> None:
"""stale is a poll warning only — must not freeze status or block cancel."""
from netx_topology_mcp import layout_jobs as lj
def runner() -> dict:
time.sleep(0.3)
return {"ok": True, "applied": False}
jid = start_job(action="polish_crossings", view_id="v1", runner=runner)
with lj._LOCK: # noqa: SLF001 — test injects silent heartbeat on disk
job = lj._read_job_disk(jid) or lj._JOBS[jid]
job["heartbeat_at"] = time.time() - (lj._STALE_AFTER_S + 5)
lj._JOBS[jid] = job
lj._write_job_disk(job)
pub = job_public(jid)
assert pub is not None
assert pub["stale"] is True
assert pub["status"] == "running"
cancelled = cancel_job(jid)
assert cancelled.get("ok") is True
assert cancelled.get("status") == "cancelling"
for _ in range(40):
pub2 = job_public(jid)
assert pub2 is not None
if pub2["status"] in {"cancelled", "done", "error"}:
break
time.sleep(0.05)
assert job_public(jid)["status"] == "cancelled"

View file

@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "netx-ops" name = "netx-ops"
version = "0.4.19" version = "0.4.19"
description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP" description = "netx operations tool: alarm-centric workflows with REST API and remote MCP"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
license = { text = "MIT" } license = { text = "MIT" }
@ -29,12 +29,11 @@ dependencies = [
"alembic>=1.13.0", "alembic>=1.13.0",
"psutil>=5.9.0", "psutil>=5.9.0",
"redis>=5.0.0", "redis>=5.0.0",
"mcp>=1.0.0",
] ]
[project.optional-dependencies] [project.optional-dependencies]
mcp = ["netx-mcp @ file:packages/netx-mcp"] # Remote MCP is built into netx_api (/mcp). Optional extras removed.
mcp-topology = ["netx-topology-mcp @ file:packages/netx-topology-mcp"]
mcp-bizmonitor = ["netx-bizmonitor-mcp @ file:packages/netx-bizmonitor-mcp"]
[tool.setuptools.packages.find] [tool.setuptools.packages.find]
where = ["."] where = ["."]

View file

@ -21,3 +21,4 @@ PyJWT>=2.8.0
alembic>=1.13.0 alembic>=1.13.0
psutil>=5.9.0 psutil>=5.9.0
redis>=5.0.0 redis>=5.0.0
mcp>=1.0.0

View file

@ -1,6 +1,6 @@
"""通过真实 stdio MCP 留存本地验收证据;不安装插件、不导入 handler。 """通过真实远程 HTTP MCP(/mcp Streamable HTTP)留存本地验收证据。
使用已有本机 MCP 配置中的凭据,只在子进程内存中传递,证据自动去敏。 使用已有本机 MCP 配置中的凭据,只在进程内存中传递,证据自动去敏。
示例:python scripts/mcp_acceptance.py ops runNmsDiagnostics '{}' 示例:python scripts/mcp_acceptance.py ops runNmsDiagnostics '{}'
""" """
from __future__ import annotations from __future__ import annotations
@ -10,18 +10,14 @@ import datetime as dt
import json import json
import os import os
from pathlib import Path from pathlib import Path
import queue
import re import re
import subprocess
import sys import sys
import threading
import time
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
GROUPS = { GROUPS = {
"ops": ("netx", "netx_mcp", "netx-mcp"), "ops": "ops",
"topology": ("netx-topology", "netx_topology_mcp", "netx-topology-mcp"), "topology": "topology",
"biz": ("netx-bizmonitor", "netx_bizmonitor_mcp", "netx-bizmonitor-mcp"), "biz": "bizMonitor",
} }
SENSITIVE = re.compile(r"password|passwd|secret|token|authorization|cookie|private_key", re.I) SENSITIVE = re.compile(r"password|passwd|secret|token|authorization|cookie|private_key", re.I)
@ -32,7 +28,6 @@ def redact(value):
if isinstance(value, list): if isinstance(value, list):
return [redact(x) for x in value] return [redact(x) for x in value]
if isinstance(value, str): if isinstance(value, str):
# MCP 的 JSON 位于 content.text 内;递归处理,避免只净化外层。
try: try:
decoded = json.loads(value) decoded = json.loads(value)
except (ValueError, TypeError): except (ValueError, TypeError):
@ -53,69 +48,100 @@ def payload(response):
return result or response.get("error", {}) return result or response.get("error", {})
class McpSession: def _resolve_base(config: Path) -> tuple[str, str]:
def __init__(self, group, config, evidence, source_root=ROOT): cfg = json.loads(Path(config).read_text(encoding="utf-8-sig"))
source_root = Path(source_root).resolve() servers = cfg.get("mcpServers") or {}
server_id, module, package = GROUPS[group] # Prefer first remote URL entry; fall back to env.
cfg = json.loads(Path(config).read_text(encoding="utf-8-sig")) url = ""
settings = cfg["mcpServers"][server_id].get("env", {}) token = os.environ.get("NETX_API_TOKEN") or os.environ.get("NETX_MCP_TOKEN") or ""
env = os.environ.copy() for _name, entry in servers.items():
env.update({k: str(v) for k, v in settings.items()}) if not isinstance(entry, dict):
# 只允许本机 netx;不能由配置悄悄指向生产服务。 continue
actual_url = (env.get("NETX_API_URL") or env.get("OCLAW_NETX_BASE_URL") candidate = str(entry.get("url") or "").strip()
or "http://127.0.0.1:8890").rstrip("/") if candidate.startswith("http"):
if actual_url not in ( url = candidate
"http://127.0.0.1:8890", "http://localhost:8890", headers = entry.get("headers") or {}
"http://127.0.0.1:8891", "http://localhost:8891" auth = str(headers.get("Authorization") or headers.get("authorization") or "")
if auth.lower().startswith("bearer "):
raw = auth[7:].strip()
if raw and not raw.startswith("${"):
token = raw
break
if not url:
url = (os.environ.get("NETX_API_URL") or "http://127.0.0.1:8890").rstrip("/") + "/mcp"
# Strip query for base; group applied separately.
base = url.split("?", 1)[0].rstrip("/")
allowed = {
"http://127.0.0.1:8890/mcp",
"http://localhost:8890/mcp",
"http://127.0.0.1:8891/mcp",
"http://localhost:8891/mcp",
}
if base not in allowed:
raise ValueError("验收仅允许本机 8890/8891 的 /mcp")
if not token:
for p in (
Path(os.environ.get("NETX_AUTH_MCP_TOKEN_FILE") or ""),
ROOT / "data" / "auth" / "mcp_token",
): ):
raise ValueError("验收仅允许本机 8890 API 或隔离样例 8891 API") if p.is_file():
env["NETX_API_URL"] = actual_url token = p.read_text(encoding="utf-8").strip()
env.pop("OCLAW_NETX_BASE_URL", None) if token:
env["PYTHONPATH"] = str(source_root / "packages" / package / "src") break
env["PYTHONUTF8"] = "1" if not token:
self.group, self.evidence, self.seq = group, Path(evidence), 0 raise ValueError("缺少 Bearer token(NETX_API_TOKEN 或 data/auth/mcp_token)")
self.stderr = [] return base, token
self.proc = subprocess.Popen(
[sys.executable, "-m", module], cwd=source_root, env=env,
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE, class McpSession:
encoding="utf-8", bufsize=1, def __init__(self, group: str, config: str, evidence: str):
import httpx
self.group = GROUPS[group]
self.evidence = Path(evidence)
self.seq = 0
base, token = _resolve_base(Path(config))
self.url = f"{base}?groups={self.group}"
self._client = httpx.Client(
timeout=180.0,
trust_env=False, # Windows system proxy breaks loopback /mcp framing
headers={
"Authorization": f"Bearer {token}",
"Accept": "application/json, text/event-stream",
"Content-Type": "application/json",
"MCP-Protocol-Version": "2024-11-05",
},
)
self.rpc(
"initialize",
{
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "netx-mcp-acceptance", "version": "1"},
},
)
# notifications/initialized — no response expected
self._client.post(
self.url,
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
) )
self.lines = queue.Queue()
threading.Thread(target=self._read_stdout, daemon=True).start()
threading.Thread(target=self._read_stderr, daemon=True).start()
self.rpc("initialize", {"protocolVersion": "2024-11-05", "capabilities": {},
"clientInfo": {"name": "netx-mcp-acceptance", "version": "1"}})
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
self.proc.stdin.flush()
def _read_stdout(self): def rpc(self, method, params=None):
for line in self.proc.stdout: import time
self.lines.put(line)
def _read_stderr(self):
for line in self.proc.stderr:
self.stderr.append(line)
def rpc(self, method, params=None, timeout=180):
self.seq += 1 self.seq += 1
request = {"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params or {}} request = {"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params or {}}
start = time.monotonic() start = time.monotonic()
self.proc.stdin.write(json.dumps(request, ensure_ascii=False) + "\n") r = self._client.post(self.url, json=request)
self.proc.stdin.flush() r.raise_for_status()
deadline = start + timeout response = r.json()
while True: record = {
try: "at": dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).isoformat(),
line = self.lines.get(timeout=max(0.01, deadline - time.monotonic())) "group": self.group,
except queue.Empty as exc: "request": request,
raise TimeoutError(f"{self.group} {method} 超时;stderr={''.join(self.stderr)[-500:]}") from exc "response": response,
response = json.loads(line) "elapsed_ms": round((time.monotonic() - start) * 1000),
if response.get("id") == self.seq: }
break
if "id" in response:
raise RuntimeError("服务器对通知回包或返回了错误的请求 id")
record = {"at": dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).isoformat(),
"group": self.group, "request": request, "response": response,
"elapsed_ms": round((time.monotonic() - start) * 1000)}
self.evidence.parent.mkdir(parents=True, exist_ok=True) self.evidence.parent.mkdir(parents=True, exist_ok=True)
with self.evidence.open("a", encoding="utf-8") as f: with self.evidence.open("a", encoding="utf-8") as f:
f.write(json.dumps(redact(record), ensure_ascii=False) + "\n") f.write(json.dumps(redact(record), ensure_ascii=False) + "\n")
@ -132,12 +158,7 @@ class McpSession:
return self.rpc("tools/call", {"name": name, "arguments": args}) return self.rpc("tools/call", {"name": name, "arguments": args})
def close(self): def close(self):
self.proc.stdin.close() self._client.close()
try:
self.proc.wait(timeout=8)
except subprocess.TimeoutExpired:
self.proc.terminate()
self.proc.wait(timeout=5)
def main(): def main():
@ -146,10 +167,9 @@ def main():
parser.add_argument("tool", help="工具名,或 tools/list") parser.add_argument("tool", help="工具名,或 tools/list")
parser.add_argument("arguments", nargs="?", default="{}") parser.add_argument("arguments", nargs="?", default="{}")
parser.add_argument("--config", default=str(ROOT / "mcp.all.json")) parser.add_argument("--config", default=str(ROOT / "mcp.all.json"))
parser.add_argument("--source-root", default=str(ROOT), help="被验收包所在 worktree 根目录")
parser.add_argument("--evidence", default=str(ROOT / "artifacts/mcp-evidence.jsonl")) parser.add_argument("--evidence", default=str(ROOT / "artifacts/mcp-evidence.jsonl"))
opts = parser.parse_args() opts = parser.parse_args()
session = McpSession(opts.group, opts.config, opts.evidence, opts.source_root) session = McpSession(opts.group, opts.config, opts.evidence)
try: try:
if opts.tool == "tools/list": if opts.tool == "tools/list":
response = session.rpc("tools/list") response = session.rpc("tools/list")

Some files were not shown because too many files have changed in this diff Show more