mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 03:20:45 +08:00
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:
parent
918ece86c3
commit
52679a7cfc
150 changed files with 2367 additions and 2812 deletions
31
README.md
31
README.md
|
|
@ -9,7 +9,7 @@ Licensed under the [MIT License](LICENSE). Security reports: [SECURITY.md](SECUR
|
|||
- Import ZTE Alarm Monitor Excel
|
||||
- Normalize and store alarms into an isolated PostgreSQL
|
||||
- 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`)
|
||||
- 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`.
|
||||
|
||||
### 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)**
|
||||
**拓扑画布独立 MCP** 见:[docs/MCP_TOPOLOGY.md](docs/MCP_TOPOLOGY.md)
|
||||
**完整说明见:[docs/MCP.md](docs/MCP.md)**(topology / bizMonitor 分组见同目录文档)。配置示例:[mcp.all.json](mcp.all.json)、[mcp.json](mcp.json)。
|
||||
|
||||
**割接 / 业务监控独立 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>"
|
||||
|
||||
速查:
|
||||
|
||||
```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
|
||||
# 只要 ops / topology / bizMonitor 之一:
|
||||
# http://127.0.0.1:8890/mcp?groups=ops
|
||||
```
|
||||
|
||||
- 客户端配置:[`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
|
||||
|
||||
- `POST /v1/alarms/import` (legacy import path, kept for compatibility)
|
||||
|
|
|
|||
263
docs/MCP.md
263
docs/MCP.md
|
|
@ -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(进程内工具)
|
||||
```
|
||||
|
||||
| 组件 | 部署位置 | 说明 |
|
||||
|------|----------|------|
|
||||
| **netx API** | 本机或远端服务器 | `python -m netx_api.main`,默认 `http://127.0.0.1:8890` |
|
||||
| **netx-mcp** | 与 MCP 宿主同机 | `pip install` 轻量包,仅需 Python + httpx |
|
||||
工具分组(查询参数 `groups`):
|
||||
|
||||
---
|
||||
| groups | 工具数 | 说明 |
|
||||
|--------|--------|------|
|
||||
| `ops` | 15 | 告警 / 网元 / 只读 CLI |
|
||||
| `topology` | 16 | 拓扑树 / 画布 / Fabric / 布局 |
|
||||
| `bizMonitor` | 8 | 割接与业务状态只读 |
|
||||
| (省略) | 39 | 当前 token 有权限的全部工具 |
|
||||
|
||||
## 前置条件
|
||||
示例:`/mcp?groups=ops,bizMonitor`
|
||||
|
||||
1. **netx API 已启动**且可访问:
|
||||
## 鉴权
|
||||
|
||||
```powershell
|
||||
curl http://127.0.0.1:8890/health
|
||||
```
|
||||
- Header:`Authorization: Bearer <token>`
|
||||
- 接受 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 示例:
|
||||
|
||||
---
|
||||
|
||||
## 1. 安装 `netx-mcp` 包
|
||||
|
||||
在 **运行 MCP 的那台机器**上执行(不必安装整套 netx 服务依赖):
|
||||
|
||||
```powershell
|
||||
cd D:\project\chatgpt\netx
|
||||
pip install -e ./packages/netx-mcp
|
||||
```bash
|
||||
claude mcp add --transport http netx http://127.0.0.1:8890/mcp \
|
||||
--header "Authorization: Bearer <token>"
|
||||
```
|
||||
|
||||
验证(**必须用 oclaw 启动时会调用的同一个 `python`**,见下方说明):
|
||||
仅 ops:
|
||||
|
||||
```powershell
|
||||
where python
|
||||
python -c "import netx_mcp; print('ok')"
|
||||
python -m netx_mcp
|
||||
```text
|
||||
http://127.0.0.1:8890/mcp?groups=ops
|
||||
```
|
||||
|
||||
若你在 A 终端里 `import netx_mcp` 成功,但 oclaw Admin 安装/Health 无反应或失败,多半是 **pip 装到了另一个 Python**。请用 oclaw 实际用的解释器安装:
|
||||
## 语言
|
||||
|
||||
```powershell
|
||||
# 把路径换成 where python 的第一条,或 oclaw 服务 venv 里的 python.exe
|
||||
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')"
|
||||
```
|
||||
- 环境变量 `NETX_LANG=zh|en`,或请求头 `Accept-Language`
|
||||
- 工具 `description` / `inputSchema` 与历史 Python 包一致;`en` 时 API 查询附加 `lang=en`
|
||||
|
||||
从 GitHub 安装(无本地仓库时,**子目录必须是 `packages/netx-mcp`**):
|
||||
## 安全
|
||||
|
||||
```powershell
|
||||
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp"
|
||||
```
|
||||
- `execManagedNe` 等写工具带 `annotations.readOnlyHint=false`
|
||||
- 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 配置:
|
||||
|
||||
```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) | 子包速查 |
|
||||
- [MCP_TOPOLOGY.md](./MCP_TOPOLOGY.md) — topology 组工具说明
|
||||
- [MCP_BIZMONITOR.md](./MCP_BIZMONITOR.md) — bizMonitor 组工具说明
|
||||
|
|
|
|||
|
|
@ -1,41 +1,9 @@
|
|||
# 割接与业务监控 MCP
|
||||
# netx MCP · bizMonitor 组
|
||||
|
||||
`netx-bizmonitor-mcp` 独立提供 bizMonitor 的 8 个只读工具。不安装 netxops 也能读取已有项目、批次、看板、红票、差异和采集原文。它只依赖 Python 3.11+ 与 httpx,通过 HTTP 使用正在运行的 netx。
|
||||
割接与业务状态只读工具(8 个),通过同一远程端点提供:
|
||||
|
||||
## 三组一起配置
|
||||
|
||||
在运行 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
|
||||
```text
|
||||
http://127.0.0.1:8890/mcp?groups=bizMonitor
|
||||
```
|
||||
|
||||
将根 [mcp.all.json](../mcp.all.json) 的三个 server 放入客户端 MCP 配置。也可以不安装包,分别将该包的 `src` 绝对路径设为对应 server 的 `PYTHONPATH`。三个模块分别为 `netx_mcp`、`netx_topology_mcp`、`netx_bizmonitor_mcp`;console scripts 属于各独立包,安装 API 本体不会提供一个缺少依赖的 MCP 假入口。
|
||||
|
||||
统一设置 `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 验证读取链路。样例不代表现场故障;不要将样例库替换应用库。脚本拒绝覆盖、外部路径、网络连接和监听端口。
|
||||
鉴权与总览见 [MCP.md](./MCP.md)。需要 `biz-monitor:read`。全部工具 `readOnlyHint=true`。
|
||||
|
|
|
|||
|
|
@ -1,141 +1,11 @@
|
|||
# netx Topology MCP — 安装与更新
|
||||
# netx MCP · topology 组
|
||||
|
||||
与告警/CLI 的 [`netx-mcp`](./MCP.md) **分开**的 stdio MCP,只提供 **拓扑树、画布(views)、Fabric 边/邻接**。Agent 可只装本包,或与 `netx` 并存。
|
||||
拓扑树、画布(views)、Fabric 与布局工具,通过同一远程端点提供:
|
||||
|
||||
```
|
||||
MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → /v1/topology/*
|
||||
```text
|
||||
http://127.0.0.1:8890/mcp?groups=topology
|
||||
```
|
||||
|
||||
| 组件 | 说明 |
|
||||
|------|------|
|
||||
| **netx API** | 需已启动,拓扑数据在服务端 |
|
||||
| **netx-topology-mcp** | 轻量 HTTP 客户端,与宿主同机 |
|
||||
鉴权与总览见 [MCP.md](./MCP.md)。需要 `ne:read` / `ne:write` 等 scope(写工具含新建文件夹、改节点、布局写入等)。
|
||||
|
||||
---
|
||||
|
||||
## 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。
|
||||
布局算法在 netx 进程内执行(原 `netx-topology-mcp` 逻辑已迁入 `netx_api/mcp_remote/topology/`)。异步布局 job 仍返回 job id 后轮询。
|
||||
|
|
|
|||
36
mcp.all.json
36
mcp.all.json
|
|
@ -1,33 +1,27 @@
|
|||
{
|
||||
"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"
|
||||
"url": "http://127.0.0.1:8890/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||
}
|
||||
},
|
||||
"netx-ops": {
|
||||
"url": "http://127.0.0.1:8890/mcp?groups=ops",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||
}
|
||||
},
|
||||
"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"
|
||||
"url": "http://127.0.0.1:8890/mcp?groups=topology",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||
}
|
||||
},
|
||||
"netx-bizmonitor": {
|
||||
"command": "python",
|
||||
"args": ["-m", "netx_bizmonitor_mcp"],
|
||||
"env": {
|
||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
||||
"NETX_LANG": "zh",
|
||||
"PYTHONIOENCODING": "utf-8",
|
||||
"PYTHONUTF8": "1"
|
||||
"url": "http://127.0.0.1:8890/mcp?groups=bizMonitor",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
20
mcp.json
20
mcp.json
|
|
@ -1,23 +1,9 @@
|
|||
{
|
||||
"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-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"
|
||||
"url": "http://127.0.0.1:8890/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
{
|
||||
"source_type": "local",
|
||||
"source_ref": "netx-mcp",
|
||||
"source_type": "remote",
|
||||
"source_ref": "netx-remote-mcp",
|
||||
"server_id": "netx",
|
||||
"version": "0.2.0",
|
||||
"entry_command": "python",
|
||||
"entry_args": ["-m", "netx_mcp"],
|
||||
"version": "0.4.19",
|
||||
"transport": "http",
|
||||
"url": "http://127.0.0.1:8890/mcp",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||
},
|
||||
"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": {
|
||||
"type": "string",
|
||||
"description": "Optional Bearer token when netx API auth is enabled"
|
||||
"description": "Bearer token (MCP token file or user JWT)"
|
||||
},
|
||||
"NETX_LANG": {
|
||||
"type": "string",
|
||||
|
|
|
|||
|
|
@ -1,19 +1,17 @@
|
|||
{
|
||||
"source_type": "local",
|
||||
"source_ref": "netx-topology-mcp",
|
||||
"source_type": "remote",
|
||||
"source_ref": "netx-remote-mcp",
|
||||
"server_id": "netx-topology",
|
||||
"version": "0.1.0",
|
||||
"entry_command": "python",
|
||||
"entry_args": ["-m", "netx_topology_mcp"],
|
||||
"version": "0.4.19",
|
||||
"transport": "http",
|
||||
"url": "http://127.0.0.1:8890/mcp?groups=topology",
|
||||
"headers": {
|
||||
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||
},
|
||||
"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": {
|
||||
"type": "string",
|
||||
"description": "Optional Bearer token when netx API auth is enabled"
|
||||
"description": "Bearer token (MCP token file or user JWT)"
|
||||
},
|
||||
"NETX_LANG": {
|
||||
"type": "string",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
import logging
|
||||
import time
|
||||
from typing import Callable
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
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_scopes import has_scope, required_scope_for_request
|
||||
|
|
@ -35,11 +34,12 @@ _PUBLIC_PREFIXES = (
|
|||
"/assets",
|
||||
)
|
||||
|
||||
# Authenticated API / infra only. Everything else (SPA routes, favicon, …) is public.
|
||||
|
||||
def _is_api_auth_path(path: str) -> bool:
|
||||
p = str(path or "")
|
||||
if p.startswith("/v1/"):
|
||||
return True
|
||||
# /mcp has its own Bearer gate (mcp_remote.mount.McpAuthMiddleware).
|
||||
if p in ("/metrics", "/metrics/json", "/v1/metrics/json", "/openapi.json"):
|
||||
return True
|
||||
if p.startswith("/docs") or p.startswith("/redoc"):
|
||||
|
|
@ -47,7 +47,6 @@ def _is_api_auth_path(path: str) -> bool:
|
|||
return False
|
||||
|
||||
|
||||
# While must_change_password is true, only these authenticated endpoints are allowed.
|
||||
_PASSWORD_CHANGE_ALLOW = frozenset(
|
||||
{
|
||||
("GET", "/v1/auth/me"),
|
||||
|
|
@ -74,12 +73,16 @@ def _is_public(path: str) -> bool:
|
|||
return True
|
||||
if any(p.startswith(pref) for pref in _PUBLIC_PREFIXES):
|
||||
return True
|
||||
# Bundled UI routes (/login, /topology, …) must load without a token.
|
||||
if not _is_api_auth_path(p):
|
||||
return True
|
||||
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:
|
||||
from .client_ip import resolve_client_ip
|
||||
|
||||
|
|
@ -104,18 +107,37 @@ def _action_for(method: str, path: str) -> str:
|
|||
return f"http.{m.lower()}"
|
||||
|
||||
|
||||
class AuthAuditMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
||||
if request.method.upper() == "OPTIONS":
|
||||
return await call_next(request)
|
||||
class AuthAuditMiddleware:
|
||||
"""Pure ASGI auth + audit. BaseHTTPMiddleware breaks Streamable HTTP under Mount."""
|
||||
|
||||
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
|
||||
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"):
|
||||
return await call_next(request)
|
||||
await self.app(scope, receive, send)
|
||||
return
|
||||
|
||||
token = ""
|
||||
auth = str(request.headers.get("authorization") or "").strip()
|
||||
|
|
@ -125,26 +147,27 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
|||
from .auth_cookies import read_access_cookie
|
||||
|
||||
token = read_access_cookie(request)
|
||||
# Query-string access_token is no longer accepted (leaks via logs/proxies).
|
||||
|
||||
db = SessionLocal()
|
||||
try:
|
||||
resolved = resolve_user_from_token(db, token) if token else None
|
||||
if resolved is None:
|
||||
# Do not audit anonymous 401s — SPA fan-out floods the log.
|
||||
# Intentional auth ops (login/logout/login_failed) are written in auth_router.
|
||||
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
|
||||
await JSONResponse(status_code=401, content={"detail": "unauthorized"})(
|
||||
scope, receive, send
|
||||
)
|
||||
return
|
||||
user, via, scopes, token_id, jti = resolved
|
||||
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:
|
||||
return JSONResponse(
|
||||
await JSONResponse(
|
||||
status_code=403,
|
||||
content={"detail": "password_change_required"},
|
||||
)
|
||||
need = required_scope_for_request(request.method, path)
|
||||
)(scope, receive, send)
|
||||
return
|
||||
need = required_scope_for_request(method, path)
|
||||
if need and not has_scope(scopes, need):
|
||||
return JSONResponse(
|
||||
await JSONResponse(
|
||||
status_code=403,
|
||||
content={
|
||||
"detail": {
|
||||
|
|
@ -153,7 +176,8 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
|||
"granted": sorted(scopes),
|
||||
}
|
||||
},
|
||||
)
|
||||
)(scope, receive, send)
|
||||
return
|
||||
request.state.auth_user = user
|
||||
request.state.auth_via = via
|
||||
request.state.auth_scopes = scopes
|
||||
|
|
@ -164,22 +188,33 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
|||
auth_via = via
|
||||
except Exception:
|
||||
_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:
|
||||
db.close()
|
||||
|
||||
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:
|
||||
from .audit_async import enqueue_audit
|
||||
|
||||
enqueue_audit(
|
||||
action=_action_for(request.method, path),
|
||||
action=_action_for(method, path),
|
||||
actor_user_id=actor_id,
|
||||
actor_username=actor_name,
|
||||
method=request.method,
|
||||
method=method,
|
||||
path=path,
|
||||
status_code=int(response.status_code),
|
||||
status_code=int(status_code or 0),
|
||||
client_ip=_client_ip(request),
|
||||
user_agent=str(request.headers.get("user-agent") or "")[:512],
|
||||
detail={
|
||||
|
|
@ -189,4 +224,3 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
|||
)
|
||||
except Exception:
|
||||
_log.exception("audit enqueue after request failed path=%s", path)
|
||||
return response
|
||||
|
|
|
|||
|
|
@ -218,6 +218,11 @@ class Settings(BaseSettings):
|
|||
bootstrap_admin_password: str = "admin123"
|
||||
# Written on first boot for MCP; path relative to cwd / absolute
|
||||
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.
|
||||
auth_api_token_max_count: int = 20
|
||||
# Comma-separated peers allowed to supply X-Forwarded-For / X-Real-IP
|
||||
|
|
|
|||
|
|
@ -52,13 +52,17 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
|||
from .app_startup import run_api_startup
|
||||
from .app_shutdown import shutdown_runtime
|
||||
from .dsh_alarm_hub import bind_event_loop
|
||||
from .mcp_remote.mount import mcp_lifespan_cm
|
||||
|
||||
run_api_startup()
|
||||
bind_event_loop(asyncio.get_running_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
shutdown_runtime(reason="api_lifespan")
|
||||
# Pass the live FastAPI app so /mcp ASGI is rebound to a fresh session manager
|
||||
# (TestClient cycles + tests that clear mcp context must not leave a stale app).
|
||||
async with mcp_lifespan_cm(host_app=_app):
|
||||
run_api_startup()
|
||||
bind_event_loop(asyncio.get_running_loop())
|
||||
try:
|
||||
yield
|
||||
finally:
|
||||
shutdown_runtime(reason="api_lifespan")
|
||||
|
||||
|
||||
app = FastAPI(
|
||||
|
|
@ -95,8 +99,11 @@ def health() -> dict[str, str]:
|
|||
return {"status": "ok"}
|
||||
|
||||
|
||||
from .mcp_remote.mount import mount_mcp # noqa: E402
|
||||
from .ui_static import mount_ui_if_present # noqa: E402
|
||||
|
||||
mount_mcp(app)
|
||||
|
||||
_UI_MOUNTED = mount_ui_if_present(app)
|
||||
if not _UI_MOUNTED:
|
||||
|
||||
|
|
@ -109,6 +116,7 @@ if not _UI_MOUNTED:
|
|||
"frontend_url": settings.frontend_url,
|
||||
"api_health": "/health",
|
||||
"api_status": "/v1/integrations/status",
|
||||
"mcp": "/mcp",
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
1
netx_api/mcp_remote/__init__.py
Normal file
1
netx_api/mcp_remote/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
"""Built-in remote MCP (Streamable HTTP) for netx."""
|
||||
285
netx_api/mcp_remote/asgi_client.py
Normal file
285
netx_api/mcp_remote/asgi_client.py
Normal 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)
|
||||
|
|
@ -6,7 +6,7 @@ import math
|
|||
from typing import Any
|
||||
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):
|
||||
52
netx_api/mcp_remote/context.py
Normal file
52
netx_api/mcp_remote/context.py
Normal 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
|
||||
316
netx_api/mcp_remote/mount.py
Normal file
316
netx_api/mcp_remote/mount.py
Normal 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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
@ -10,7 +10,7 @@ import os
|
|||
from typing import Any, Callable
|
||||
|
||||
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_DEFAULT = 5
|
||||
146
netx_api/mcp_remote/registry.py
Normal file
146
netx_api/mcp_remote/registry.py
Normal 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)
|
||||
116
netx_api/mcp_remote/server.py
Normal file
116
netx_api/mcp_remote/server.py
Normal 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()
|
||||
|
|
@ -5,18 +5,18 @@ from __future__ import annotations
|
|||
import sys
|
||||
from typing import Any, Callable
|
||||
|
||||
from netx_topology_mcp.http_client import http_json, http_json_many, mcp_from_handler_result
|
||||
from netx_topology_mcp.layout_jobs import (
|
||||
from netx_api.mcp_remote.asgi_client import http_json, http_json_many, mcp_from_handler_result
|
||||
from netx_api.mcp_remote.topology.layout_jobs import (
|
||||
cancel_job,
|
||||
job_public,
|
||||
raise_if_cancelled,
|
||||
report_progress,
|
||||
start_job,
|
||||
)
|
||||
from netx_topology_mcp.layout_sight import build_sight
|
||||
from netx_topology_mcp.layout_stats import analyze_layout_stats
|
||||
from netx_topology_mcp.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_sight import build_sight
|
||||
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
|
||||
from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
|
||||
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]:
|
||||
|
|
@ -632,7 +632,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
|||
|
||||
# compose_views / compose_orbit: load staging canvases, inject blocks.
|
||||
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)
|
||||
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",
|
||||
"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)
|
||||
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]:
|
||||
"""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,
|
||||
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(
|
||||
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]:
|
||||
"""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_to_reference,
|
||||
)
|
||||
from netx_topology_mcp.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_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
|
||||
|
||||
view_id = str(args.get("view_id") or "").strip()
|
||||
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
|
||||
polish still via layoutTopologyView on the sink.
|
||||
"""
|
||||
from netx_topology_mcp.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_topology_mcp.layout_ops.sink_dual_units import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.dual_units import find_dual_portal_units
|
||||
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import (
|
||||
batch_node_ids,
|
||||
layout_and_pack_batch,
|
||||
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")},
|
||||
}
|
||||
|
||||
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,
|
||||
park_positions,
|
||||
)
|
||||
|
|
@ -10,14 +10,14 @@ from pathlib import Path
|
|||
|
||||
|
||||
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)
|
||||
args = parser.parse_args(argv)
|
||||
job_id = str(args.job_id).strip()
|
||||
if not job_id:
|
||||
return 2
|
||||
|
||||
from netx_topology_mcp.layout_jobs import (
|
||||
from netx_api.mcp_remote.topology.layout_jobs import (
|
||||
_JobCancelled,
|
||||
_args_path,
|
||||
_read_job_disk,
|
||||
|
|
@ -68,7 +68,7 @@ def main(argv: list[str] | None = None) -> int:
|
|||
raise _JobCancelled()
|
||||
|
||||
# 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)
|
||||
finish_job(job_id, result=result, cancelled=is_cancelled())
|
||||
|
|
@ -42,7 +42,7 @@ def _job_dir() -> Path:
|
|||
if env:
|
||||
p = Path(env)
|
||||
else:
|
||||
# packages/netx-topology-mcp/src/netx_topology_mcp → repo data/
|
||||
# netx_api/mcp_remote/topology → repo data/
|
||||
here = Path(__file__).resolve()
|
||||
repo = here.parents[4] # .../netx
|
||||
p = repo / "data" / "runtime" / "layout_jobs"
|
||||
|
|
@ -407,7 +407,7 @@ def start_job(
|
|||
|
||||
try:
|
||||
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,
|
||||
stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL,
|
||||
|
|
@ -12,8 +12,8 @@ Scoped / block ops:
|
|||
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_topology_mcp.layout_ops.partition import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_api.mcp_remote.topology.layout_ops.partition import (
|
||||
igraph_available,
|
||||
pack_block_centers,
|
||||
pack_soft_blocks,
|
||||
|
|
@ -21,7 +21,7 @@ from netx_topology_mcp.layout_ops.partition import (
|
|||
partition_soft_blocks,
|
||||
resolve_block_mode,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.recipe import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.recipe import (
|
||||
RECIPES,
|
||||
REGISTRY,
|
||||
agg_rings_v1_passes,
|
||||
|
|
@ -31,19 +31,19 @@ from netx_topology_mcp.layout_ops.recipe import (
|
|||
smd_corridor_unstick_v1_passes,
|
||||
smd_corridor_v1_passes,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.rings import build_ring_skeleton
|
||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
|
||||
from netx_topology_mcp.layout_ops.scope import list_blocks, select_scope
|
||||
from netx_topology_mcp.layout_ops.score import score_state
|
||||
from netx_topology_mcp.layout_ops.compose_views import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.rings import build_ring_skeleton
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
|
||||
from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks, select_scope
|
||||
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||
from netx_api.mcp_remote.topology.layout_ops.compose_views import (
|
||||
compose_into_state,
|
||||
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,
|
||||
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__ = [
|
||||
"LayoutParams",
|
||||
|
|
@ -14,10 +14,10 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.graph_util import bbox
|
||||
from netx_topology_mcp.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_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
|
||||
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
|
||||
def _dist(a: tuple[float, float], b: tuple[float, float]) -> float:
|
||||
|
|
@ -26,18 +26,18 @@ import math
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
count_edge_crossings,
|
||||
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,
|
||||
_far_field_guides,
|
||||
_polar_grid,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.ring_faces import extract_ring_faces
|
||||
from netx_topology_mcp.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_ops.ring_faces import extract_ring_faces
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
|
||||
|
||||
# Compact-first: tight packs before full-length (fits crowded pockets).
|
||||
# Floor must clear icon+caption; smaller steps cause member self-overlap → apply reject.
|
||||
|
|
@ -266,11 +266,11 @@ def _bundle_ok(
|
|||
nn_floor: float,
|
||||
) -> bool:
|
||||
"""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,
|
||||
_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.
|
||||
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).
|
||||
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):
|
||||
return OpResult(
|
||||
|
|
@ -697,7 +697,7 @@ def rank_bundles_by_hotspots(
|
|||
"""Prefer bundles whose tip / members participate in crossings."""
|
||||
if not bundles:
|
||||
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]
|
||||
hot = {
|
||||
|
|
@ -6,15 +6,15 @@ nodes sit on deg≤2 corridors.
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.channels import extract_channels
|
||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_topology_mcp.layout_ops.pin_beam import pin_beam_rigid
|
||||
from netx_topology_mcp.layout_ops.ring_faces import eject_intruders, extract_ring_faces
|
||||
from netx_topology_mcp.layout_ops.skeleton import build_skeleton
|
||||
from netx_topology_mcp.layout_ops.soft_petals import soft_petals_greedy
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_topology_mcp.layout_ops.transforms import normalize_origin
|
||||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.channels import extract_channels
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_api.mcp_remote.topology.layout_ops.pin_beam import pin_beam_rigid
|
||||
from netx_api.mcp_remote.topology.layout_ops.ring_faces import eject_intruders, extract_ring_faces
|
||||
from netx_api.mcp_remote.topology.layout_ops.skeleton import build_skeleton
|
||||
from netx_api.mcp_remote.topology.layout_ops.soft_petals import soft_petals_greedy
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
|
||||
|
||||
|
||||
def build_channel_metro_skeleton(
|
||||
|
|
@ -10,8 +10,8 @@ import math
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
||||
from netx_topology_mcp.layout_topology_quality import extract_chain_paths
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||
from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
|
||||
|
||||
|
||||
def order_stubs_crossing_aware(
|
||||
|
|
@ -244,9 +244,9 @@ def straighten_channels_greedy(
|
|||
Prefer fewer global crossings; when crossings stay equal (incl. already 0),
|
||||
accept H/V / L modes that raise path axis credit (metro look).
|
||||
"""
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, OpResult
|
||||
|
||||
params = params or LayoutParams()
|
||||
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))):
|
||||
raise_if_cancelled()
|
||||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
EDGE_CLEARANCE_THR,
|
||||
REC_CENTER_DX,
|
||||
REC_CENTER_DY,
|
||||
|
|
@ -19,7 +19,7 @@ from netx_topology_mcp.layout_metrics import (
|
|||
node_footprint,
|
||||
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
|
||||
|
||||
|
|
@ -12,13 +12,13 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
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_topology_mcp.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.graph_util import bbox
|
||||
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
|
||||
def _anchor(
|
||||
|
|
@ -17,11 +17,11 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
count_edge_crossings,
|
||||
segments_properly_intersect,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.compose_views import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.compose_views import (
|
||||
ComposeBlock,
|
||||
_membership_counts,
|
||||
_portal_grow_order,
|
||||
|
|
@ -30,7 +30,7 @@ from netx_topology_mcp.layout_ops.compose_views import (
|
|||
_rigid_align_to_world,
|
||||
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_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["compose_views"] = meta
|
||||
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,
|
||||
build_mass_field,
|
||||
)
|
||||
|
|
@ -17,8 +17,8 @@ from collections import defaultdict
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
DEFAULT_SLOT_PAD = 600.0
|
||||
|
||||
|
|
@ -733,7 +733,7 @@ def compose_into_state(
|
|||
out.meta = dict(out.meta or {})
|
||||
out.meta["compose_views"] = meta
|
||||
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,
|
||||
build_mass_field,
|
||||
)
|
||||
|
|
@ -10,23 +10,23 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
count_edge_crossings,
|
||||
crossings_involving_node,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
|
||||
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
|
||||
_diversify_top,
|
||||
_eval_candidate,
|
||||
_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,
|
||||
groups_from_compose_meta,
|
||||
shrink_long_corridors,
|
||||
)
|
||||
from netx_topology_mcp.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.score import score_state
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
_MAX_PULL = 900.0
|
||||
_MAX_PULL_CAP = 2200.0
|
||||
|
|
@ -17,22 +17,22 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.compose_orbit import orbit_pack_blocks
|
||||
from netx_topology_mcp.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_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.compose_orbit import orbit_pack_blocks
|
||||
from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock, _rigid_align_to_world
|
||||
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
|
||||
DualUnit,
|
||||
beautify_dual_unit_positions,
|
||||
classify_dual_unit,
|
||||
find_dual_portal_units,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_topology_mcp.layout_ops.mass_field import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
|
||||
attach_mass_to_compose_meta,
|
||||
build_mass_field,
|
||||
)
|
||||
from netx_topology_mcp.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.mass_merge import mass_merge_round
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
|
||||
def _unit_links(
|
||||
|
|
@ -17,11 +17,11 @@ from collections import deque
|
|||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_topology_mcp.layout_ops.min_rings import cover_hub_paths
|
||||
from netx_topology_mcp.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_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_api.mcp_remote.topology.layout_ops.min_rings import cover_hub_paths
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.sugiyama import _find_two_portal_ring_groups
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
@ -756,7 +756,7 @@ def _uncross_unit(
|
|||
preserve_side: bool = True,
|
||||
) -> dict[str, tuple[float, float]]:
|
||||
"""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)
|
||||
deg: dict[str, int] = {}
|
||||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||
import math
|
||||
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]:
|
||||
|
|
@ -21,13 +21,13 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
|
||||
from netx_topology_mcp.layout_ops.rigid_units import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
|
||||
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
|
||||
frozen_ids_for_protect,
|
||||
groups_from_compose_meta,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.score import score_state
|
||||
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
||||
|
||||
_COORD_ABS_MAX = 1.0e6
|
||||
_DEFAULT_ITERS = 12
|
||||
|
|
@ -299,7 +299,7 @@ def force_densify_round(
|
|||
damping: float = 0.85,
|
||||
) -> OpResult:
|
||||
"""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,
|
||||
report_progress,
|
||||
touch_heartbeat,
|
||||
|
|
@ -384,7 +384,7 @@ def force_densify_round(
|
|||
best_meta_util = util_proxy0
|
||||
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):
|
||||
raise_if_cancelled()
|
||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||
|
||||
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__ = [
|
||||
"infer_layer",
|
||||
|
|
@ -147,9 +147,9 @@ def spine_backbone(
|
|||
def build_state_from_nodes_edges(
|
||||
nodes: list[dict], edges: list[dict]
|
||||
) -> "LayoutState": # noqa: F821
|
||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
||||
from netx_topology_mcp.layout_metrics import collapse_links
|
||||
from netx_topology_mcp.layout_ops.level_util import _parse_level
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||
from netx_api.mcp_remote.topology.layout_metrics import collapse_links
|
||||
from netx_api.mcp_remote.topology.layout_ops.level_util import _parse_level
|
||||
|
||||
names: dict[str, str] = {}
|
||||
layers: dict[str, str] = {}
|
||||
|
|
@ -16,10 +16,10 @@ import math
|
|||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings, segments_properly_intersect
|
||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_topology_mcp.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_metrics import count_edge_crossings, segments_properly_intersect
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_api.mcp_remote.topology.layout_ops.partition import pick_hub_seeds
|
||||
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:
|
||||
|
|
@ -7,10 +7,10 @@ from collections import defaultdict
|
|||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint
|
||||
from netx_topology_mcp.layout_ops.scope import select_scope
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_topology_mcp.layout_ops.transforms import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint
|
||||
from netx_api.mcp_remote.topology.layout_ops.scope import select_scope
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.transforms import (
|
||||
explode_clusters,
|
||||
pack_utilization,
|
||||
resolve_overlaps,
|
||||
|
|
@ -119,7 +119,7 @@ def sprawled_component_scopes(
|
|||
max_size: int = 80,
|
||||
) -> list[set[str]]:
|
||||
"""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)
|
||||
out: list[set[str]] = []
|
||||
|
|
@ -197,7 +197,7 @@ def fix_overlaps_local(
|
|||
state: LayoutState, params: LayoutParams | None = None
|
||||
) -> OpResult:
|
||||
"""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()
|
||||
st = state.copy()
|
||||
|
|
@ -276,7 +276,7 @@ def fix_overlaps_local(
|
|||
|
||||
|
||||
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:
|
||||
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
|
||||
)
|
||||
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]
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ import math
|
|||
import re
|
||||
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 = {
|
||||
|
|
@ -9,8 +9,8 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.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.dual_units import DualUnit, find_dual_portal_units
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||
|
||||
# role -> (attract, repulse, mass)
|
||||
_ROLE_NODE: dict[str, tuple[float, float, float]] = {
|
||||
|
|
@ -15,13 +15,13 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.force_densify import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.force_densify import (
|
||||
_MAX_EDGE_PULL,
|
||||
_bbox_area,
|
||||
_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,
|
||||
capture_pass,
|
||||
core_centroid,
|
||||
|
|
@ -30,9 +30,9 @@ from netx_topology_mcp.layout_ops.mass_field import (
|
|||
group_effective_mass,
|
||||
groups_from_mass_or_rigid,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect
|
||||
from netx_topology_mcp.layout_ops.score import score_state
|
||||
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect
|
||||
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
||||
|
||||
_COORD_ABS_MAX = 1.0e6
|
||||
_STEP_CAP = {
|
||||
|
|
@ -599,7 +599,7 @@ def mass_merge_round(
|
|||
Islands = dual-unit groups; soft global gravity keeps the **cloud of
|
||||
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,
|
||||
report_progress,
|
||||
touch_heartbeat,
|
||||
|
|
@ -12,8 +12,8 @@ import math
|
|||
from collections import defaultdict, deque
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
|
||||
def _all_simple_paths(
|
||||
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
count_edge_crossings,
|
||||
crossing_participation,
|
||||
crossing_participation_full,
|
||||
|
|
@ -20,7 +20,7 @@ from netx_topology_mcp.layout_metrics import (
|
|||
top_crossing_edges,
|
||||
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
|
||||
# Long fabric bridges on giant metros often exceed 5k; cap must leave room for
|
||||
|
|
@ -201,7 +201,7 @@ def _resolve_frozen(
|
|||
return set()
|
||||
frozen: set[str] = set(frozen_ids or ())
|
||||
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)
|
||||
return frozen
|
||||
|
|
@ -548,7 +548,7 @@ def _local_clearance_hits(
|
|||
endpoint_t: float = 0.05,
|
||||
) -> int:
|
||||
"""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:
|
||||
return 0
|
||||
|
|
@ -1188,7 +1188,7 @@ def _until_limit_queue(
|
|||
base_hits = max(hits, prev[0] if prev else 0)
|
||||
scored[nid] = (base_hits, deg, 1, prev[3] if prev else 0)
|
||||
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)
|
||||
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:
|
||||
rounds += 1
|
||||
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()
|
||||
raise_if_cancelled()
|
||||
|
|
@ -1389,12 +1389,12 @@ def orbit_sweep_until_limit(
|
|||
break
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
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()
|
||||
raise_if_cancelled()
|
||||
|
|
@ -12,16 +12,16 @@ from __future__ import annotations
|
|||
from collections import deque
|
||||
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_DY,
|
||||
count_edge_crossings,
|
||||
node_footprint,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.channels import extract_channels
|
||||
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
|
||||
from netx_topology_mcp.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.channels import extract_channels
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
|
||||
|
||||
_TOL_PX = 8.0
|
||||
|
||||
|
|
@ -39,7 +39,7 @@ def _segment_clear(
|
|||
skip: set[str] | None = None,
|
||||
) -> bool:
|
||||
"""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:
|
||||
return True
|
||||
|
|
@ -930,7 +930,7 @@ def _place_component(
|
|||
if _segment_clear(pos, a, b, thr=40.0):
|
||||
continue
|
||||
# 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]
|
||||
obstacles = []
|
||||
|
|
@ -1086,7 +1086,7 @@ def build_ortho_metro_skeleton(
|
|||
st = separate_overlaps_ortho(st, params).state
|
||||
|
||||
# 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)
|
||||
clr = clear_edge_hits(
|
||||
|
|
@ -1114,7 +1114,7 @@ def build_ortho_metro_skeleton(
|
|||
else:
|
||||
diag_n += 1
|
||||
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)
|
||||
st.meta = dict(st.meta or {})
|
||||
|
|
@ -17,8 +17,8 @@ from collections import defaultdict, deque
|
|||
from dataclasses import dataclass
|
||||
from typing import Any, Iterable
|
||||
|
||||
from netx_topology_mcp.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.graph_util import connected_components
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
# Preferred soft-block step when packing centers (px).
|
||||
_BLOCK_GAP = 420.0
|
||||
|
|
@ -437,7 +437,7 @@ def partition_report(state: LayoutState, *, mode: str = "soft") -> dict[str, Any
|
|||
"tip": (
|
||||
"Soft blocks for init only: lay each hub territory / leftover with our "
|
||||
"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."
|
||||
),
|
||||
}
|
||||
|
||||
|
|
@ -5,12 +5,12 @@ from __future__ import annotations
|
|||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_topology_mcp.layout_ops.partition import partition_soft_blocks
|
||||
from netx_topology_mcp.layout_ops.score import score_state
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_topology_mcp.layout_ops.transforms import normalize_origin
|
||||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_api.mcp_remote.topology.layout_ops.partition import partition_soft_blocks
|
||||
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
|
||||
|
||||
|
||||
def _hops_to_cores(
|
||||
|
|
@ -14,7 +14,7 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
count_edge_crossings,
|
||||
crossings_after_node_move,
|
||||
crossings_involving_node,
|
||||
|
|
@ -23,8 +23,8 @@ from netx_topology_mcp.layout_metrics import (
|
|||
segments_properly_intersect,
|
||||
top_crossing_edges,
|
||||
)
|
||||
from netx_topology_mcp.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.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.untangle import untangle_crossings
|
||||
|
||||
|
||||
def park_phantom_nodes(state: LayoutState) -> set[str]:
|
||||
|
|
@ -76,7 +76,7 @@ def _rotate_about(
|
|||
def frozen_portals_from_state(
|
||||
state: LayoutState, portal_ids: list[str] | None = None
|
||||
) -> set[str]:
|
||||
from netx_topology_mcp.layout_ops.rigid_units import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
|
||||
_hub_portals,
|
||||
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:
|
||||
"""True when a multi-corridor dual-unit covers a large share of the canvas."""
|
||||
try:
|
||||
from netx_topology_mcp.layout_ops.dual_units import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
|
||||
classify_dual_unit,
|
||||
find_dual_portal_units,
|
||||
)
|
||||
|
|
@ -202,7 +202,7 @@ def press_hot_edges(
|
|||
max_sweeps: int | None = None,
|
||||
) -> OpResult:
|
||||
"""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
|
||||
st = state.copy()
|
||||
|
|
@ -620,7 +620,7 @@ def polish_crossings(
|
|||
Petal dual-unit eyes: straighten stays off unless ``straighten=true`` —
|
||||
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()
|
||||
st = state.copy()
|
||||
|
|
@ -631,7 +631,7 @@ def polish_crossings(
|
|||
eye_portals: list[str] = []
|
||||
if preserve:
|
||||
try:
|
||||
from netx_topology_mcp.layout_ops.dual_units import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
|
||||
classify_dual_unit,
|
||||
find_dual_portal_units,
|
||||
)
|
||||
|
|
@ -685,7 +685,7 @@ def polish_crossings(
|
|||
moved: set[str] = set()
|
||||
|
||||
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()
|
||||
report_progress("polish_straighten", pct=48.0, message=f"x0={start}")
|
||||
|
|
@ -11,11 +11,11 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings, compute_edge_clearance
|
||||
from netx_topology_mcp.layout_ops.graph_util import bbox
|
||||
from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
||||
from netx_topology_mcp.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_metrics import count_edge_crossings, compute_edge_clearance
|
||||
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
|
||||
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
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]:
|
||||
|
|
@ -5,15 +5,15 @@ from __future__ import annotations
|
|||
from dataclasses import asdict
|
||||
from typing import Any, Callable
|
||||
|
||||
from netx_topology_mcp.layout_ops.score import score_op, score_state
|
||||
from netx_topology_mcp.layout_ops.scope import list_blocks, map_blocks, select_scope
|
||||
from netx_topology_mcp.layout_ops.sides import place_side_branches
|
||||
from netx_topology_mcp.layout_ops.skeleton import build_skeleton
|
||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
|
||||
from netx_topology_mcp.layout_ops.rings import build_ring_skeleton
|
||||
from netx_topology_mcp.layout_ops.partition import pack_soft_blocks
|
||||
from netx_topology_mcp.layout_ops.transforms import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.score import score_op, score_state
|
||||
from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks, map_blocks, select_scope
|
||||
from netx_api.mcp_remote.topology.layout_ops.sides import place_side_branches
|
||||
from netx_api.mcp_remote.topology.layout_ops.skeleton import build_skeleton
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
|
||||
from netx_api.mcp_remote.topology.layout_ops.rings import build_ring_skeleton
|
||||
from netx_api.mcp_remote.topology.layout_ops.partition import pack_soft_blocks
|
||||
from netx_api.mcp_remote.topology.layout_ops.transforms import (
|
||||
enforce_min_gap,
|
||||
explode_clusters,
|
||||
normalize_origin,
|
||||
|
|
@ -234,7 +234,7 @@ def _expand_foreach(
|
|||
min_size = int(kwargs.get("min_size") or 2)
|
||||
blocks = list_blocks(st, mode=mode)
|
||||
# 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
|
||||
st = st.copy()
|
||||
|
|
@ -14,12 +14,12 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.compose_orbit import crossings_touching
|
||||
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
|
||||
from netx_topology_mcp.layout_ops.rigid_units import groups_from_compose_meta
|
||||
from netx_topology_mcp.layout_ops.score import score_state
|
||||
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.compose_orbit import crossings_touching
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
|
||||
from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_compose_meta
|
||||
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
||||
|
||||
_COORD_ABS_MAX = 1.0e6
|
||||
_DEFAULT_ANGLE_STEP = 30
|
||||
|
|
@ -277,7 +277,7 @@ def rigid_orbit_round(
|
|||
max_accepts: int = 16,
|
||||
) -> OpResult:
|
||||
"""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,
|
||||
report_progress,
|
||||
touch_heartbeat,
|
||||
|
|
@ -10,9 +10,9 @@ import math
|
|||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.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_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.graph_util import connected_components
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
# Reject / ignore coords that would blow util (phantom region markers, bugs).
|
||||
_COORD_ABS_MAX = 1.0e6
|
||||
|
|
@ -9,9 +9,9 @@ import math
|
|||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import segments_properly_intersect
|
||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
||||
from netx_topology_mcp.layout_topology_quality import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||
from netx_api.mcp_remote.topology.layout_topology_quality import (
|
||||
RING_MAX_LEN,
|
||||
find_short_chordless_cycles,
|
||||
)
|
||||
|
|
@ -7,9 +7,9 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from netx_topology_mcp.layout_ops.min_rings import build_min_ring_skeleton
|
||||
from netx_topology_mcp.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.min_rings import build_min_ring_skeleton
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_ops.sugiyama import build_sugiyama_layout
|
||||
|
||||
|
||||
def build_ring_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult:
|
||||
|
|
@ -4,8 +4,8 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any, Iterable
|
||||
|
||||
from netx_topology_mcp.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.graph_util import bbox, connected_components
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
||||
|
||||
|
||||
def active_nodes(state: LayoutState) -> set[str]:
|
||||
|
|
@ -41,7 +41,7 @@ def list_blocks(
|
|||
ids = set(state.positions.keys())
|
||||
m = (mode or "component").strip().lower()
|
||||
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")
|
||||
if m == "component":
|
||||
|
|
@ -58,7 +58,7 @@ def list_blocks(
|
|||
blocks.append(rest)
|
||||
return blocks
|
||||
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,
|
||||
soft_blocks_as_sets,
|
||||
)
|
||||
|
|
@ -4,8 +4,8 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
|
||||
from netx_topology_mcp.layout_stats import analyze_layout_stats
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
||||
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
|
||||
|
||||
|
||||
def score_state(
|
||||
|
|
@ -4,8 +4,8 @@ from __future__ import annotations
|
|||
|
||||
import math
|
||||
|
||||
from netx_topology_mcp.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.graph_util import spine_backbone
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
|
||||
def place_side_branches(
|
||||
|
|
@ -7,8 +7,8 @@ from __future__ import annotations
|
|||
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.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.dual_units import DualUnit, layout_dual_unit
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState
|
||||
|
||||
|
||||
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)
|
||||
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_orphan,
|
||||
_orbit_candidates_two_portal,
|
||||
_pick_best,
|
||||
_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,
|
||||
_rigid_align_to_world,
|
||||
)
|
||||
|
|
@ -5,14 +5,14 @@ from __future__ import annotations
|
|||
from collections import defaultdict, deque
|
||||
from itertools import product
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.graph_util import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.graph_util import (
|
||||
bbox,
|
||||
connected_components,
|
||||
order_ans,
|
||||
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(
|
||||
|
|
@ -6,11 +6,11 @@ import math
|
|||
from collections import deque
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.channels import order_stubs_crossing_aware
|
||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_topology_mcp.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_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.channels import order_stubs_crossing_aware
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||
from netx_api.mcp_remote.topology.layout_ops.partition import partition_soft_blocks, pick_hub_seeds
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
|
||||
def _longest_path(
|
||||
|
|
@ -14,14 +14,14 @@ from __future__ import annotations
|
|||
import math
|
||||
from collections import deque
|
||||
|
||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
||||
from netx_topology_mcp.layout_ops.graph_util import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||
from netx_api.mcp_remote.topology.layout_ops.graph_util import (
|
||||
bbox,
|
||||
chain_order,
|
||||
connected_components,
|
||||
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(
|
||||
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],
|
||||
) -> bool:
|
||||
"""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:
|
||||
return False
|
||||
|
|
@ -1130,7 +1130,7 @@ def _foreign_edge_crossings(
|
|||
"""Crossings of unit-internal edges by foreign edges (through the unit)."""
|
||||
if len(unit_nodes) < 2 or not edges:
|
||||
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]
|
||||
foreign = [
|
||||
|
|
@ -6,15 +6,15 @@ import math
|
|||
from collections import defaultdict
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
ICON_SIZE,
|
||||
REC_CENTER_DX,
|
||||
REC_CENTER_DY,
|
||||
node_footprint,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.graph_util import bbox
|
||||
from netx_topology_mcp.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.graph_util import bbox
|
||||
from netx_api.mcp_remote.topology.layout_ops.scope import active_nodes, movable_nodes
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||
|
||||
|
||||
def select_pins(
|
||||
|
|
@ -6,14 +6,14 @@ import math
|
|||
import random
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
count_edge_crossings,
|
||||
crossing_participation,
|
||||
crossings_involving_node,
|
||||
node_footprint,
|
||||
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]):
|
||||
|
|
@ -166,13 +166,13 @@ def untangle_crossings(
|
|||
moved: set[str] = set()
|
||||
frozen: set[str] = set(frozen_ids or ())
|
||||
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)
|
||||
faces = None
|
||||
pierce0 = 0
|
||||
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,
|
||||
extract_ring_faces,
|
||||
)
|
||||
|
|
@ -182,7 +182,7 @@ def untangle_crossings(
|
|||
|
||||
focus: set[str] = {str(x) for x in (focus_ids or []) if str(x)}
|
||||
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))
|
||||
for _round in range(rounds_total):
|
||||
|
|
@ -265,7 +265,7 @@ def untangle_crossings(
|
|||
if g2 >= cur_c:
|
||||
continue
|
||||
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(
|
||||
count_ring_pierces(trial, links, faces).get("pierce_crossings")
|
||||
|
|
@ -287,7 +287,7 @@ def untangle_crossings(
|
|||
else:
|
||||
hit.pop(node, 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(
|
||||
count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0
|
||||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||
import math
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import (
|
||||
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||
collapse_links,
|
||||
segments_properly_intersect,
|
||||
top_crossing_edges,
|
||||
|
|
@ -88,7 +88,7 @@ def list_crossings(
|
|||
adj[a].add(b)
|
||||
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()}
|
||||
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),
|
||||
# suggest from edge_clearance.top and crossing top_nodes regardless of degree.
|
||||
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)
|
||||
for eh in (ec.get("top_edge_hits") or [])[:5]:
|
||||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
|||
import math
|
||||
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_DY,
|
||||
analyze_positions,
|
||||
|
|
@ -698,12 +698,12 @@ def analyze_layout_stats(
|
|||
pos[fid] = (x, y)
|
||||
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,
|
||||
compute_edge_axis,
|
||||
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_topology_quality,
|
||||
)
|
||||
|
|
@ -15,8 +15,8 @@ from collections import defaultdict, deque
|
|||
from statistics import mean, median
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_metrics import collapse_links
|
||||
from netx_topology_mcp.layout_ops.graph_util import infer_layer
|
||||
from netx_api.mcp_remote.topology.layout_metrics import collapse_links
|
||||
from netx_api.mcp_remote.topology.layout_ops.graph_util import infer_layer
|
||||
|
||||
|
||||
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}
|
||||
dual_units: dict[str, Any] = {"unit_count": 0, "units": []}
|
||||
try:
|
||||
from netx_topology_mcp.layout_ops.dual_units import dual_units_report
|
||||
from netx_topology_mcp.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.dual_units import dual_units_report
|
||||
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
|
||||
from netx_api.mcp_remote.topology.layout_ops.partition import partition_report
|
||||
|
||||
st = build_state_from_nodes_edges(nodes, edges)
|
||||
soft_mode = "soft" if advice.get("decompose_soft_blocks") else "hub_territory"
|
||||
|
|
@ -5,59 +5,59 @@ from __future__ import annotations
|
|||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from netx_topology_mcp.layout_ops import (
|
||||
from netx_api.mcp_remote.topology.layout_ops import (
|
||||
LayoutParams,
|
||||
build_state_from_nodes_edges,
|
||||
positions_for_api,
|
||||
run_recipe,
|
||||
score_state,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.hotspots import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.hotspots import (
|
||||
fix_overlaps_local,
|
||||
hotspot_scopes,
|
||||
overlapping_nodes,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.recipe import RECIPES
|
||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
||||
from netx_topology_mcp.layout_ops.transforms import normalize_origin
|
||||
from netx_topology_mcp.layout_ops.channels import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.recipe import RECIPES
|
||||
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
|
||||
from netx_api.mcp_remote.topology.layout_ops.channels import (
|
||||
straighten_channels_greedy,
|
||||
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,
|
||||
layout_dual_unit,
|
||||
)
|
||||
from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership
|
||||
from netx_topology_mcp.layout_ops.untangle import (
|
||||
from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_membership
|
||||
from netx_api.mcp_remote.topology.layout_ops.untangle import (
|
||||
untangle_crossings,
|
||||
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,
|
||||
polish_crossings,
|
||||
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_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_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_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,
|
||||
orbit_params_from_overrides,
|
||||
orbit_sweep_node,
|
||||
orbit_sweep_round,
|
||||
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,
|
||||
level_bands_params_from_overrides,
|
||||
)
|
||||
|
|
@ -385,7 +385,7 @@ def run_layout_on_graph(
|
|||
# layout; local jumps are capped inside untangle_crossings.
|
||||
st = normalize_origin(op.state, base_params).state
|
||||
# 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)
|
||||
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),
|
||||
)
|
||||
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)
|
||||
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
|
||||
# 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)
|
||||
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]:
|
||||
from netx_topology_mcp import NETX_MCP_REV, __version__
|
||||
from netx_api.mcp_remote.topology import NETX_MCP_REV, __version__
|
||||
|
||||
return {
|
||||
"version": __version__,
|
||||
|
|
@ -10,7 +10,7 @@ import math
|
|||
from collections import defaultdict, deque
|
||||
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).
|
||||
CHAIN_KINK_DEG = 40.0
|
||||
|
|
@ -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、业务与协议错误、通知、凭据来源优先级、文件错误回退、令牌轮换与脱敏及长原文不截断。
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
from .server import main
|
||||
|
||||
main()
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"))
|
||||
|
|
@ -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 仍保留。
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
from netx_mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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="")
|
||||
|
|
@ -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()
|
||||
|
|
@ -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。
|
||||
|
|
@ -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.
|
||||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"]
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
from netx_topology_mcp.server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
|
@ -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)
|
||||
|
|
@ -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()
|
||||
|
|
@ -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"
|
||||
|
|
@ -5,7 +5,7 @@ build-backend = "setuptools.build_meta"
|
|||
[project]
|
||||
name = "netx-ops"
|
||||
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"
|
||||
requires-python = ">=3.11"
|
||||
license = { text = "MIT" }
|
||||
|
|
@ -29,12 +29,11 @@ dependencies = [
|
|||
"alembic>=1.13.0",
|
||||
"psutil>=5.9.0",
|
||||
"redis>=5.0.0",
|
||||
"mcp>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
mcp = ["netx-mcp @ file:packages/netx-mcp"]
|
||||
mcp-topology = ["netx-topology-mcp @ file:packages/netx-topology-mcp"]
|
||||
mcp-bizmonitor = ["netx-bizmonitor-mcp @ file:packages/netx-bizmonitor-mcp"]
|
||||
# Remote MCP is built into netx_api (/mcp). Optional extras removed.
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
|
|
|
|||
|
|
@ -21,3 +21,4 @@ PyJWT>=2.8.0
|
|||
alembic>=1.13.0
|
||||
psutil>=5.9.0
|
||||
redis>=5.0.0
|
||||
mcp>=1.0.0
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
"""通过真实 stdio MCP 留存本地验收证据;不安装插件、不导入 handler。
|
||||
"""通过真实远程 HTTP MCP(/mcp Streamable HTTP)留存本地验收证据。
|
||||
|
||||
使用已有本机 MCP 配置中的凭据,只在子进程内存中传递,证据自动去敏。
|
||||
使用已有本机 MCP 配置中的凭据,只在进程内存中传递,证据自动去敏。
|
||||
示例:python scripts/mcp_acceptance.py ops runNmsDiagnostics '{}'
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
|
@ -10,18 +10,14 @@ import datetime as dt
|
|||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
GROUPS = {
|
||||
"ops": ("netx", "netx_mcp", "netx-mcp"),
|
||||
"topology": ("netx-topology", "netx_topology_mcp", "netx-topology-mcp"),
|
||||
"biz": ("netx-bizmonitor", "netx_bizmonitor_mcp", "netx-bizmonitor-mcp"),
|
||||
"ops": "ops",
|
||||
"topology": "topology",
|
||||
"biz": "bizMonitor",
|
||||
}
|
||||
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):
|
||||
return [redact(x) for x in value]
|
||||
if isinstance(value, str):
|
||||
# MCP 的 JSON 位于 content.text 内;递归处理,避免只净化外层。
|
||||
try:
|
||||
decoded = json.loads(value)
|
||||
except (ValueError, TypeError):
|
||||
|
|
@ -53,69 +48,100 @@ def payload(response):
|
|||
return result or response.get("error", {})
|
||||
|
||||
|
||||
class McpSession:
|
||||
def __init__(self, group, config, evidence, source_root=ROOT):
|
||||
source_root = Path(source_root).resolve()
|
||||
server_id, module, package = GROUPS[group]
|
||||
cfg = json.loads(Path(config).read_text(encoding="utf-8-sig"))
|
||||
settings = cfg["mcpServers"][server_id].get("env", {})
|
||||
env = os.environ.copy()
|
||||
env.update({k: str(v) for k, v in settings.items()})
|
||||
# 只允许本机 netx;不能由配置悄悄指向生产服务。
|
||||
actual_url = (env.get("NETX_API_URL") or env.get("OCLAW_NETX_BASE_URL")
|
||||
or "http://127.0.0.1:8890").rstrip("/")
|
||||
if actual_url not in (
|
||||
"http://127.0.0.1:8890", "http://localhost:8890",
|
||||
"http://127.0.0.1:8891", "http://localhost:8891"
|
||||
def _resolve_base(config: Path) -> tuple[str, str]:
|
||||
cfg = json.loads(Path(config).read_text(encoding="utf-8-sig"))
|
||||
servers = cfg.get("mcpServers") or {}
|
||||
# Prefer first remote URL entry; fall back to env.
|
||||
url = ""
|
||||
token = os.environ.get("NETX_API_TOKEN") or os.environ.get("NETX_MCP_TOKEN") or ""
|
||||
for _name, entry in servers.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
candidate = str(entry.get("url") or "").strip()
|
||||
if candidate.startswith("http"):
|
||||
url = candidate
|
||||
headers = entry.get("headers") or {}
|
||||
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")
|
||||
env["NETX_API_URL"] = actual_url
|
||||
env.pop("OCLAW_NETX_BASE_URL", None)
|
||||
env["PYTHONPATH"] = str(source_root / "packages" / package / "src")
|
||||
env["PYTHONUTF8"] = "1"
|
||||
self.group, self.evidence, self.seq = group, Path(evidence), 0
|
||||
self.stderr = []
|
||||
self.proc = subprocess.Popen(
|
||||
[sys.executable, "-m", module], cwd=source_root, env=env,
|
||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
||||
encoding="utf-8", bufsize=1,
|
||||
if p.is_file():
|
||||
token = p.read_text(encoding="utf-8").strip()
|
||||
if token:
|
||||
break
|
||||
if not token:
|
||||
raise ValueError("缺少 Bearer token(NETX_API_TOKEN 或 data/auth/mcp_token)")
|
||||
return base, token
|
||||
|
||||
|
||||
class McpSession:
|
||||
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):
|
||||
for line in self.proc.stdout:
|
||||
self.lines.put(line)
|
||||
def rpc(self, method, params=None):
|
||||
import time
|
||||
|
||||
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
|
||||
request = {"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params or {}}
|
||||
start = time.monotonic()
|
||||
self.proc.stdin.write(json.dumps(request, ensure_ascii=False) + "\n")
|
||||
self.proc.stdin.flush()
|
||||
deadline = start + timeout
|
||||
while True:
|
||||
try:
|
||||
line = self.lines.get(timeout=max(0.01, deadline - time.monotonic()))
|
||||
except queue.Empty as exc:
|
||||
raise TimeoutError(f"{self.group} {method} 超时;stderr={''.join(self.stderr)[-500:]}") from exc
|
||||
response = json.loads(line)
|
||||
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)}
|
||||
r = self._client.post(self.url, json=request)
|
||||
r.raise_for_status()
|
||||
response = r.json()
|
||||
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)
|
||||
with self.evidence.open("a", encoding="utf-8") as f:
|
||||
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})
|
||||
|
||||
def close(self):
|
||||
self.proc.stdin.close()
|
||||
try:
|
||||
self.proc.wait(timeout=8)
|
||||
except subprocess.TimeoutExpired:
|
||||
self.proc.terminate()
|
||||
self.proc.wait(timeout=5)
|
||||
self._client.close()
|
||||
|
||||
|
||||
def main():
|
||||
|
|
@ -146,10 +167,9 @@ def main():
|
|||
parser.add_argument("tool", help="工具名,或 tools/list")
|
||||
parser.add_argument("arguments", nargs="?", default="{}")
|
||||
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"))
|
||||
opts = parser.parse_args()
|
||||
session = McpSession(opts.group, opts.config, opts.evidence, opts.source_root)
|
||||
session = McpSession(opts.group, opts.config, opts.evidence)
|
||||
try:
|
||||
if opts.tool == "tools/list":
|
||||
response = session.rpc("tools/list")
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue