mirror of
https://github.com/hansjone/netx.git
synced 2026-10-17 06:10:58 +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
|
- Import ZTE Alarm Monitor Excel
|
||||||
- Normalize and store alarms into an isolated PostgreSQL
|
- Normalize and store alarms into an isolated PostgreSQL
|
||||||
- Query and aggregate alarms via REST API
|
- Query and aggregate alarms via REST API
|
||||||
- Expose the same read capabilities through MCP stdio tools
|
- Expose the same capabilities through built-in remote MCP (`/mcp`)
|
||||||
- Quick diagnostics endpoint (`/v1/diagnostics`)
|
- Quick diagnostics endpoint (`/v1/diagnostics`)
|
||||||
- AP analysis bridge to oclaw (`/v1/ap/analyze`)
|
- AP analysis bridge to oclaw (`/v1/ap/analyze`)
|
||||||
|
|
||||||
|
|
@ -162,32 +162,21 @@ After `npm run build` in `web/`, the API can also serve the UI at `http://127.0.
|
||||||
|
|
||||||
Fool-proof Windows delivery via `NetX-Setup-*.exe` (bundled or external Postgres, program/data split, offline upgrade over existing installs) lives under [`packaging/`](packaging/README.md). **Linux installs are unchanged** — keep using your own Postgres and `scripts/start_netx.sh`.
|
Fool-proof Windows delivery via `NetX-Setup-*.exe` (bundled or external Postgres, program/data split, offline upgrade over existing installs) lives under [`packaging/`](packaging/README.md). **Linux installs are unchanged** — keep using your own Postgres and `scripts/start_netx.sh`.
|
||||||
|
|
||||||
### 6) MCP(Cursor / oclaw / Claude)
|
### 6) MCP(Cursor / Codex / Claude / DSH)
|
||||||
|
|
||||||
先启动 netx API(§5),再在 **MCP 宿主同机** 安装轻量客户端并配置。
|
先启动 netx API(§5)。MCP 已内置在 API 同端口:`http://127.0.0.1:8890/mcp`(Streamable HTTP)。客户端只需 URL + Bearer token,**不必再装 Python MCP 包**。
|
||||||
|
|
||||||
**完整说明(安装、配置、更新、排错)见:[docs/MCP.md](docs/MCP.md)**
|
**完整说明见:[docs/MCP.md](docs/MCP.md)**(topology / bizMonitor 分组见同目录文档)。配置示例:[mcp.all.json](mcp.all.json)、[mcp.json](mcp.json)。
|
||||||
**拓扑画布独立 MCP** 见:[docs/MCP_TOPOLOGY.md](docs/MCP_TOPOLOGY.md)
|
|
||||||
|
|
||||||
**割接 / 业务监控独立 MCP** 见:[docs/MCP_BIZMONITOR.md](docs/MCP_BIZMONITOR.md)。三组一起使用时无需 netxops,完整配置为 [mcp.all.json](mcp.all.json)。
|
```bash
|
||||||
|
# Claude Code
|
||||||
|
claude mcp add --transport http netx http://127.0.0.1:8890/mcp \
|
||||||
|
--header "Authorization: Bearer <token>"
|
||||||
|
|
||||||
速查:
|
# 只要 ops / topology / bizMonitor 之一:
|
||||||
|
# http://127.0.0.1:8890/mcp?groups=ops
|
||||||
```powershell
|
|
||||||
pip install -e ./packages/netx-mcp
|
|
||||||
# 拓扑(可选,单独安装)
|
|
||||||
pip install -e ./packages/netx-topology-mcp
|
|
||||||
pip install -e ./packages/netx-bizmonitor-mcp
|
|
||||||
# 三组配置见 mcp.all.json,分别运行:
|
|
||||||
python -m netx_mcp
|
|
||||||
python -m netx_topology_mcp
|
|
||||||
python -m netx_bizmonitor_mcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
- 客户端配置:[`mcp.json`](mcp.json)(含 `netx` + 可选 `netx-topology`)
|
|
||||||
- oclaw payload:[`mcp_install_payload.json`](mcp_install_payload.json) / [`mcp_topology_install_payload.json`](mcp_topology_install_payload.json)
|
|
||||||
- 子包:[`packages/netx-mcp`](packages/netx-mcp/README.md)、[`packages/netx-topology-mcp`](packages/netx-topology-mcp/README.md)、[`packages/netx-bizmonitor-mcp`](packages/netx-bizmonitor-mcp/README.md)
|
|
||||||
|
|
||||||
## Useful API endpoints
|
## Useful API endpoints
|
||||||
|
|
||||||
- `POST /v1/alarms/import` (legacy import path, kept for compatibility)
|
- `POST /v1/alarms/import` (legacy import path, kept for compatibility)
|
||||||
|
|
|
||||||
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(进程内工具)
|
||||||
```
|
```
|
||||||
|
|
||||||
| 组件 | 部署位置 | 说明 |
|
工具分组(查询参数 `groups`):
|
||||||
|------|----------|------|
|
|
||||||
| **netx API** | 本机或远端服务器 | `python -m netx_api.main`,默认 `http://127.0.0.1:8890` |
|
|
||||||
| **netx-mcp** | 与 MCP 宿主同机 | `pip install` 轻量包,仅需 Python + httpx |
|
|
||||||
|
|
||||||
---
|
| groups | 工具数 | 说明 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| `ops` | 15 | 告警 / 网元 / 只读 CLI |
|
||||||
|
| `topology` | 16 | 拓扑树 / 画布 / Fabric / 布局 |
|
||||||
|
| `bizMonitor` | 8 | 割接与业务状态只读 |
|
||||||
|
| (省略) | 39 | 当前 token 有权限的全部工具 |
|
||||||
|
|
||||||
## 前置条件
|
示例:`/mcp?groups=ops,bizMonitor`
|
||||||
|
|
||||||
1. **netx API 已启动**且可访问:
|
## 鉴权
|
||||||
|
|
||||||
```powershell
|
- Header:`Authorization: Bearer <token>`
|
||||||
curl http://127.0.0.1:8890/health
|
- 接受 MCP 令牌(`NETX_AUTH_MCP_TOKEN_FILE`,bootstrap 写入)或用户 JWT
|
||||||
```
|
- 无 token / 无效 → HTTP **401**
|
||||||
|
- `tools/list` 只列出当前 token 有 scope 的工具;`tools/call` 再按 RBAC 校验
|
||||||
|
|
||||||
2. **Python 3.11+**(与 MCP 宿主使用的 `python` 一致)。
|
## 客户端配置
|
||||||
|
|
||||||
3. 远端 API 时:记下 base URL(无尾部 `/`),例如 `http://10.0.0.5:8890`。
|
见根目录 [`mcp.all.json`](../mcp.all.json)。Claude Code 示例:
|
||||||
|
|
||||||
---
|
```bash
|
||||||
|
claude mcp add --transport http netx http://127.0.0.1:8890/mcp \
|
||||||
## 1. 安装 `netx-mcp` 包
|
--header "Authorization: Bearer <token>"
|
||||||
|
|
||||||
在 **运行 MCP 的那台机器**上执行(不必安装整套 netx 服务依赖):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd D:\project\chatgpt\netx
|
|
||||||
pip install -e ./packages/netx-mcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
验证(**必须用 oclaw 启动时会调用的同一个 `python`**,见下方说明):
|
仅 ops:
|
||||||
|
|
||||||
```powershell
|
```text
|
||||||
where python
|
http://127.0.0.1:8890/mcp?groups=ops
|
||||||
python -c "import netx_mcp; print('ok')"
|
|
||||||
python -m netx_mcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
若你在 A 终端里 `import netx_mcp` 成功,但 oclaw Admin 安装/Health 无反应或失败,多半是 **pip 装到了另一个 Python**。请用 oclaw 实际用的解释器安装:
|
## 语言
|
||||||
|
|
||||||
```powershell
|
- 环境变量 `NETX_LANG=zh|en`,或请求头 `Accept-Language`
|
||||||
# 把路径换成 where python 的第一条,或 oclaw 服务 venv 里的 python.exe
|
- 工具 `description` / `inputSchema` 与历史 Python 包一致;`en` 时 API 查询附加 `lang=en`
|
||||||
C:\Path\To\Same\python.exe -m pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp"
|
|
||||||
C:\Path\To\Same\python.exe -c "import netx_mcp; print('ok')"
|
|
||||||
```
|
|
||||||
|
|
||||||
从 GitHub 安装(无本地仓库时,**子目录必须是 `packages/netx-mcp`**):
|
## 安全
|
||||||
|
|
||||||
```powershell
|
- `execManagedNe` 等写工具带 `annotations.readOnlyHint=false`
|
||||||
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp"
|
- CLI 硬门(`cli_guard`):仅单行 `show`/`display`,拒绝 shell 与破坏性命令
|
||||||
```
|
- 异步 exec / layout job:先返回 job id,再用轮询工具查询
|
||||||
|
|
||||||
开发者在 netx 仓库根目录也可 `pip install -e .`(含 API);MCP 仍推荐只装 `packages/netx-mcp`。
|
## 版本
|
||||||
|
|
||||||
---
|
`initialize.serverInfo.version` 使用 netx 包版本(`pyproject.toml` / `netx-ops`)。
|
||||||
|
|
||||||
## 2. 环境变量
|
无状态模式下不主动推送 `notifications/tools/list_changed`;客户端应在需要时重新 `tools/list`。
|
||||||
|
|
||||||
| 变量 | 必填 | 默认 | 说明 |
|
## 相关文档
|
||||||
|------|------|------|------|
|
|
||||||
| `NETX_API_URL` | 否 | `http://127.0.0.1:8890` | netx REST 根地址,可指向远端 |
|
|
||||||
| `NETX_API_TOKEN` | 否 | 空 | Bearer;空则自动读 `data/auth/mcp_token`(API 首次启动生成) |
|
|
||||||
| `NETX_MCP_TOKEN_FILE` | 否 | `data/auth/mcp_token` | 默认 token 文件路径 |
|
|
||||||
| `NETX_LANG` | 否 | `zh` | `zh` / `en`,影响 API 文案 |
|
|
||||||
| `NETX_NE_EXEC_MAX_COMMANDS` | 否 | `5` | `execManagedNe` 单次最多命令数(硬上限 50);API 与 MCP 需同设 |
|
|
||||||
| `NETX_NE_EXEC_ASYNC_MIN_NES` | 否 | `4` | 批量 NE 数 ≥ 此值时自动走后台 job(`0`=仅 `async=true`) |
|
|
||||||
| `NETX_NE_EXEC_JOB_DIR` | 否 | `data/ne_exec_jobs` | 后台 exec job 状态目录 |
|
|
||||||
| `NETX_NE_EXEC_JOB_MAX_CONCURRENT` | 否 | `3` | 同时运行的 exec job 上限 |
|
|
||||||
|
|
||||||
本机默认端口时 **可不设任何变量**。启用登录后,先启动一次 netx API,会生成 `data/auth/mcp_token`;MCP 会自动带上该 token。若要把 token 写进 Cursor 配置:
|
- [MCP_TOPOLOGY.md](./MCP_TOPOLOGY.md) — topology 组工具说明
|
||||||
|
- [MCP_BIZMONITOR.md](./MCP_BIZMONITOR.md) — bizMonitor 组工具说明
|
||||||
```json
|
|
||||||
"NETX_API_TOKEN": "nxt_从文件复制的内容"
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 在 Cursor / Claude Desktop 中配置
|
|
||||||
|
|
||||||
复制仓库中的 [`mcp.json`](../mcp.json)(或 [`packages/netx-mcp/mcp.json`](../packages/netx-mcp/mcp.json))到客户端 MCP 配置,例如 Cursor:`.cursor/mcp.json`。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"netx": {
|
|
||||||
"command": "python",
|
|
||||||
"args": ["-m", "netx_mcp"],
|
|
||||||
"env": {
|
|
||||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
|
||||||
"NETX_LANG": "zh",
|
|
||||||
"PYTHONIOENCODING": "utf-8",
|
|
||||||
"PYTHONUTF8": "1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
(可选)显式设置 `"NETX_API_TOKEN": "nxt_..."`;不设则读仓库/`cwd` 下的 `data/auth/mcp_token`。
|
|
||||||
|
|
||||||
保存后 **重启 Cursor/客户端**,使 MCP 子进程重新拉起。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 在 oclaw Admin 中安装
|
|
||||||
|
|
||||||
与 Cursor **同一份** `mcpServers` JSON 即可,无需转成别的格式。
|
|
||||||
|
|
||||||
1. 完成上文 **§1**(用 **oclaw 同机同一个 `python`** 安装 `netx-mcp`)。
|
|
||||||
2. Admin → MCP → 粘贴 `mcp.json` 全文 → 点击 **Install from JSON**(安装状态在下方一行小字)。
|
|
||||||
3. **Health** → **Sync Tools**(完整 scope 下应看到 **15** 个工具)。
|
|
||||||
4. 在 **MCP 专家绑定** 中为 ops 专家勾选 `server_id=netx`。
|
|
||||||
|
|
||||||
更细的 oclaw 说明(双轨内置工具、锚点注入等)见 oclaw 仓库:
|
|
||||||
`oclaw/docs/NETX_MCP_INTEGRATION.md`。
|
|
||||||
|
|
||||||
可选:oclaw 专用字段展开版 [`mcp_install_payload.json`](../mcp_install_payload.json)(与 `mcp.json` 等价)。
|
|
||||||
|
|
||||||
**Skills(真源)**:仓库根 [`skills/`](../skills/README.md) — `netx-ops` / `netx-topology` (ops = NMS + managed CLI)。MCP 包不再带 skills 镜像;Cursor 直接指 `netx/skills/`。dsh-netxops 发版前可 `sync-skills-from-netx.ps1`。oclaw 旧 playbook 不再维护。
|
|
||||||
|
|
||||||
**拓扑画布 MCP**(独立安装/绑定)见 [`MCP_TOPOLOGY.md`](./MCP_TOPOLOGY.md)(`server_id=netx-topology`)。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 暴露的工具
|
|
||||||
|
|
||||||
模型面使用通用 **Nms** 命名(当前适配器仍为 zte-ume,HTTP 仍 `/v1/ume/*`)。**0.3.0** 起不再暴露 `*Ume*` 工具名。
|
|
||||||
|
|
||||||
| 类别 | 工具名 |
|
|
||||||
|------|--------|
|
|
||||||
| NMS 告警 | `queryNmsAlarms`, `aggregateNmsAlarms`, `runNmsDiagnostics` |
|
|
||||||
| NMS 网元 | `queryNmsNeInventory`, `getNmsNe` |
|
|
||||||
| NMS 原始/SQL | `queryNmsAlarmsRaw`, `aggregateNmsAlarmsRaw`, `listNmsAlarmFields`, `sqlQueryNms` |
|
|
||||||
| common(CLI + 路径) | `listManagedNe`, `getManagedNe`, `execManagedNe`, `getNeExecJob`, `listCliTargets`, `findTopologyPaths` |
|
|
||||||
|
|
||||||
参数使用 `nms_ne_id` / `nms_ne_ids`,旧 `ume_*` 模型参数已删除;`getManagedNe` 只接受 `ne_id`。拓扑 Fabric / 画布工具在 **[`netx-topology-mcp`](./MCP_TOPOLOGY.md)**。oclaw 中名称带前缀:`mcp__netx__<toolName>`;DSH:`netx__<toolName>`。
|
|
||||||
|
|
||||||
**业务监控 / 割接分析**:独立 [`netx-bizmonitor-mcp`](./MCP_BIZMONITOR.md) 提供 8 个工具,需 `biz-monitor:read`;netxops 的 bizMonitor 组使用相同 stem。三组统一配置见根 [`mcp.all.json`](../mcp.all.json),不需要安装 netxops。
|
|
||||||
|
|
||||||
**CLI 安全边界**:独立 MCP 强制单行只读 `show` / `display`,可用只读输出过滤;配置、shell 操作符、重定向和多行脚本在 HTTP 前拒绝。后端开放 exec_policy 或 capability 的脚本提示也不能解除这个 MCP 门禁,Linux/RouterOS 原生 CLI 当前未暴露。异步 job_id 仅表示开始,按 poll_after_ms 有界轮询;terminal 后检查 result.ok,批量还须检查 summary.failed 和每条 results[].ok,不能只凭作业 succeeded 宣称全部成功。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 6. 更新 MCP
|
|
||||||
|
|
||||||
MCP 代码在 **`packages/netx-mcp`**(版本见 `packages/netx-mcp/pyproject.toml`)。更新步骤:
|
|
||||||
|
|
||||||
### 6.1 重装 `netx-mcp` 包
|
|
||||||
|
|
||||||
**远程 MCP 机 / 无本地 netx 仓库**(不必 `git pull`,每次更新执行一条即可):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pip install --upgrade "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-mcp"
|
|
||||||
```
|
|
||||||
|
|
||||||
**本机有 netx 仓库(开发)**:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd D:\project\chatgpt\netx
|
|
||||||
git pull
|
|
||||||
pip install -e ./packages/netx-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
确认版本(可选):
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pip show netx-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
### 6.2 重启 MCP 宿主
|
|
||||||
|
|
||||||
| 宿主 | 操作 |
|
|
||||||
|------|------|
|
|
||||||
| **Cursor / Claude** | 完全退出客户端后重开,或重载 MCP |
|
|
||||||
| **oclaw** | 重启 gateway/主进程;Admin 对 `netx` 再点 **Health** → **Sync Tools** |
|
|
||||||
|
|
||||||
仅改 `NETX_API_URL` 等 env 时,同样需重启 MCP 子进程(或 oclaw 整进程)。
|
|
||||||
|
|
||||||
### 6.3 无需改配置的情况
|
|
||||||
|
|
||||||
- 只改 **netx API** 业务逻辑、未改 MCP 工具名/参数:重装 `netx-mcp` 可选;API 部署后 MCP 自动走新 API。
|
|
||||||
- 改了 **工具列表或 JSON schema**:必须重装 `netx-mcp` 并在宿主 **Sync Tools**。
|
|
||||||
|
|
||||||
### 6.4 从旧入口迁移
|
|
||||||
|
|
||||||
| 旧方式 | 新方式 |
|
|
||||||
|--------|--------|
|
|
||||||
| `python -m netx_api.mcp` | `python -m netx_mcp`(唯一 Python 模块入口) |
|
|
||||||
| `NETX_MCP_MODE=db` 直连库 | 已废弃;使用 HTTP 模式 |
|
|
||||||
|
|
||||||
旧入口 `netx_api.mcp`、`netx_api/mcp_server.py` 和 `NETX_MCP_MODE=db` 已删除。请安装独立 `netx-mcp` 包并迁移配置;不再保留兼容委托。旧 DB 模式独有的导入告警查询/聚合/批次/诊断、同步触发、历史告警查询仍有 HTTP API,但不属于当前 ops 15 工具。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 自检
|
|
||||||
|
|
||||||
**API:**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
curl http://127.0.0.1:8890/health
|
|
||||||
```
|
|
||||||
|
|
||||||
**MCP 工具列表(需已 `pip install -e packages/netx-mcp`):**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd D:\project\chatgpt\netx
|
|
||||||
python -m pytest packages/netx-mcp/tests/test_mcp_http.py -q
|
|
||||||
```
|
|
||||||
|
|
||||||
**手动 stdio(PowerShell 示例):**
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
$env:NETX_API_URL = "http://127.0.0.1:8890"
|
|
||||||
$p = Start-Process python -ArgumentList "-m","netx_mcp" -RedirectStandardInput pipe -RedirectStandardOutput pipe -NoNewWindow -PassThru
|
|
||||||
# 向 stdin 写入一行 JSON-RPC initialize / tools/list(见 packages/netx-mcp/tests)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 常见问题
|
|
||||||
|
|
||||||
| 现象 | 处理 |
|
|
||||||
|------|------|
|
|
||||||
| `ModuleNotFoundError: netx_mcp` | 用 **oclaw 同机同一个 `python.exe`** 执行 pip 安装(见 §1 验证) |
|
|
||||||
| **oclaw Admin 点安装「没反应」** | 1) 必须点 **Install from JSON**(只粘贴不点按钮无效)<br>2) 看安装区下方 **install status** 一行字(应出现 `[json] installing...` 或错误)<br>3) 浏览器 F12 → Console 是否有红色报错<br>4) 确认 JSON 为完整 `mcpServers` 或 `mcp_install_payload.json` 格式 |
|
|
||||||
| oclaw preflight `mcp_python_module_missing` | `python` 在 PATH 里但无 `netx_mcp`;对该 python 执行 git+pip 安装 |
|
|
||||||
| 工具调用连不上 API | 检查 `NETX_API_URL`、防火墙、远端 API 是否启动 |
|
|
||||||
| Windows 乱码 / JSON 解析失败 | 配置里保留 `PYTHONIOENCODING=utf-8`、`PYTHONUTF8=1`(见 `mcp.json`) |
|
|
||||||
| oclaw 工具数为 0 | Admin **Health → Sync Tools**;确认 `server_id=netx` 已绑定 ops 专家 |
|
|
||||||
| 旧脚本路径或模块不存在 | 安装 `packages/netx-mcp`,配置改为 `"args": ["-m", "netx_mcp"]` |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 相关文件
|
|
||||||
|
|
||||||
| 文件 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| [`packages/netx-mcp/`](../packages/netx-mcp/) | MCP 实现与 `pyproject.toml` |
|
|
||||||
| [`mcp.json`](../mcp.json) | Cursor / oclaw 粘贴用配置 |
|
|
||||||
| [`mcp_install_payload.json`](../mcp_install_payload.json) | oclaw 字段展开版(可选) |
|
|
||||||
| [`packages/netx-mcp/README.md`](../packages/netx-mcp/README.md) | 子包速查 |
|
|
||||||
|
|
|
||||||
|
|
@ -1,41 +1,9 @@
|
||||||
# 割接与业务监控 MCP
|
# netx MCP · bizMonitor 组
|
||||||
|
|
||||||
`netx-bizmonitor-mcp` 独立提供 bizMonitor 的 8 个只读工具。不安装 netxops 也能读取已有项目、批次、看板、红票、差异和采集原文。它只依赖 Python 3.11+ 与 httpx,通过 HTTP 使用正在运行的 netx。
|
割接与业务状态只读工具(8 个),通过同一远程端点提供:
|
||||||
|
|
||||||
## 三组一起配置
|
```text
|
||||||
|
http://127.0.0.1:8890/mcp?groups=bizMonitor
|
||||||
在运行 MCP 客户端的 Python 环境分别安装:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
python -m pip install -e ./packages/netx-mcp
|
|
||||||
python -m pip install -e ./packages/netx-topology-mcp
|
|
||||||
python -m pip install -e ./packages/netx-bizmonitor-mcp
|
|
||||||
```
|
```
|
||||||
|
|
||||||
将根 [mcp.all.json](../mcp.all.json) 的三个 server 放入客户端 MCP 配置。也可以不安装包,分别将该包的 `src` 绝对路径设为对应 server 的 `PYTHONPATH`。三个模块分别为 `netx_mcp`、`netx_topology_mcp`、`netx_bizmonitor_mcp`;console scripts 属于各独立包,安装 API 本体不会提供一个缺少依赖的 MCP 假入口。
|
鉴权与总览见 [MCP.md](./MCP.md)。需要 `biz-monitor:read`。全部工具 `readOnlyHint=true`。
|
||||||
|
|
||||||
统一设置 `NETX_API_URL`。凭据优先用 `NETX_API_TOKEN`,否则读取 `NETX_MCP_TOKEN_FILE`,再尝试工作目录 `data/auth/mcp_token`。客户端工作目录不确定时,给 `NETX_MCP_TOKEN_FILE` 配置绝对路径。不要把真实令牌提交到仓库。
|
|
||||||
|
|
||||||
业务组需 `biz-monitor:read`;ops 需 `alarms:read`、`ne:read`、`ne:exec`,SQL 另需 `sql:query`;topology 读取需 `ne:read`,建图/布局需 `ne:write`。默认 bootstrap token 不含 SQL 和拓扑写权限,因此工具数量会按 scope 缩减;有完整权限时分别列出 **15、16、8** 个工具。
|
|
||||||
|
|
||||||
## 入口与证据链
|
|
||||||
|
|
||||||
MCP 使用裸名;netxops 使用 `netx__` 加相同名称。
|
|
||||||
|
|
||||||
1. `listBizMonitors`:无 ID 的入口,取得 project_id/task_id。
|
|
||||||
2. `getBizMonitorContext`:读取模板、归一化、端口映射、两侧任务和基线。
|
|
||||||
3. `listBizMonitorBatches(project_id,include_runs=true)`:取得割接批次与 run_id;也可 `kind=runs,batch_id=...`。
|
|
||||||
4. `getBizMonitorBoard(batch_id)`:检查完成状态、缺失数据和统计。
|
|
||||||
5. `listBizMonitorReds(project_id)` / `getBizMonitorDiffs(run_id,color=red)`:逐页取得红项和证据。`q` 自动映射后端 `kw`。
|
|
||||||
6. `getBizCollectBatch`:采集批次的命令目录;需要时由 `listBizMonitorBatches(task_id)` 找采集批次。
|
|
||||||
7. `getBizCollectCommandRaw(batch_id,command_id)`:两侧原文取证。这里使用采集 batch_id,不能传割接 batch_id。
|
|
||||||
|
|
||||||
`old_key/new_key` 保留设备原始 A/B;`match_*` 和归一化映射用于内部配对。原文按需获取,可能很大;凭据会脱敏,不把内部匹配键冒充设备原始端口。
|
|
||||||
|
|
||||||
本组不会创建任务/模板、启动采集、执行 evaluate、关闭红票。没有批次时不能得出“割接通过”。完整行为和参数见 [包 README](../packages/netx-bizmonitor-mcp/README.md) 与 [skill](../skills/biz-monitor/netx-biz-monitor/SKILL.md)。
|
|
||||||
|
|
||||||
## 可复现验收
|
|
||||||
|
|
||||||
`scripts/mcp_acceptance.py` 启动当前 worktree 的真实 stdio MCP,记录去敏 JSONL;仅允许本机 8890 或隔离样例 8891。工具源目录可用 `--source-root` 显式指定,不会静默导入主仓库的 editable 包。
|
|
||||||
|
|
||||||
没有现场割接数据时,可用 `scripts/seed_mcp_acceptance.py` 在 `agent-wt/netx-mcp-lead/artifacts/` 的新 acceptance SQLite 文件生成明确标注的离线样例,真实运行业务评价,再通过本地 API/MCP 验证读取链路。样例不代表现场故障;不要将样例库替换应用库。脚本拒绝覆盖、外部路径、网络连接和监听端口。
|
|
||||||
|
|
|
||||||
|
|
@ -1,141 +1,11 @@
|
||||||
# netx Topology MCP — 安装与更新
|
# netx MCP · topology 组
|
||||||
|
|
||||||
与告警/CLI 的 [`netx-mcp`](./MCP.md) **分开**的 stdio MCP,只提供 **拓扑树、画布(views)、Fabric 边/邻接**。Agent 可只装本包,或与 `netx` 并存。
|
拓扑树、画布(views)、Fabric 与布局工具,通过同一远程端点提供:
|
||||||
|
|
||||||
```
|
```text
|
||||||
MCP 宿主 → stdio netx_topology_mcp → HTTP NETX_API_URL → /v1/topology/*
|
http://127.0.0.1:8890/mcp?groups=topology
|
||||||
```
|
```
|
||||||
|
|
||||||
| 组件 | 说明 |
|
鉴权与总览见 [MCP.md](./MCP.md)。需要 `ne:read` / `ne:write` 等 scope(写工具含新建文件夹、改节点、布局写入等)。
|
||||||
|------|------|
|
|
||||||
| **netx API** | 需已启动,拓扑数据在服务端 |
|
|
||||||
| **netx-topology-mcp** | 轻量 HTTP 客户端,与宿主同机 |
|
|
||||||
|
|
||||||
---
|
布局算法在 netx 进程内执行(原 `netx-topology-mcp` 逻辑已迁入 `netx_api/mcp_remote/topology/`)。异步布局 job 仍返回 job id 后轮询。
|
||||||
|
|
||||||
## 1. 安装
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd D:\project\chatgpt\netx
|
|
||||||
pip install -e ./packages/netx-topology-mcp
|
|
||||||
python -c "import netx_topology_mcp; print('ok')"
|
|
||||||
python -m netx_topology_mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
从 GitHub:
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
pip install "git+https://github.com/hansjone/netx.git#subdirectory=packages/netx-topology-mcp"
|
|
||||||
```
|
|
||||||
|
|
||||||
环境变量与 [`MCP.md`](./MCP.md) 相同:`NETX_API_URL`、`NETX_API_TOKEN` / `data/auth/mcp_token`、`NETX_LANG`。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. Cursor / oclaw 配置
|
|
||||||
|
|
||||||
独立服务器 id:`netx-topology`(不要与 `netx` 混在同一个 command 里)。
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"mcpServers": {
|
|
||||||
"netx-topology": {
|
|
||||||
"command": "python",
|
|
||||||
"args": ["-m", "netx_topology_mcp"],
|
|
||||||
"env": {
|
|
||||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
|
||||||
"NETX_API_TOKEN": "nxt_your_key_with_ne_write",
|
|
||||||
"NETX_LANG": "zh",
|
|
||||||
"PYTHONIOENCODING": "utf-8",
|
|
||||||
"PYTHONUTF8": "1"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
画图必须设 `NETX_API_TOKEN`(网页新建 Key,默认含 `ne:write`)。未设置时回退读 `data/auth/mcp_token`(只读+CLI,**无写工具**)。改 Key 权限后 Sync Tools / 约 45s 内 scopes 会刷新。
|
|
||||||
|
|
||||||
样本:[`packages/netx-topology-mcp/mcp.json`](../packages/netx-topology-mcp/mcp.json)。
|
|
||||||
|
|
||||||
与告警 MCP 并存时,把两个 server 都放进 `mcpServers` 即可;未勾选/未安装的不会加载工具。
|
|
||||||
|
|
||||||
oclaw:Install from JSON → Health → Sync Tools(完整 scope 下应看到 **16** 个工具,含 `layoutTopologyView` / `sinkTopologyDualUnits` / `queryTopologyFabricNodes`)→ 专家绑定勾选 `server_id=netx-topology`。
|
|
||||||
|
|
||||||
Cursor:改包后若 Sync 工具数不对,请 **禁用/启用** `netx-topology` 或重载窗口(须重启 stdio 进程)。可选在 env 加 `PYTHONPATH=.../packages/netx-topology-mcp/src` 强制最新源码。
|
|
||||||
|
|
||||||
配套 Agent Skill(dual_unit 排水 → 扫角/polish → 手拖):[`skills/topology/netx-topology/SKILL.md`](../skills/topology/netx-topology/SKILL.md)。Cursor / oclaw 读 skill 后再调 MCP。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 3. 工具一览
|
|
||||||
|
|
||||||
### 目录模型
|
|
||||||
|
|
||||||
- 顶级为导航「根」;其下唯一「根图」/ `Root map` 为画布(physical view)。
|
|
||||||
- 子区域即画布(创建时自动 physical view)。
|
|
||||||
- 文件夹 `ne_count`:子树去重 Fabric 网元数(来源无关)。
|
|
||||||
|
|
||||||
### 读
|
|
||||||
|
|
||||||
| 工具 | 作用 |
|
|
||||||
|------|------|
|
|
||||||
| `getTopologyTree` | 「根 / 根图 / 子区域」树 + views + `ne_count`(找画布用这个,勿再 listViews) |
|
|
||||||
| `getTopologyView` | 单图(节点+边+坐标);`detail=summary\|full` |
|
|
||||||
| `queryTopologyFabricNodes` | Fabric 库存:`mode=summary\|list\|search`(有 `q` 默认 search;list 支持 `region_folder_id`) |
|
|
||||||
| `classifyTopologyFabricNodes` | **分级打标**:`match`→`tag(level\|role, dry_run)`→`tag`;`level` 为 major.minor(0 外部…3 接入);`role` 预设仍可用。再 `addTopologyViewNodes` 上区域画布。**勿用切片建图** |
|
|
||||||
| `queryTopologyNeighborhood` | 邻域:compact nodes + `links[]`(NE 对,非端口) |
|
|
||||||
| `queryTopologyEdges` | 默认 **adjacency**:`links[{a,b,link_count}]`;画布一对网元一条线。`detail=ports` 才给端口行 |
|
|
||||||
| `analyzeTopologyViewLayout` | 布图验收(只读):`verdict` + score;`score_profile=auto\|default\|eye`(大稀疏斜边→eye);贴边=节点命中∪hits/link;util 甜区下限 0.08;`detail=structure` / `hotspots\|blocks\|both` |
|
|
||||||
|
|
||||||
### 写(只动画布,不污染 Fabric)
|
|
||||||
|
|
||||||
| 工具 | 作用 |
|
|
||||||
|------|------|
|
|
||||||
| `createTopologyFolder` | **唯一建图入口**:顶级→「根」+「根图」;根图下→**子区域**;返回 `view_id` |
|
|
||||||
| `addTopologyViewNodes` | **优先**传 `keyword`/`role`/`vendor`/`link_status` + `limit`/`offset`,由 API 筛选落点;也可 `fabric_node_ids`。拒绝 managed/UME。返回摘要。 |
|
|
||||||
| `removeTopologyViewNodes` | 筛选或 id 从画布移除(不删 Fabric),摘要 |
|
|
||||||
| `sinkTopologyDualUnits` | 根图 dual_units 分批沉入子区域;默认 `layout_batch`→`layout_dual_unit`→块扫挂载;日常禁 `until_empty` |
|
|
||||||
| `copyTopologyViewNodes` | 一键克隆画布成员+坐标到另一画布(`clear_target` 可选);源画布不动,测沙箱用 |
|
|
||||||
| `layoutTopologyView` `move_nodes` | 指定 `fabric_node_ids` 从 `source_view_id`→`view_id`(默认同移出源);对调两 view 回迁;`park=true` 扫角停靠 |
|
|
||||||
| `updateTopologyViewPositions` | **优先** `layout=grid\|offset\|stack` + 筛选,API 自己挪点;`positions[]` 仅少量微调 |
|
|
||||||
| `layoutTopologyView` | **布图/局部修**:公开 `action=layout\|layout_dual_unit\|orbit_sweep\|clear_edge_hits\|pull_far_chains\|compact_bbox\|polish_crossings\|fix_overlaps\|untangle\|straighten_channels\|level_bands\|move_nodes`;recipe 仅 `compact\|corridor\|rings\|unstick`。眼 sink 主路径:`until_limit(crossing→total)` → `clear_edge_hits` → `pull_far_chains` → `compact_bbox` → 手拖;禁对眼跑 polish/fix_overlaps/untangle/round。stall 且 moved≈0 即算法到头。巨图 apply 可能返回 `job_id` → 轮询 `job_status` / `job_cancel`。Job=**子进程+`data/runtime/layout_jobs` 落盘**。`mode=preview\|apply` |
|
|
||||||
| `projectTopologyNeighbors` | 投影**已有** LLDP 邻居到画布;区域画布务必传 `region_folder_id`,读 `out_of_region_skipped` |
|
|
||||||
|
|
||||||
**推荐流水线:** `getTopologyTree` →(可选)`createTopologyFolder` 取 `view_id` → `addTopologyViewNodes` →(可选)邻居投影 / 布局。
|
|
||||||
|
|
||||||
**硬顶 2000:目标 ≤2000 画一张图即可**;physical 根图/区域默认 `max_nodes=2000`,custom 仍按角色软顶。Agent 默认 `links[]` 邻接;布图按邻接与 Skill 中的经验摆点。
|
|
||||||
|
|
||||||
**刻意不提供:** 手工建链、`populate`、删 Fabric / 删整图。
|
|
||||||
|
|
||||||
写操作需要 token 具备 `ne:write`;只读为 `ne:read`。
|
|
||||||
|
|
||||||
**权限怎么开:** 网页 **系统 → API Key**(`/api-keys`)。新建默认已含 `ne:write`(可关掉);已有 Key 在 **操作 → 改权限**。把明文配到 `NETX_API_TOKEN` 后重启 MCP / Sync Tools。仓库自动生成的 `data/auth/mcp_token` 仍是只读+CLI(无写),需要画图请另建 Key 或改权限。
|
|
||||||
|
|
||||||
**前端能否看着画:** 在拓扑页左侧树或右侧浏览区点 **「实时同步」**(默认关闭;**不需要先打开某张图**)。开启后树约每 5 秒、已打开的图约每 3 秒拉取,可看到 MCP 新建区域/画布并往上加点。有未保存本地拖动时不会覆盖你的编辑。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 4. 与 netx-mcp 的关系
|
|
||||||
|
|
||||||
| 包 | server_id | 职责 |
|
|
||||||
|----|-----------|------|
|
|
||||||
| `netx-mcp` | `netx` | NMS 告警、网元和只读 CLI(**15** 工具) |
|
|
||||||
| `netx-topology-mcp` | `netx-topology` | topology 组 15 工具及额外 sinkTopologyDualUnits(共 **16**) |
|
|
||||||
| `netx-bizmonitor-mcp` | `netx-bizmonitor` | 割接 / 业务状态只读分析(**8** 工具) |
|
|
||||||
|
|
||||||
显式后台保存:`layoutTopologyView(view_id=...,action=layout,recipe=compact,mode=apply,params={background:true})`。用 `layoutTopologyView(action=job_status,params={job_id:...})` 轮询,成功后 getTopologyView 回读。任务是 MCP 本地子进程与落盘 JSON,没有 HTTP layout job API;跨目录或机器不能轮询同一 job。失败后可能已有部分写入,先回读,不盲重试。布局保存成功与评分诊断分别判断;评分校准另行安排,本轮不改算法/阈值。
|
|
||||||
|
|
||||||
`queryTopologyEdges` 已从 `netx-mcp` **迁出**到本包,避免重复。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 更新
|
|
||||||
|
|
||||||
```powershell
|
|
||||||
cd <netx 仓库>
|
|
||||||
git pull
|
|
||||||
pip install -e ./packages/netx-topology-mcp
|
|
||||||
```
|
|
||||||
|
|
||||||
然后重启 MCP 宿主,并 Sync Tools。
|
|
||||||
|
|
|
||||||
36
mcp.all.json
36
mcp.all.json
|
|
@ -1,33 +1,27 @@
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"netx": {
|
"netx": {
|
||||||
"command": "python",
|
"url": "http://127.0.0.1:8890/mcp",
|
||||||
"args": ["-m", "netx_mcp"],
|
"headers": {
|
||||||
"env": {
|
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
}
|
||||||
"NETX_LANG": "zh",
|
},
|
||||||
"PYTHONIOENCODING": "utf-8",
|
"netx-ops": {
|
||||||
"PYTHONUTF8": "1"
|
"url": "http://127.0.0.1:8890/mcp?groups=ops",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"netx-topology": {
|
"netx-topology": {
|
||||||
"command": "python",
|
"url": "http://127.0.0.1:8890/mcp?groups=topology",
|
||||||
"args": ["-m", "netx_topology_mcp"],
|
"headers": {
|
||||||
"env": {
|
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
|
||||||
"NETX_LANG": "zh",
|
|
||||||
"PYTHONIOENCODING": "utf-8",
|
|
||||||
"PYTHONUTF8": "1"
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"netx-bizmonitor": {
|
"netx-bizmonitor": {
|
||||||
"command": "python",
|
"url": "http://127.0.0.1:8890/mcp?groups=bizMonitor",
|
||||||
"args": ["-m", "netx_bizmonitor_mcp"],
|
"headers": {
|
||||||
"env": {
|
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
|
||||||
"NETX_LANG": "zh",
|
|
||||||
"PYTHONIOENCODING": "utf-8",
|
|
||||||
"PYTHONUTF8": "1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
20
mcp.json
20
mcp.json
|
|
@ -1,23 +1,9 @@
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcpServers": {
|
||||||
"netx": {
|
"netx": {
|
||||||
"command": "python",
|
"url": "http://127.0.0.1:8890/mcp",
|
||||||
"args": ["-m", "netx_mcp"],
|
"headers": {
|
||||||
"env": {
|
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
|
||||||
"NETX_LANG": "zh",
|
|
||||||
"PYTHONIOENCODING": "utf-8",
|
|
||||||
"PYTHONUTF8": "1"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"netx-topology": {
|
|
||||||
"command": "python",
|
|
||||||
"args": ["-m", "netx_topology_mcp"],
|
|
||||||
"env": {
|
|
||||||
"NETX_API_URL": "http://127.0.0.1:8890",
|
|
||||||
"NETX_LANG": "zh",
|
|
||||||
"PYTHONIOENCODING": "utf-8",
|
|
||||||
"PYTHONUTF8": "1"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
{
|
{
|
||||||
"source_type": "local",
|
"source_type": "remote",
|
||||||
"source_ref": "netx-mcp",
|
"source_ref": "netx-remote-mcp",
|
||||||
"server_id": "netx",
|
"server_id": "netx",
|
||||||
"version": "0.2.0",
|
"version": "0.4.19",
|
||||||
"entry_command": "python",
|
"transport": "http",
|
||||||
"entry_args": ["-m", "netx_mcp"],
|
"url": "http://127.0.0.1:8890/mcp",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||||
|
},
|
||||||
"env_schema": {
|
"env_schema": {
|
||||||
"NETX_API_URL": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "netx REST API base URL (no trailing slash)",
|
|
||||||
"default": "http://127.0.0.1:8890"
|
|
||||||
},
|
|
||||||
"NETX_API_TOKEN": {
|
"NETX_API_TOKEN": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional Bearer token when netx API auth is enabled"
|
"description": "Bearer token (MCP token file or user JWT)"
|
||||||
},
|
},
|
||||||
"NETX_LANG": {
|
"NETX_LANG": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,17 @@
|
||||||
{
|
{
|
||||||
"source_type": "local",
|
"source_type": "remote",
|
||||||
"source_ref": "netx-topology-mcp",
|
"source_ref": "netx-remote-mcp",
|
||||||
"server_id": "netx-topology",
|
"server_id": "netx-topology",
|
||||||
"version": "0.1.0",
|
"version": "0.4.19",
|
||||||
"entry_command": "python",
|
"transport": "http",
|
||||||
"entry_args": ["-m", "netx_topology_mcp"],
|
"url": "http://127.0.0.1:8890/mcp?groups=topology",
|
||||||
|
"headers": {
|
||||||
|
"Authorization": "Bearer ${NETX_API_TOKEN}"
|
||||||
|
},
|
||||||
"env_schema": {
|
"env_schema": {
|
||||||
"NETX_API_URL": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "netx REST API base URL (no trailing slash)",
|
|
||||||
"default": "http://127.0.0.1:8890"
|
|
||||||
},
|
|
||||||
"NETX_API_TOKEN": {
|
"NETX_API_TOKEN": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "Optional Bearer token when netx API auth is enabled"
|
"description": "Bearer token (MCP token file or user JWT)"
|
||||||
},
|
},
|
||||||
"NETX_LANG": {
|
"NETX_LANG": {
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,13 @@
|
||||||
"""HTTP auth gate + request audit middleware."""
|
"""HTTP auth gate + request audit middleware (pure ASGI — safe with Mount /mcp)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
import time
|
import time
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
from starlette.middleware.base import BaseHTTPMiddleware
|
|
||||||
from starlette.requests import Request
|
from starlette.requests import Request
|
||||||
from starlette.responses import JSONResponse, Response
|
from starlette.responses import JSONResponse
|
||||||
|
from starlette.types import ASGIApp, Message, Receive, Scope, Send
|
||||||
|
|
||||||
from .auth_deps import resolve_user_from_token
|
from .auth_deps import resolve_user_from_token
|
||||||
from .auth_scopes import has_scope, required_scope_for_request
|
from .auth_scopes import has_scope, required_scope_for_request
|
||||||
|
|
@ -35,11 +34,12 @@ _PUBLIC_PREFIXES = (
|
||||||
"/assets",
|
"/assets",
|
||||||
)
|
)
|
||||||
|
|
||||||
# Authenticated API / infra only. Everything else (SPA routes, favicon, …) is public.
|
|
||||||
def _is_api_auth_path(path: str) -> bool:
|
def _is_api_auth_path(path: str) -> bool:
|
||||||
p = str(path or "")
|
p = str(path or "")
|
||||||
if p.startswith("/v1/"):
|
if p.startswith("/v1/"):
|
||||||
return True
|
return True
|
||||||
|
# /mcp has its own Bearer gate (mcp_remote.mount.McpAuthMiddleware).
|
||||||
if p in ("/metrics", "/metrics/json", "/v1/metrics/json", "/openapi.json"):
|
if p in ("/metrics", "/metrics/json", "/v1/metrics/json", "/openapi.json"):
|
||||||
return True
|
return True
|
||||||
if p.startswith("/docs") or p.startswith("/redoc"):
|
if p.startswith("/docs") or p.startswith("/redoc"):
|
||||||
|
|
@ -47,7 +47,6 @@ def _is_api_auth_path(path: str) -> bool:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
# While must_change_password is true, only these authenticated endpoints are allowed.
|
|
||||||
_PASSWORD_CHANGE_ALLOW = frozenset(
|
_PASSWORD_CHANGE_ALLOW = frozenset(
|
||||||
{
|
{
|
||||||
("GET", "/v1/auth/me"),
|
("GET", "/v1/auth/me"),
|
||||||
|
|
@ -74,12 +73,16 @@ def _is_public(path: str) -> bool:
|
||||||
return True
|
return True
|
||||||
if any(p.startswith(pref) for pref in _PUBLIC_PREFIXES):
|
if any(p.startswith(pref) for pref in _PUBLIC_PREFIXES):
|
||||||
return True
|
return True
|
||||||
# Bundled UI routes (/login, /topology, …) must load without a token.
|
|
||||||
if not _is_api_auth_path(p):
|
if not _is_api_auth_path(p):
|
||||||
return True
|
return True
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _is_mcp_path(path: str) -> bool:
|
||||||
|
p = str(path or "")
|
||||||
|
return p == "/mcp" or p.startswith("/mcp/")
|
||||||
|
|
||||||
|
|
||||||
def _client_ip(request: Request) -> str:
|
def _client_ip(request: Request) -> str:
|
||||||
from .client_ip import resolve_client_ip
|
from .client_ip import resolve_client_ip
|
||||||
|
|
||||||
|
|
@ -104,18 +107,37 @@ def _action_for(method: str, path: str) -> str:
|
||||||
return f"http.{m.lower()}"
|
return f"http.{m.lower()}"
|
||||||
|
|
||||||
|
|
||||||
class AuthAuditMiddleware(BaseHTTPMiddleware):
|
class AuthAuditMiddleware:
|
||||||
async def dispatch(self, request: Request, call_next: Callable) -> Response:
|
"""Pure ASGI auth + audit. BaseHTTPMiddleware breaks Streamable HTTP under Mount."""
|
||||||
if request.method.upper() == "OPTIONS":
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
|
def __init__(self, app: ASGIApp) -> None:
|
||||||
|
self.app = app
|
||||||
|
|
||||||
|
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
|
||||||
|
if scope["type"] != "http":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
request = Request(scope, receive)
|
||||||
|
method = request.method.upper()
|
||||||
path = request.url.path
|
path = request.url.path
|
||||||
if not bool(settings.auth_enabled) or _is_public(path):
|
|
||||||
return await call_next(request)
|
|
||||||
|
|
||||||
# WebSocket upgrades are authenticated inside the WS endpoint.
|
# MCP is authenticated inside the mounted sub-app; never wrap its body.
|
||||||
|
if _is_mcp_path(path):
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
if method == "OPTIONS":
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
|
if not bool(settings.auth_enabled) or _is_public(path):
|
||||||
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
if path.startswith("/v1/webcrt/") and path.endswith("/ws"):
|
if path.startswith("/v1/webcrt/") and path.endswith("/ws"):
|
||||||
return await call_next(request)
|
await self.app(scope, receive, send)
|
||||||
|
return
|
||||||
|
|
||||||
token = ""
|
token = ""
|
||||||
auth = str(request.headers.get("authorization") or "").strip()
|
auth = str(request.headers.get("authorization") or "").strip()
|
||||||
|
|
@ -125,26 +147,27 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
||||||
from .auth_cookies import read_access_cookie
|
from .auth_cookies import read_access_cookie
|
||||||
|
|
||||||
token = read_access_cookie(request)
|
token = read_access_cookie(request)
|
||||||
# Query-string access_token is no longer accepted (leaks via logs/proxies).
|
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
resolved = resolve_user_from_token(db, token) if token else None
|
resolved = resolve_user_from_token(db, token) if token else None
|
||||||
if resolved is None:
|
if resolved is None:
|
||||||
# Do not audit anonymous 401s — SPA fan-out floods the log.
|
await JSONResponse(status_code=401, content={"detail": "unauthorized"})(
|
||||||
# Intentional auth ops (login/logout/login_failed) are written in auth_router.
|
scope, receive, send
|
||||||
return JSONResponse(status_code=401, content={"detail": "unauthorized"})
|
)
|
||||||
|
return
|
||||||
user, via, scopes, token_id, jti = resolved
|
user, via, scopes, token_id, jti = resolved
|
||||||
if bool(getattr(user, "must_change_password", False)):
|
if bool(getattr(user, "must_change_password", False)):
|
||||||
allow_key = (request.method.upper(), path.rstrip("/") if len(path) > 1 else path)
|
allow_key = (method, path.rstrip("/") if len(path) > 1 else path)
|
||||||
if allow_key not in _PASSWORD_CHANGE_ALLOW:
|
if allow_key not in _PASSWORD_CHANGE_ALLOW:
|
||||||
return JSONResponse(
|
await JSONResponse(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
content={"detail": "password_change_required"},
|
content={"detail": "password_change_required"},
|
||||||
)
|
)(scope, receive, send)
|
||||||
need = required_scope_for_request(request.method, path)
|
return
|
||||||
|
need = required_scope_for_request(method, path)
|
||||||
if need and not has_scope(scopes, need):
|
if need and not has_scope(scopes, need):
|
||||||
return JSONResponse(
|
await JSONResponse(
|
||||||
status_code=403,
|
status_code=403,
|
||||||
content={
|
content={
|
||||||
"detail": {
|
"detail": {
|
||||||
|
|
@ -153,7 +176,8 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
||||||
"granted": sorted(scopes),
|
"granted": sorted(scopes),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)(scope, receive, send)
|
||||||
|
return
|
||||||
request.state.auth_user = user
|
request.state.auth_user = user
|
||||||
request.state.auth_via = via
|
request.state.auth_via = via
|
||||||
request.state.auth_scopes = scopes
|
request.state.auth_scopes = scopes
|
||||||
|
|
@ -164,22 +188,33 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
||||||
auth_via = via
|
auth_via = via
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.exception("auth middleware failure path=%s", path)
|
_log.exception("auth middleware failure path=%s", path)
|
||||||
return JSONResponse(status_code=500, content={"detail": "auth_middleware_error"})
|
await JSONResponse(status_code=500, content={"detail": "auth_middleware_error"})(
|
||||||
|
scope, receive, send
|
||||||
|
)
|
||||||
|
return
|
||||||
finally:
|
finally:
|
||||||
db.close()
|
db.close()
|
||||||
|
|
||||||
started = time.perf_counter()
|
started = time.perf_counter()
|
||||||
response = await call_next(request)
|
status_code = 0
|
||||||
|
|
||||||
|
async def send_wrapper(message: Message) -> None:
|
||||||
|
nonlocal status_code
|
||||||
|
if message["type"] == "http.response.start":
|
||||||
|
status_code = int(message.get("status") or 0)
|
||||||
|
await send(message)
|
||||||
|
|
||||||
|
await self.app(scope, receive, send_wrapper)
|
||||||
try:
|
try:
|
||||||
from .audit_async import enqueue_audit
|
from .audit_async import enqueue_audit
|
||||||
|
|
||||||
enqueue_audit(
|
enqueue_audit(
|
||||||
action=_action_for(request.method, path),
|
action=_action_for(method, path),
|
||||||
actor_user_id=actor_id,
|
actor_user_id=actor_id,
|
||||||
actor_username=actor_name,
|
actor_username=actor_name,
|
||||||
method=request.method,
|
method=method,
|
||||||
path=path,
|
path=path,
|
||||||
status_code=int(response.status_code),
|
status_code=int(status_code or 0),
|
||||||
client_ip=_client_ip(request),
|
client_ip=_client_ip(request),
|
||||||
user_agent=str(request.headers.get("user-agent") or "")[:512],
|
user_agent=str(request.headers.get("user-agent") or "")[:512],
|
||||||
detail={
|
detail={
|
||||||
|
|
@ -189,4 +224,3 @@ class AuthAuditMiddleware(BaseHTTPMiddleware):
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.exception("audit enqueue after request failed path=%s", path)
|
_log.exception("audit enqueue after request failed path=%s", path)
|
||||||
return response
|
|
||||||
|
|
|
||||||
|
|
@ -218,6 +218,11 @@ class Settings(BaseSettings):
|
||||||
bootstrap_admin_password: str = "admin123"
|
bootstrap_admin_password: str = "admin123"
|
||||||
# Written on first boot for MCP; path relative to cwd / absolute
|
# Written on first boot for MCP; path relative to cwd / absolute
|
||||||
auth_mcp_token_file: str = "data/auth/mcp_token"
|
auth_mcp_token_file: str = "data/auth/mcp_token"
|
||||||
|
# Comma-separated Host allow-list for MCP DNS-rebinding guard.
|
||||||
|
# Empty (default) = disable DNS-rebinding protection (Bearer auth is the gate;
|
||||||
|
# needed for LAN IP / reverse-proxy Host headers). Example:
|
||||||
|
# NETX_MCP_ALLOWED_HOSTS=localhost,127.0.0.1,netx.example.com,netx.example.com:*
|
||||||
|
mcp_allowed_hosts: str = ""
|
||||||
# Cap non-revoked API keys (MCP / DSH / scripts). 0 = unlimited.
|
# Cap non-revoked API keys (MCP / DSH / scripts). 0 = unlimited.
|
||||||
auth_api_token_max_count: int = 20
|
auth_api_token_max_count: int = 20
|
||||||
# Comma-separated peers allowed to supply X-Forwarded-For / X-Real-IP
|
# Comma-separated peers allowed to supply X-Forwarded-For / X-Real-IP
|
||||||
|
|
|
||||||
|
|
@ -52,13 +52,17 @@ async def lifespan(_app: FastAPI) -> AsyncIterator[None]:
|
||||||
from .app_startup import run_api_startup
|
from .app_startup import run_api_startup
|
||||||
from .app_shutdown import shutdown_runtime
|
from .app_shutdown import shutdown_runtime
|
||||||
from .dsh_alarm_hub import bind_event_loop
|
from .dsh_alarm_hub import bind_event_loop
|
||||||
|
from .mcp_remote.mount import mcp_lifespan_cm
|
||||||
|
|
||||||
run_api_startup()
|
# Pass the live FastAPI app so /mcp ASGI is rebound to a fresh session manager
|
||||||
bind_event_loop(asyncio.get_running_loop())
|
# (TestClient cycles + tests that clear mcp context must not leave a stale app).
|
||||||
try:
|
async with mcp_lifespan_cm(host_app=_app):
|
||||||
yield
|
run_api_startup()
|
||||||
finally:
|
bind_event_loop(asyncio.get_running_loop())
|
||||||
shutdown_runtime(reason="api_lifespan")
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
shutdown_runtime(reason="api_lifespan")
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|
@ -95,8 +99,11 @@ def health() -> dict[str, str]:
|
||||||
return {"status": "ok"}
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
from .mcp_remote.mount import mount_mcp # noqa: E402
|
||||||
from .ui_static import mount_ui_if_present # noqa: E402
|
from .ui_static import mount_ui_if_present # noqa: E402
|
||||||
|
|
||||||
|
mount_mcp(app)
|
||||||
|
|
||||||
_UI_MOUNTED = mount_ui_if_present(app)
|
_UI_MOUNTED = mount_ui_if_present(app)
|
||||||
if not _UI_MOUNTED:
|
if not _UI_MOUNTED:
|
||||||
|
|
||||||
|
|
@ -109,6 +116,7 @@ if not _UI_MOUNTED:
|
||||||
"frontend_url": settings.frontend_url,
|
"frontend_url": settings.frontend_url,
|
||||||
"api_health": "/health",
|
"api_health": "/health",
|
||||||
"api_status": "/v1/integrations/status",
|
"api_status": "/v1/integrations/status",
|
||||||
|
"mcp": "/mcp",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
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 typing import Any
|
||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
|
|
||||||
from .http_client import http_json, mcp_from_handler_result
|
from netx_api.mcp_remote.asgi_client import http_json, mcp_from_handler_result
|
||||||
|
|
||||||
|
|
||||||
class _InvalidArgument(ValueError):
|
class _InvalidArgument(ValueError):
|
||||||
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
|
from .http_tools import HTTP_MCP_TOOLS, call_http_tool
|
||||||
|
|
||||||
|
|
@ -10,7 +10,7 @@ import os
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from .cli_guard import validate_readonly_cli
|
from .cli_guard import validate_readonly_cli
|
||||||
from .http_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id
|
from netx_api.mcp_remote.asgi_client import http_json, http_post_json, mcp_from_handler_result, quote_ne_id
|
||||||
|
|
||||||
_EXEC_MAX_COMMANDS_CAP = 50
|
_EXEC_MAX_COMMANDS_CAP = 50
|
||||||
_EXEC_MAX_COMMANDS_DEFAULT = 5
|
_EXEC_MAX_COMMANDS_DEFAULT = 5
|
||||||
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
|
import sys
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from netx_topology_mcp.http_client import http_json, http_json_many, mcp_from_handler_result
|
from netx_api.mcp_remote.asgi_client import http_json, http_json_many, mcp_from_handler_result
|
||||||
from netx_topology_mcp.layout_jobs import (
|
from netx_api.mcp_remote.topology.layout_jobs import (
|
||||||
cancel_job,
|
cancel_job,
|
||||||
job_public,
|
job_public,
|
||||||
raise_if_cancelled,
|
raise_if_cancelled,
|
||||||
report_progress,
|
report_progress,
|
||||||
start_job,
|
start_job,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_sight import build_sight
|
from netx_api.mcp_remote.topology.layout_sight import build_sight
|
||||||
from netx_topology_mcp.layout_stats import analyze_layout_stats
|
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
|
||||||
from netx_topology_mcp.layout_structure import analyze_graph_structure
|
from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
|
||||||
from netx_topology_mcp.layout_tool import list_layout_catalog, run_layout_on_graph
|
from netx_api.mcp_remote.topology.layout_tool import list_layout_catalog, run_layout_on_graph
|
||||||
|
|
||||||
|
|
||||||
def _data(out: dict[str, Any]) -> dict[str, Any]:
|
def _data(out: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
@ -632,7 +632,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
||||||
# compose_views / compose_orbit: load staging canvases, inject blocks.
|
# compose_views / compose_orbit: load staging canvases, inject blocks.
|
||||||
if action in {"compose_views", "compose_orbit"}:
|
if action in {"compose_views", "compose_orbit"}:
|
||||||
from netx_topology_mcp.layout_ops.compose_views import compose_params_from_overrides
|
from netx_api.mcp_remote.topology.layout_ops.compose_views import compose_params_from_overrides
|
||||||
|
|
||||||
knobs = compose_params_from_overrides(overrides)
|
knobs = compose_params_from_overrides(overrides)
|
||||||
src_ids = list(knobs.get("source_view_ids") or [])
|
src_ids = list(knobs.get("source_view_ids") or [])
|
||||||
|
|
@ -774,7 +774,7 @@ def _layout_topology_view(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
"untangle",
|
"untangle",
|
||||||
"polish_crossings",
|
"polish_crossings",
|
||||||
}:
|
}:
|
||||||
from netx_topology_mcp.layout_ops.compose_views import compose_params_from_overrides
|
from netx_api.mcp_remote.topology.layout_ops.compose_views import compose_params_from_overrides
|
||||||
|
|
||||||
knobs = compose_params_from_overrides(overrides)
|
knobs = compose_params_from_overrides(overrides)
|
||||||
src_ids = list(knobs.get("source_view_ids") or [])
|
src_ids = list(knobs.get("source_view_ids") or [])
|
||||||
|
|
@ -1147,11 +1147,11 @@ def _view_membership_ids(view_id: str) -> tuple[dict[str, Any], set[str], list[d
|
||||||
|
|
||||||
def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]:
|
def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Rank hub territories on source for non-dual move_nodes(park) batches."""
|
"""Rank hub territories on source for non-dual move_nodes(park) batches."""
|
||||||
from netx_topology_mcp.layout_ops.suggest_sink_hubs import (
|
from netx_api.mcp_remote.topology.layout_ops.suggest_sink_hubs import (
|
||||||
pick_batch,
|
pick_batch,
|
||||||
suggest_sink_hub_batches,
|
suggest_sink_hub_batches,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_structure import analyze_graph_structure
|
from netx_api.mcp_remote.topology.layout_structure import analyze_graph_structure
|
||||||
|
|
||||||
source_view_id = str(
|
source_view_id = str(
|
||||||
args.get("source_view_id") or args.get("view_id") or ""
|
args.get("source_view_id") or args.get("view_id") or ""
|
||||||
|
|
@ -1314,12 +1314,12 @@ def _suggest_sink_hubs(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
||||||
def _align_topology_reference(args: dict[str, Any]) -> dict[str, Any]:
|
def _align_topology_reference(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
"""Map reference canvas geometry onto target via shared fabric_node_ids."""
|
"""Map reference canvas geometry onto target via shared fabric_node_ids."""
|
||||||
from netx_topology_mcp.layout_ops.align_reference import (
|
from netx_api.mcp_remote.topology.layout_ops.align_reference import (
|
||||||
align_reference_params_from_overrides,
|
align_reference_params_from_overrides,
|
||||||
align_to_reference,
|
align_to_reference,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
|
||||||
from netx_topology_mcp.layout_stats import analyze_layout_stats
|
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
|
||||||
|
|
||||||
view_id = str(args.get("view_id") or "").strip()
|
view_id = str(args.get("view_id") or "").strip()
|
||||||
overrides = args.get("params") if isinstance(args.get("params"), dict) else {}
|
overrides = args.get("params") if isinstance(args.get("params"), dict) else {}
|
||||||
|
|
@ -2527,9 +2527,9 @@ def _sink_topology_dual_units(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
runs layout_dual_unit per unit before parking (old staging flow). Global
|
runs layout_dual_unit per unit before parking (old staging flow). Global
|
||||||
polish still via layoutTopologyView on the sink.
|
polish still via layoutTopologyView on the sink.
|
||||||
"""
|
"""
|
||||||
from netx_topology_mcp.layout_ops.dual_units import find_dual_portal_units
|
from netx_api.mcp_remote.topology.layout_ops.dual_units import find_dual_portal_units
|
||||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
|
||||||
from netx_topology_mcp.layout_ops.sink_dual_units import (
|
from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import (
|
||||||
batch_node_ids,
|
batch_node_ids,
|
||||||
layout_and_pack_batch,
|
layout_and_pack_batch,
|
||||||
leftover_batch_ids,
|
leftover_batch_ids,
|
||||||
|
|
@ -3149,7 +3149,7 @@ def _move_topology_view_nodes(args: dict[str, Any]) -> dict[str, Any]:
|
||||||
**{k: base[k] for k in ("source_view_id", "view_id", "move_ids")},
|
**{k: base[k] for k in ("source_view_id", "view_id", "move_ids")},
|
||||||
}
|
}
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.sink_dual_units import (
|
from netx_api.mcp_remote.topology.layout_ops.sink_dual_units import (
|
||||||
merge_view_links,
|
merge_view_links,
|
||||||
park_positions,
|
park_positions,
|
||||||
)
|
)
|
||||||
|
|
@ -10,14 +10,14 @@ from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(prog="netx_topology_mcp.layout_job_worker")
|
parser = argparse.ArgumentParser(prog="netx_api.mcp_remote.topology.layout_job_worker")
|
||||||
parser.add_argument("--job-id", required=True)
|
parser.add_argument("--job-id", required=True)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
job_id = str(args.job_id).strip()
|
job_id = str(args.job_id).strip()
|
||||||
if not job_id:
|
if not job_id:
|
||||||
return 2
|
return 2
|
||||||
|
|
||||||
from netx_topology_mcp.layout_jobs import (
|
from netx_api.mcp_remote.topology.layout_jobs import (
|
||||||
_JobCancelled,
|
_JobCancelled,
|
||||||
_args_path,
|
_args_path,
|
||||||
_read_job_disk,
|
_read_job_disk,
|
||||||
|
|
@ -68,7 +68,7 @@ def main(argv: list[str] | None = None) -> int:
|
||||||
raise _JobCancelled()
|
raise _JobCancelled()
|
||||||
|
|
||||||
# Late import: pulls http_tools + layout stack.
|
# Late import: pulls http_tools + layout stack.
|
||||||
from netx_topology_mcp.http_tools import _layout_topology_view
|
from netx_api.mcp_remote.topology.http_tools import _layout_topology_view
|
||||||
|
|
||||||
result = _layout_topology_view(tool_args)
|
result = _layout_topology_view(tool_args)
|
||||||
finish_job(job_id, result=result, cancelled=is_cancelled())
|
finish_job(job_id, result=result, cancelled=is_cancelled())
|
||||||
|
|
@ -42,7 +42,7 @@ def _job_dir() -> Path:
|
||||||
if env:
|
if env:
|
||||||
p = Path(env)
|
p = Path(env)
|
||||||
else:
|
else:
|
||||||
# packages/netx-topology-mcp/src/netx_topology_mcp → repo data/
|
# netx_api/mcp_remote/topology → repo data/
|
||||||
here = Path(__file__).resolve()
|
here = Path(__file__).resolve()
|
||||||
repo = here.parents[4] # .../netx
|
repo = here.parents[4] # .../netx
|
||||||
p = repo / "data" / "runtime" / "layout_jobs"
|
p = repo / "data" / "runtime" / "layout_jobs"
|
||||||
|
|
@ -407,7 +407,7 @@ def start_job(
|
||||||
|
|
||||||
try:
|
try:
|
||||||
proc = subprocess.Popen( # noqa: S603 — controlled argv
|
proc = subprocess.Popen( # noqa: S603 — controlled argv
|
||||||
[sys.executable, "-m", "netx_topology_mcp.layout_job_worker", "--job-id", job_id],
|
[sys.executable, "-m", "netx_api.mcp_remote.topology.layout_job_worker", "--job-id", job_id],
|
||||||
env=env,
|
env=env,
|
||||||
stdin=subprocess.DEVNULL,
|
stdin=subprocess.DEVNULL,
|
||||||
stdout=subprocess.DEVNULL,
|
stdout=subprocess.DEVNULL,
|
||||||
|
|
@ -12,8 +12,8 @@ Scoped / block ops:
|
||||||
Soft partition: layout_ops.partition (hub BFS + optional igraph).
|
Soft partition: layout_ops.partition (hub BFS + optional igraph).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
|
||||||
from netx_topology_mcp.layout_ops.partition import (
|
from netx_api.mcp_remote.topology.layout_ops.partition import (
|
||||||
igraph_available,
|
igraph_available,
|
||||||
pack_block_centers,
|
pack_block_centers,
|
||||||
pack_soft_blocks,
|
pack_soft_blocks,
|
||||||
|
|
@ -21,7 +21,7 @@ from netx_topology_mcp.layout_ops.partition import (
|
||||||
partition_soft_blocks,
|
partition_soft_blocks,
|
||||||
resolve_block_mode,
|
resolve_block_mode,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.recipe import (
|
from netx_api.mcp_remote.topology.layout_ops.recipe import (
|
||||||
RECIPES,
|
RECIPES,
|
||||||
REGISTRY,
|
REGISTRY,
|
||||||
agg_rings_v1_passes,
|
agg_rings_v1_passes,
|
||||||
|
|
@ -31,19 +31,19 @@ from netx_topology_mcp.layout_ops.recipe import (
|
||||||
smd_corridor_unstick_v1_passes,
|
smd_corridor_unstick_v1_passes,
|
||||||
smd_corridor_v1_passes,
|
smd_corridor_v1_passes,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.rings import build_ring_skeleton
|
from netx_api.mcp_remote.topology.layout_ops.rings import build_ring_skeleton
|
||||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
|
||||||
from netx_topology_mcp.layout_ops.scope import list_blocks, select_scope
|
from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks, select_scope
|
||||||
from netx_topology_mcp.layout_ops.score import score_state
|
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||||
from netx_topology_mcp.layout_ops.compose_views import (
|
from netx_api.mcp_remote.topology.layout_ops.compose_views import (
|
||||||
compose_into_state,
|
compose_into_state,
|
||||||
strip_pack_blocks,
|
strip_pack_blocks,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.dual_units import (
|
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
|
||||||
find_dual_portal_units,
|
find_dual_portal_units,
|
||||||
layout_dual_unit,
|
layout_dual_unit,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"LayoutParams",
|
"LayoutParams",
|
||||||
|
|
@ -14,10 +14,10 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.graph_util import bbox
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
|
||||||
from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def _dist(a: tuple[float, float], b: tuple[float, float]) -> float:
|
def _dist(a: tuple[float, float], b: tuple[float, float]) -> float:
|
||||||
|
|
@ -26,18 +26,18 @@ import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
count_edge_crossings,
|
count_edge_crossings,
|
||||||
top_crossing_nodes,
|
top_crossing_nodes,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.orbit_sweep import (
|
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
|
||||||
_MAX_JUMP_CAP,
|
_MAX_JUMP_CAP,
|
||||||
_far_field_guides,
|
_far_field_guides,
|
||||||
_polar_grid,
|
_polar_grid,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.ring_faces import extract_ring_faces
|
from netx_api.mcp_remote.topology.layout_ops.ring_faces import extract_ring_faces
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_topology_quality import extract_chain_paths
|
from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
|
||||||
|
|
||||||
# Compact-first: tight packs before full-length (fits crowded pockets).
|
# Compact-first: tight packs before full-length (fits crowded pockets).
|
||||||
# Floor must clear icon+caption; smaller steps cause member self-overlap → apply reject.
|
# Floor must clear icon+caption; smaller steps cause member self-overlap → apply reject.
|
||||||
|
|
@ -266,11 +266,11 @@ def _bundle_ok(
|
||||||
nn_floor: float,
|
nn_floor: float,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""Gate: no footprint invade (members∪outsiders) and nn_floor vs outsiders."""
|
"""Gate: no footprint invade (members∪outsiders) and nn_floor vs outsiders."""
|
||||||
from netx_topology_mcp.layout_ops.orbit_sweep import (
|
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
|
||||||
_box,
|
_box,
|
||||||
_centers_may_overlap,
|
_centers_may_overlap,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_metrics import node_footprint
|
from netx_api.mcp_remote.topology.layout_metrics import node_footprint
|
||||||
|
|
||||||
# Apply refuses any footprint overlap — members must clear each other too.
|
# Apply refuses any footprint overlap — members must clear each other too.
|
||||||
mem_list = [n for n in members if n in trial]
|
mem_list = [n for n in members if n in trial]
|
||||||
|
|
@ -639,7 +639,7 @@ def apply_bundle_pick(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Minimize-expand must not leave footprint overlaps (apply gate).
|
# Minimize-expand must not leave footprint overlaps (apply gate).
|
||||||
from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
||||||
|
|
||||||
if _has_any_footprint_overlap(trial, state.names):
|
if _has_any_footprint_overlap(trial, state.names):
|
||||||
return OpResult(
|
return OpResult(
|
||||||
|
|
@ -697,7 +697,7 @@ def rank_bundles_by_hotspots(
|
||||||
"""Prefer bundles whose tip / members participate in crossings."""
|
"""Prefer bundles whose tip / members participate in crossings."""
|
||||||
if not bundles:
|
if not bundles:
|
||||||
return []
|
return []
|
||||||
from netx_topology_mcp.layout_metrics import crossing_participation
|
from netx_api.mcp_remote.topology.layout_metrics import crossing_participation
|
||||||
|
|
||||||
_n, node_hit = crossing_participation(pos, links)[:2]
|
_n, node_hit = crossing_participation(pos, links)[:2]
|
||||||
hot = {
|
hot = {
|
||||||
|
|
@ -6,15 +6,15 @@ nodes sit on deg≤2 corridors.
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.channels import extract_channels
|
from netx_api.mcp_remote.topology.layout_ops.channels import extract_channels
|
||||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||||
from netx_topology_mcp.layout_ops.pin_beam import pin_beam_rigid
|
from netx_api.mcp_remote.topology.layout_ops.pin_beam import pin_beam_rigid
|
||||||
from netx_topology_mcp.layout_ops.ring_faces import eject_intruders, extract_ring_faces
|
from netx_api.mcp_remote.topology.layout_ops.ring_faces import eject_intruders, extract_ring_faces
|
||||||
from netx_topology_mcp.layout_ops.skeleton import build_skeleton
|
from netx_api.mcp_remote.topology.layout_ops.skeleton import build_skeleton
|
||||||
from netx_topology_mcp.layout_ops.soft_petals import soft_petals_greedy
|
from netx_api.mcp_remote.topology.layout_ops.soft_petals import soft_petals_greedy
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_ops.transforms import normalize_origin
|
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
|
||||||
|
|
||||||
|
|
||||||
def build_channel_metro_skeleton(
|
def build_channel_metro_skeleton(
|
||||||
|
|
@ -10,8 +10,8 @@ import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||||
from netx_topology_mcp.layout_topology_quality import extract_chain_paths
|
from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
|
||||||
|
|
||||||
|
|
||||||
def order_stubs_crossing_aware(
|
def order_stubs_crossing_aware(
|
||||||
|
|
@ -244,9 +244,9 @@ def straighten_channels_greedy(
|
||||||
Prefer fewer global crossings; when crossings stay equal (incl. already 0),
|
Prefer fewer global crossings; when crossings stay equal (incl. already 0),
|
||||||
accept H/V / L modes that raise path axis credit (metro look).
|
accept H/V / L modes that raise path axis credit (metro look).
|
||||||
"""
|
"""
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, OpResult
|
||||||
|
|
||||||
params = params or LayoutParams()
|
params = params or LayoutParams()
|
||||||
step_px = float(step if step is not None else max(getattr(params, "pitch", 180.0), 160.0))
|
step_px = float(step if step is not None else max(getattr(params, "pitch", 180.0), 160.0))
|
||||||
|
|
@ -279,7 +279,7 @@ def straighten_channels_greedy(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress
|
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress
|
||||||
|
|
||||||
for _pass in range(max(1, int(max_passes))):
|
for _pass in range(max(1, int(max_passes))):
|
||||||
raise_if_cancelled()
|
raise_if_cancelled()
|
||||||
|
|
@ -10,7 +10,7 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
EDGE_CLEARANCE_THR,
|
EDGE_CLEARANCE_THR,
|
||||||
REC_CENTER_DX,
|
REC_CENTER_DX,
|
||||||
REC_CENTER_DY,
|
REC_CENTER_DY,
|
||||||
|
|
@ -19,7 +19,7 @@ from netx_topology_mcp.layout_metrics import (
|
||||||
node_footprint,
|
node_footprint,
|
||||||
point_segment_dist,
|
point_segment_dist,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
_AXIS_TOL = 8.0
|
_AXIS_TOL = 8.0
|
||||||
|
|
||||||
|
|
@ -12,13 +12,13 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
count_edge_crossings,
|
count_edge_crossings,
|
||||||
compute_edge_clearance,
|
compute_edge_clearance,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.graph_util import bbox
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
|
||||||
from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def _anchor(
|
def _anchor(
|
||||||
|
|
@ -17,11 +17,11 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
count_edge_crossings,
|
count_edge_crossings,
|
||||||
segments_properly_intersect,
|
segments_properly_intersect,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.compose_views import (
|
from netx_api.mcp_remote.topology.layout_ops.compose_views import (
|
||||||
ComposeBlock,
|
ComposeBlock,
|
||||||
_membership_counts,
|
_membership_counts,
|
||||||
_portal_grow_order,
|
_portal_grow_order,
|
||||||
|
|
@ -30,7 +30,7 @@ from netx_topology_mcp.layout_ops.compose_views import (
|
||||||
_rigid_align_to_world,
|
_rigid_align_to_world,
|
||||||
compose_params_from_overrides,
|
compose_params_from_overrides,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
_DEFAULT_ANGLE_STEP = 30
|
_DEFAULT_ANGLE_STEP = 30
|
||||||
_DEFAULT_RADII = (0.75, 1.0, 1.25, 1.55, 1.95)
|
_DEFAULT_RADII = (0.75, 1.0, 1.25, 1.55, 1.95)
|
||||||
|
|
@ -581,7 +581,7 @@ def compose_orbit_into_state(
|
||||||
out.meta = dict(out.meta or {})
|
out.meta = dict(out.meta or {})
|
||||||
out.meta["compose_views"] = meta
|
out.meta["compose_views"] = meta
|
||||||
try:
|
try:
|
||||||
from netx_topology_mcp.layout_ops.mass_field import (
|
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
|
||||||
attach_mass_to_compose_meta,
|
attach_mass_to_compose_meta,
|
||||||
build_mass_field,
|
build_mass_field,
|
||||||
)
|
)
|
||||||
|
|
@ -17,8 +17,8 @@ from collections import defaultdict
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
DEFAULT_SLOT_PAD = 600.0
|
DEFAULT_SLOT_PAD = 600.0
|
||||||
|
|
||||||
|
|
@ -733,7 +733,7 @@ def compose_into_state(
|
||||||
out.meta = dict(out.meta or {})
|
out.meta = dict(out.meta or {})
|
||||||
out.meta["compose_views"] = meta
|
out.meta["compose_views"] = meta
|
||||||
try:
|
try:
|
||||||
from netx_topology_mcp.layout_ops.mass_field import (
|
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
|
||||||
attach_mass_to_compose_meta,
|
attach_mass_to_compose_meta,
|
||||||
build_mass_field,
|
build_mass_field,
|
||||||
)
|
)
|
||||||
|
|
@ -10,23 +10,23 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
count_edge_crossings,
|
count_edge_crossings,
|
||||||
crossings_involving_node,
|
crossings_involving_node,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
|
||||||
from netx_topology_mcp.layout_ops.orbit_sweep import (
|
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
|
||||||
_diversify_top,
|
_diversify_top,
|
||||||
_eval_candidate,
|
_eval_candidate,
|
||||||
_incident_stretch,
|
_incident_stretch,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.rigid_units import (
|
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
|
||||||
frozen_ids_for_protect,
|
frozen_ids_for_protect,
|
||||||
groups_from_compose_meta,
|
groups_from_compose_meta,
|
||||||
shrink_long_corridors,
|
shrink_long_corridors,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.score import score_state
|
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
_MAX_PULL = 900.0
|
_MAX_PULL = 900.0
|
||||||
_MAX_PULL_CAP = 2200.0
|
_MAX_PULL_CAP = 2200.0
|
||||||
|
|
@ -17,22 +17,22 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.compose_orbit import orbit_pack_blocks
|
from netx_api.mcp_remote.topology.layout_ops.compose_orbit import orbit_pack_blocks
|
||||||
from netx_topology_mcp.layout_ops.compose_views import ComposeBlock, _rigid_align_to_world
|
from netx_api.mcp_remote.topology.layout_ops.compose_views import ComposeBlock, _rigid_align_to_world
|
||||||
from netx_topology_mcp.layout_ops.dual_units import (
|
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
|
||||||
DualUnit,
|
DualUnit,
|
||||||
beautify_dual_unit_positions,
|
beautify_dual_unit_positions,
|
||||||
classify_dual_unit,
|
classify_dual_unit,
|
||||||
find_dual_portal_units,
|
find_dual_portal_units,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||||
from netx_topology_mcp.layout_ops.mass_field import (
|
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
|
||||||
attach_mass_to_compose_meta,
|
attach_mass_to_compose_meta,
|
||||||
build_mass_field,
|
build_mass_field,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.mass_merge import mass_merge_round
|
from netx_api.mcp_remote.topology.layout_ops.mass_merge import mass_merge_round
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def _unit_links(
|
def _unit_links(
|
||||||
|
|
@ -17,11 +17,11 @@ from collections import deque
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||||
from netx_topology_mcp.layout_ops.min_rings import cover_hub_paths
|
from netx_api.mcp_remote.topology.layout_ops.min_rings import cover_hub_paths
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_ops.sugiyama import _find_two_portal_ring_groups
|
from netx_api.mcp_remote.topology.layout_ops.sugiyama import _find_two_portal_ring_groups
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
|
|
@ -756,7 +756,7 @@ def _uncross_unit(
|
||||||
preserve_side: bool = True,
|
preserve_side: bool = True,
|
||||||
) -> dict[str, tuple[float, float]]:
|
) -> dict[str, tuple[float, float]]:
|
||||||
"""Greedy: nudge free endpoints to kill crossings without flipping eye sides."""
|
"""Greedy: nudge free endpoints to kill crossings without flipping eye sides."""
|
||||||
from netx_topology_mcp.layout_metrics import segments_properly_intersect
|
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
|
||||||
|
|
||||||
out = dict(pos)
|
out = dict(pos)
|
||||||
deg: dict[str, int] = {}
|
deg: dict[str, int] = {}
|
||||||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def _deg(st: LayoutState) -> dict[str, int]:
|
def _deg(st: LayoutState) -> dict[str, int]:
|
||||||
|
|
@ -21,13 +21,13 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
|
||||||
from netx_topology_mcp.layout_ops.rigid_units import (
|
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
|
||||||
frozen_ids_for_protect,
|
frozen_ids_for_protect,
|
||||||
groups_from_compose_meta,
|
groups_from_compose_meta,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.score import score_state
|
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
||||||
|
|
||||||
_COORD_ABS_MAX = 1.0e6
|
_COORD_ABS_MAX = 1.0e6
|
||||||
_DEFAULT_ITERS = 12
|
_DEFAULT_ITERS = 12
|
||||||
|
|
@ -299,7 +299,7 @@ def force_densify_round(
|
||||||
damping: float = 0.85,
|
damping: float = 0.85,
|
||||||
) -> OpResult:
|
) -> OpResult:
|
||||||
"""Run damped attract/repulse densify with semi-rigid dual-unit bodies."""
|
"""Run damped attract/repulse densify with semi-rigid dual-unit bodies."""
|
||||||
from netx_topology_mcp.layout_jobs import (
|
from netx_api.mcp_remote.topology.layout_jobs import (
|
||||||
raise_if_cancelled,
|
raise_if_cancelled,
|
||||||
report_progress,
|
report_progress,
|
||||||
touch_heartbeat,
|
touch_heartbeat,
|
||||||
|
|
@ -384,7 +384,7 @@ def force_densify_round(
|
||||||
best_meta_util = util_proxy0
|
best_meta_util = util_proxy0
|
||||||
best_iter = 0
|
best_iter = 0
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
|
|
||||||
for it in range(n_iters):
|
for it in range(n_iters):
|
||||||
raise_if_cancelled()
|
raise_if_cancelled()
|
||||||
|
|
@ -4,7 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.level_util import infer_layer
|
from netx_api.mcp_remote.topology.layout_ops.level_util import infer_layer
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"infer_layer",
|
"infer_layer",
|
||||||
|
|
@ -147,9 +147,9 @@ def spine_backbone(
|
||||||
def build_state_from_nodes_edges(
|
def build_state_from_nodes_edges(
|
||||||
nodes: list[dict], edges: list[dict]
|
nodes: list[dict], edges: list[dict]
|
||||||
) -> "LayoutState": # noqa: F821
|
) -> "LayoutState": # noqa: F821
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||||
from netx_topology_mcp.layout_metrics import collapse_links
|
from netx_api.mcp_remote.topology.layout_metrics import collapse_links
|
||||||
from netx_topology_mcp.layout_ops.level_util import _parse_level
|
from netx_api.mcp_remote.topology.layout_ops.level_util import _parse_level
|
||||||
|
|
||||||
names: dict[str, str] = {}
|
names: dict[str, str] = {}
|
||||||
layers: dict[str, str] = {}
|
layers: dict[str, str] = {}
|
||||||
|
|
@ -16,10 +16,10 @@ import math
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings, segments_properly_intersect
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings, segments_properly_intersect
|
||||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||||
from netx_topology_mcp.layout_ops.partition import pick_hub_seeds
|
from netx_api.mcp_remote.topology.layout_ops.partition import pick_hub_seeds
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def _ang(pos: dict[str, tuple[float, float]], hub: str, nid: str) -> float:
|
def _ang(pos: dict[str, tuple[float, float]], hub: str, nid: str) -> float:
|
||||||
|
|
@ -7,10 +7,10 @@ from collections import defaultdict
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint
|
from netx_api.mcp_remote.topology.layout_metrics import REC_CENTER_DX, REC_CENTER_DY, node_footprint
|
||||||
from netx_topology_mcp.layout_ops.scope import select_scope
|
from netx_api.mcp_remote.topology.layout_ops.scope import select_scope
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_ops.transforms import (
|
from netx_api.mcp_remote.topology.layout_ops.transforms import (
|
||||||
explode_clusters,
|
explode_clusters,
|
||||||
pack_utilization,
|
pack_utilization,
|
||||||
resolve_overlaps,
|
resolve_overlaps,
|
||||||
|
|
@ -119,7 +119,7 @@ def sprawled_component_scopes(
|
||||||
max_size: int = 80,
|
max_size: int = 80,
|
||||||
) -> list[set[str]]:
|
) -> list[set[str]]:
|
||||||
"""Graph components with low util — skip huge ones (those need layout, not local pack)."""
|
"""Graph components with low util — skip huge ones (those need layout, not local pack)."""
|
||||||
from netx_topology_mcp.layout_ops.graph_util import bbox, connected_components
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox, connected_components
|
||||||
|
|
||||||
comps = connected_components(set(state.positions), state.adj)
|
comps = connected_components(set(state.positions), state.adj)
|
||||||
out: list[set[str]] = []
|
out: list[set[str]] = []
|
||||||
|
|
@ -197,7 +197,7 @@ def fix_overlaps_local(
|
||||||
state: LayoutState, params: LayoutParams | None = None
|
state: LayoutState, params: LayoutParams | None = None
|
||||||
) -> OpResult:
|
) -> OpResult:
|
||||||
"""Pull apart only overlapping nodes (+1-hop). No global scale/pack."""
|
"""Pull apart only overlapping nodes (+1-hop). No global scale/pack."""
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
|
|
||||||
params = params or LayoutParams()
|
params = params or LayoutParams()
|
||||||
st = state.copy()
|
st = state.copy()
|
||||||
|
|
@ -276,7 +276,7 @@ def fix_overlaps_local(
|
||||||
|
|
||||||
|
|
||||||
def _block_util(state: LayoutState, block: set[str]) -> float:
|
def _block_util(state: LayoutState, block: set[str]) -> float:
|
||||||
from netx_topology_mcp.layout_ops.graph_util import bbox
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
|
||||||
|
|
||||||
if len(block) < 2:
|
if len(block) < 2:
|
||||||
return 1.0
|
return 1.0
|
||||||
|
|
@ -313,7 +313,7 @@ def relax_hotspots(
|
||||||
st, hops=1, dense_min=4, close_thr=100.0, target_util=params.target_util
|
st, hops=1, dense_min=4, close_thr=100.0, target_util=params.target_util
|
||||||
)
|
)
|
||||||
if not scopes:
|
if not scopes:
|
||||||
from netx_topology_mcp.layout_ops.scope import list_blocks
|
from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks
|
||||||
|
|
||||||
scopes = [b for b in list_blocks(st, mode="component") if 4 <= len(b) <= 64]
|
scopes = [b for b in list_blocks(st, mode="component") if 4 <= len(b) <= 64]
|
||||||
|
|
||||||
|
|
@ -6,7 +6,7 @@ import math
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
_ROLE_TO_LAYER = {
|
_ROLE_TO_LAYER = {
|
||||||
|
|
@ -9,8 +9,8 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.dual_units import DualUnit, find_dual_portal_units
|
from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit, find_dual_portal_units
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||||
|
|
||||||
# role -> (attract, repulse, mass)
|
# role -> (attract, repulse, mass)
|
||||||
_ROLE_NODE: dict[str, tuple[float, float, float]] = {
|
_ROLE_NODE: dict[str, tuple[float, float, float]] = {
|
||||||
|
|
@ -15,13 +15,13 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.force_densify import (
|
from netx_api.mcp_remote.topology.layout_ops.force_densify import (
|
||||||
_MAX_EDGE_PULL,
|
_MAX_EDGE_PULL,
|
||||||
_bbox_area,
|
_bbox_area,
|
||||||
_spatial_bins,
|
_spatial_bins,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.mass_field import (
|
from netx_api.mcp_remote.topology.layout_ops.mass_field import (
|
||||||
build_mass_field,
|
build_mass_field,
|
||||||
capture_pass,
|
capture_pass,
|
||||||
core_centroid,
|
core_centroid,
|
||||||
|
|
@ -30,9 +30,9 @@ from netx_topology_mcp.layout_ops.mass_field import (
|
||||||
group_effective_mass,
|
group_effective_mass,
|
||||||
groups_from_mass_or_rigid,
|
groups_from_mass_or_rigid,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect
|
from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect
|
||||||
from netx_topology_mcp.layout_ops.score import score_state
|
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
||||||
|
|
||||||
_COORD_ABS_MAX = 1.0e6
|
_COORD_ABS_MAX = 1.0e6
|
||||||
_STEP_CAP = {
|
_STEP_CAP = {
|
||||||
|
|
@ -599,7 +599,7 @@ def mass_merge_round(
|
||||||
Islands = dual-unit groups; soft global gravity keeps the **cloud of
|
Islands = dual-unit groups; soft global gravity keeps the **cloud of
|
||||||
islands** roughly round (repulsion + edge traction), not a strip.
|
islands** roughly round (repulsion + edge traction), not a strip.
|
||||||
"""
|
"""
|
||||||
from netx_topology_mcp.layout_jobs import (
|
from netx_api.mcp_remote.topology.layout_jobs import (
|
||||||
raise_if_cancelled,
|
raise_if_cancelled,
|
||||||
report_progress,
|
report_progress,
|
||||||
touch_heartbeat,
|
touch_heartbeat,
|
||||||
|
|
@ -12,8 +12,8 @@ import math
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def _all_simple_paths(
|
def _all_simple_paths(
|
||||||
|
|
@ -11,7 +11,7 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
count_edge_crossings,
|
count_edge_crossings,
|
||||||
crossing_participation,
|
crossing_participation,
|
||||||
crossing_participation_full,
|
crossing_participation_full,
|
||||||
|
|
@ -20,7 +20,7 @@ from netx_topology_mcp.layout_metrics import (
|
||||||
top_crossing_edges,
|
top_crossing_edges,
|
||||||
top_crossing_nodes,
|
top_crossing_nodes,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
_MAX_JUMP = 900.0
|
_MAX_JUMP = 900.0
|
||||||
# Long fabric bridges on giant metros often exceed 5k; cap must leave room for
|
# Long fabric bridges on giant metros often exceed 5k; cap must leave room for
|
||||||
|
|
@ -201,7 +201,7 @@ def _resolve_frozen(
|
||||||
return set()
|
return set()
|
||||||
frozen: set[str] = set(frozen_ids or ())
|
frozen: set[str] = set(frozen_ids or ())
|
||||||
if not frozen:
|
if not frozen:
|
||||||
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect
|
from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect
|
||||||
|
|
||||||
frozen = frozen_ids_for_protect(st, protect_rigid)
|
frozen = frozen_ids_for_protect(st, protect_rigid)
|
||||||
return frozen
|
return frozen
|
||||||
|
|
@ -548,7 +548,7 @@ def _local_clearance_hits(
|
||||||
endpoint_t: float = 0.05,
|
endpoint_t: float = 0.05,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Fast hit count affected by moving ``nid`` (O(E + N·deg), not O(N·E))."""
|
"""Fast hit count affected by moving ``nid`` (O(E + N·deg), not O(N·E))."""
|
||||||
from netx_topology_mcp.layout_metrics import point_segment_dist
|
from netx_api.mcp_remote.topology.layout_metrics import point_segment_dist
|
||||||
|
|
||||||
if nid not in pos:
|
if nid not in pos:
|
||||||
return 0
|
return 0
|
||||||
|
|
@ -1188,7 +1188,7 @@ def _until_limit_queue(
|
||||||
base_hits = max(hits, prev[0] if prev else 0)
|
base_hits = max(hits, prev[0] if prev else 0)
|
||||||
scored[nid] = (base_hits, deg, 1, prev[3] if prev else 0)
|
scored[nid] = (base_hits, deg, 1, prev[3] if prev else 0)
|
||||||
if include_clearance:
|
if include_clearance:
|
||||||
from netx_topology_mcp.layout_metrics import compute_edge_clearance
|
from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance
|
||||||
|
|
||||||
ec = compute_edge_clearance(pos, links, names=names, top_n=24)
|
ec = compute_edge_clearance(pos, links, names=names, top_n=24)
|
||||||
if not ec.get("edge_clearance_skipped"):
|
if not ec.get("edge_clearance_skipped"):
|
||||||
|
|
@ -1302,7 +1302,7 @@ def orbit_sweep_until_limit(
|
||||||
while len(moves) < max_moves_i and stall < stall_lim:
|
while len(moves) < max_moves_i and stall < stall_lim:
|
||||||
rounds += 1
|
rounds += 1
|
||||||
try:
|
try:
|
||||||
from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat
|
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat
|
||||||
|
|
||||||
touch_heartbeat()
|
touch_heartbeat()
|
||||||
raise_if_cancelled()
|
raise_if_cancelled()
|
||||||
|
|
@ -1389,12 +1389,12 @@ def orbit_sweep_until_limit(
|
||||||
break
|
break
|
||||||
|
|
||||||
if not applied and use_bundle and not bundle_exhausted:
|
if not applied and use_bundle and not bundle_exhausted:
|
||||||
from netx_topology_mcp.layout_ops.bundle_orbit import (
|
from netx_api.mcp_remote.topology.layout_ops.bundle_orbit import (
|
||||||
bundle_orbit_until_progress,
|
bundle_orbit_until_progress,
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat
|
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat
|
||||||
|
|
||||||
touch_heartbeat()
|
touch_heartbeat()
|
||||||
raise_if_cancelled()
|
raise_if_cancelled()
|
||||||
|
|
@ -12,16 +12,16 @@ from __future__ import annotations
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
REC_CENTER_DX,
|
REC_CENTER_DX,
|
||||||
REC_CENTER_DY,
|
REC_CENTER_DY,
|
||||||
count_edge_crossings,
|
count_edge_crossings,
|
||||||
node_footprint,
|
node_footprint,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.channels import extract_channels
|
from netx_api.mcp_remote.topology.layout_ops.channels import extract_channels
|
||||||
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_ops.transforms import normalize_origin
|
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
|
||||||
|
|
||||||
_TOL_PX = 8.0
|
_TOL_PX = 8.0
|
||||||
|
|
||||||
|
|
@ -39,7 +39,7 @@ def _segment_clear(
|
||||||
skip: set[str] | None = None,
|
skip: set[str] | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""True if no other node sits on the open segment a—b (edge occlusion)."""
|
"""True if no other node sits on the open segment a—b (edge occlusion)."""
|
||||||
from netx_topology_mcp.layout_metrics import point_segment_dist
|
from netx_api.mcp_remote.topology.layout_metrics import point_segment_dist
|
||||||
|
|
||||||
if a not in pos or b not in pos:
|
if a not in pos or b not in pos:
|
||||||
return True
|
return True
|
||||||
|
|
@ -930,7 +930,7 @@ def _place_component(
|
||||||
if _segment_clear(pos, a, b, thr=40.0):
|
if _segment_clear(pos, a, b, thr=40.0):
|
||||||
continue
|
continue
|
||||||
# Collect obstacles on open segment
|
# Collect obstacles on open segment
|
||||||
from netx_topology_mcp.layout_metrics import point_segment_dist
|
from netx_api.mcp_remote.topology.layout_metrics import point_segment_dist
|
||||||
|
|
||||||
pa, pb = pos[a], pos[b]
|
pa, pb = pos[a], pos[b]
|
||||||
obstacles = []
|
obstacles = []
|
||||||
|
|
@ -1086,7 +1086,7 @@ def build_ortho_metro_skeleton(
|
||||||
st = separate_overlaps_ortho(st, params).state
|
st = separate_overlaps_ortho(st, params).state
|
||||||
|
|
||||||
# Nodes must not sit on non-incident H/V trunks (edge occlusion).
|
# Nodes must not sit on non-incident H/V trunks (edge occlusion).
|
||||||
from netx_topology_mcp.layout_ops.clear_edge_hits import clear_edge_hits
|
from netx_api.mcp_remote.topology.layout_ops.clear_edge_hits import clear_edge_hits
|
||||||
|
|
||||||
side = max(float(params.side), REC_CENTER_DY)
|
side = max(float(params.side), REC_CENTER_DY)
|
||||||
clr = clear_edge_hits(
|
clr = clear_edge_hits(
|
||||||
|
|
@ -1114,7 +1114,7 @@ def build_ortho_metro_skeleton(
|
||||||
else:
|
else:
|
||||||
diag_n += 1
|
diag_n += 1
|
||||||
cross = count_edge_crossings(st.positions, st.links)
|
cross = count_edge_crossings(st.positions, st.links)
|
||||||
from netx_topology_mcp.layout_metrics import compute_edge_clearance
|
from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance
|
||||||
|
|
||||||
clr_m = compute_edge_clearance(st.positions, st.links, names=st.names, thr=40.0)
|
clr_m = compute_edge_clearance(st.positions, st.links, names=st.names, thr=40.0)
|
||||||
st.meta = dict(st.meta or {})
|
st.meta = dict(st.meta or {})
|
||||||
|
|
@ -17,8 +17,8 @@ from collections import defaultdict, deque
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.graph_util import connected_components
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import connected_components
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
# Preferred soft-block step when packing centers (px).
|
# Preferred soft-block step when packing centers (px).
|
||||||
_BLOCK_GAP = 420.0
|
_BLOCK_GAP = 420.0
|
||||||
|
|
@ -437,7 +437,7 @@ def partition_report(state: LayoutState, *, mode: str = "soft") -> dict[str, Any
|
||||||
"tip": (
|
"tip": (
|
||||||
"Soft blocks for init only: lay each hub territory / leftover with our "
|
"Soft blocks for init only: lay each hub territory / leftover with our "
|
||||||
"petal/spine algorithms; use center_hint to pack blocks. "
|
"petal/spine algorithms; use center_hint to pack blocks. "
|
||||||
"igraph Leiden/FR are optional — install netx-topology-mcp[layout]."
|
"igraph Leiden/FR are optional extras when available."
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -5,12 +5,12 @@ from __future__ import annotations
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||||
from netx_topology_mcp.layout_ops.partition import partition_soft_blocks
|
from netx_api.mcp_remote.topology.layout_ops.partition import partition_soft_blocks
|
||||||
from netx_topology_mcp.layout_ops.score import score_state
|
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_ops.transforms import normalize_origin
|
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
|
||||||
|
|
||||||
|
|
||||||
def _hops_to_cores(
|
def _hops_to_cores(
|
||||||
|
|
@ -14,7 +14,7 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
count_edge_crossings,
|
count_edge_crossings,
|
||||||
crossings_after_node_move,
|
crossings_after_node_move,
|
||||||
crossings_involving_node,
|
crossings_involving_node,
|
||||||
|
|
@ -23,8 +23,8 @@ from netx_topology_mcp.layout_metrics import (
|
||||||
segments_properly_intersect,
|
segments_properly_intersect,
|
||||||
top_crossing_edges,
|
top_crossing_edges,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_ops.untangle import untangle_crossings
|
from netx_api.mcp_remote.topology.layout_ops.untangle import untangle_crossings
|
||||||
|
|
||||||
|
|
||||||
def park_phantom_nodes(state: LayoutState) -> set[str]:
|
def park_phantom_nodes(state: LayoutState) -> set[str]:
|
||||||
|
|
@ -76,7 +76,7 @@ def _rotate_about(
|
||||||
def frozen_portals_from_state(
|
def frozen_portals_from_state(
|
||||||
state: LayoutState, portal_ids: list[str] | None = None
|
state: LayoutState, portal_ids: list[str] | None = None
|
||||||
) -> set[str]:
|
) -> set[str]:
|
||||||
from netx_topology_mcp.layout_ops.rigid_units import (
|
from netx_api.mcp_remote.topology.layout_ops.rigid_units import (
|
||||||
_hub_portals,
|
_hub_portals,
|
||||||
frozen_ids_for_protect,
|
frozen_ids_for_protect,
|
||||||
)
|
)
|
||||||
|
|
@ -174,7 +174,7 @@ def _large_graph_budget(n_links: int) -> dict[str, Any]:
|
||||||
def _has_petal_dual_eye(state: LayoutState) -> bool:
|
def _has_petal_dual_eye(state: LayoutState) -> bool:
|
||||||
"""True when a multi-corridor dual-unit covers a large share of the canvas."""
|
"""True when a multi-corridor dual-unit covers a large share of the canvas."""
|
||||||
try:
|
try:
|
||||||
from netx_topology_mcp.layout_ops.dual_units import (
|
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
|
||||||
classify_dual_unit,
|
classify_dual_unit,
|
||||||
find_dual_portal_units,
|
find_dual_portal_units,
|
||||||
)
|
)
|
||||||
|
|
@ -202,7 +202,7 @@ def press_hot_edges(
|
||||||
max_sweeps: int | None = None,
|
max_sweeps: int | None = None,
|
||||||
) -> OpResult:
|
) -> OpResult:
|
||||||
"""Rotate non-portal ends of top crossing edges about the other endpoint."""
|
"""Rotate non-portal ends of top crossing edges about the other endpoint."""
|
||||||
from netx_topology_mcp.layout_jobs import raise_if_cancelled, touch_heartbeat
|
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, touch_heartbeat
|
||||||
|
|
||||||
del params
|
del params
|
||||||
st = state.copy()
|
st = state.copy()
|
||||||
|
|
@ -620,7 +620,7 @@ def polish_crossings(
|
||||||
Petal dual-unit eyes: straighten stays off unless ``straighten=true`` —
|
Petal dual-unit eyes: straighten stays off unless ``straighten=true`` —
|
||||||
channel flattening destroys parallel-lane eye geometry.
|
channel flattening destroys parallel-lane eye geometry.
|
||||||
"""
|
"""
|
||||||
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress
|
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress
|
||||||
|
|
||||||
params = params or LayoutParams()
|
params = params or LayoutParams()
|
||||||
st = state.copy()
|
st = state.copy()
|
||||||
|
|
@ -631,7 +631,7 @@ def polish_crossings(
|
||||||
eye_portals: list[str] = []
|
eye_portals: list[str] = []
|
||||||
if preserve:
|
if preserve:
|
||||||
try:
|
try:
|
||||||
from netx_topology_mcp.layout_ops.dual_units import (
|
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
|
||||||
classify_dual_unit,
|
classify_dual_unit,
|
||||||
find_dual_portal_units,
|
find_dual_portal_units,
|
||||||
)
|
)
|
||||||
|
|
@ -685,7 +685,7 @@ def polish_crossings(
|
||||||
moved: set[str] = set()
|
moved: set[str] = set()
|
||||||
|
|
||||||
if do_straighten:
|
if do_straighten:
|
||||||
from netx_topology_mcp.layout_ops.channels import straighten_channels_greedy
|
from netx_api.mcp_remote.topology.layout_ops.channels import straighten_channels_greedy
|
||||||
|
|
||||||
raise_if_cancelled()
|
raise_if_cancelled()
|
||||||
report_progress("polish_straighten", pct=48.0, message=f"x0={start}")
|
report_progress("polish_straighten", pct=48.0, message=f"x0={start}")
|
||||||
|
|
@ -11,11 +11,11 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings, compute_edge_clearance
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings, compute_edge_clearance
|
||||||
from netx_topology_mcp.layout_ops.graph_util import bbox
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
|
||||||
from netx_topology_mcp.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import _has_any_footprint_overlap
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_topology_quality import extract_chain_paths
|
from netx_api.mcp_remote.topology.layout_topology_quality import extract_chain_paths
|
||||||
|
|
||||||
|
|
||||||
def _anchor(pos: dict[str, tuple[float, float]], frozen: set[str]) -> tuple[float, float]:
|
def _anchor(pos: dict[str, tuple[float, float]], frozen: set[str]) -> tuple[float, float]:
|
||||||
|
|
@ -5,15 +5,15 @@ from __future__ import annotations
|
||||||
from dataclasses import asdict
|
from dataclasses import asdict
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.score import score_op, score_state
|
from netx_api.mcp_remote.topology.layout_ops.score import score_op, score_state
|
||||||
from netx_topology_mcp.layout_ops.scope import list_blocks, map_blocks, select_scope
|
from netx_api.mcp_remote.topology.layout_ops.scope import list_blocks, map_blocks, select_scope
|
||||||
from netx_topology_mcp.layout_ops.sides import place_side_branches
|
from netx_api.mcp_remote.topology.layout_ops.sides import place_side_branches
|
||||||
from netx_topology_mcp.layout_ops.skeleton import build_skeleton
|
from netx_api.mcp_remote.topology.layout_ops.skeleton import build_skeleton
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local, relax_hotspots
|
||||||
from netx_topology_mcp.layout_ops.rings import build_ring_skeleton
|
from netx_api.mcp_remote.topology.layout_ops.rings import build_ring_skeleton
|
||||||
from netx_topology_mcp.layout_ops.partition import pack_soft_blocks
|
from netx_api.mcp_remote.topology.layout_ops.partition import pack_soft_blocks
|
||||||
from netx_topology_mcp.layout_ops.transforms import (
|
from netx_api.mcp_remote.topology.layout_ops.transforms import (
|
||||||
enforce_min_gap,
|
enforce_min_gap,
|
||||||
explode_clusters,
|
explode_clusters,
|
||||||
normalize_origin,
|
normalize_origin,
|
||||||
|
|
@ -234,7 +234,7 @@ def _expand_foreach(
|
||||||
min_size = int(kwargs.get("min_size") or 2)
|
min_size = int(kwargs.get("min_size") or 2)
|
||||||
blocks = list_blocks(st, mode=mode)
|
blocks = list_blocks(st, mode=mode)
|
||||||
# Record resolved mode (auto → hub_territory|component) for agents/trace.
|
# Record resolved mode (auto → hub_territory|component) for agents/trace.
|
||||||
from netx_topology_mcp.layout_ops.partition import resolve_block_mode
|
from netx_api.mcp_remote.topology.layout_ops.partition import resolve_block_mode
|
||||||
|
|
||||||
resolved_mode = resolve_block_mode(st, mode) if mode == "auto" else mode
|
resolved_mode = resolve_block_mode(st, mode) if mode == "auto" else mode
|
||||||
st = st.copy()
|
st = st.copy()
|
||||||
|
|
@ -14,12 +14,12 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.compose_orbit import crossings_touching
|
from netx_api.mcp_remote.topology.layout_ops.compose_orbit import crossings_touching
|
||||||
from netx_topology_mcp.layout_ops.hotspots import overlapping_nodes
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import overlapping_nodes
|
||||||
from netx_topology_mcp.layout_ops.rigid_units import groups_from_compose_meta
|
from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_compose_meta
|
||||||
from netx_topology_mcp.layout_ops.score import score_state
|
from netx_api.mcp_remote.topology.layout_ops.score import score_state
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
||||||
|
|
||||||
_COORD_ABS_MAX = 1.0e6
|
_COORD_ABS_MAX = 1.0e6
|
||||||
_DEFAULT_ANGLE_STEP = 30
|
_DEFAULT_ANGLE_STEP = 30
|
||||||
|
|
@ -277,7 +277,7 @@ def rigid_orbit_round(
|
||||||
max_accepts: int = 16,
|
max_accepts: int = 16,
|
||||||
) -> OpResult:
|
) -> OpResult:
|
||||||
"""Greedy bridge-orbit densify over top rigid groups."""
|
"""Greedy bridge-orbit densify over top rigid groups."""
|
||||||
from netx_topology_mcp.layout_jobs import (
|
from netx_api.mcp_remote.topology.layout_jobs import (
|
||||||
raise_if_cancelled,
|
raise_if_cancelled,
|
||||||
report_progress,
|
report_progress,
|
||||||
touch_heartbeat,
|
touch_heartbeat,
|
||||||
|
|
@ -10,9 +10,9 @@ import math
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.graph_util import connected_components
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import connected_components
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
# Reject / ignore coords that would blow util (phantom region markers, bugs).
|
# Reject / ignore coords that would blow util (phantom region markers, bugs).
|
||||||
_COORD_ABS_MAX = 1.0e6
|
_COORD_ABS_MAX = 1.0e6
|
||||||
|
|
@ -9,9 +9,9 @@ import math
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import segments_properly_intersect
|
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||||
from netx_topology_mcp.layout_topology_quality import (
|
from netx_api.mcp_remote.topology.layout_topology_quality import (
|
||||||
RING_MAX_LEN,
|
RING_MAX_LEN,
|
||||||
find_short_chordless_cycles,
|
find_short_chordless_cycles,
|
||||||
)
|
)
|
||||||
|
|
@ -7,9 +7,9 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.min_rings import build_min_ring_skeleton
|
from netx_api.mcp_remote.topology.layout_ops.min_rings import build_min_ring_skeleton
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_ops.sugiyama import build_sugiyama_layout
|
from netx_api.mcp_remote.topology.layout_ops.sugiyama import build_sugiyama_layout
|
||||||
|
|
||||||
|
|
||||||
def build_ring_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult:
|
def build_ring_skeleton(state: LayoutState, params: LayoutParams | None = None) -> OpResult:
|
||||||
|
|
@ -4,8 +4,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any, Iterable
|
from typing import Any, Iterable
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.graph_util import bbox, connected_components
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox, connected_components
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def active_nodes(state: LayoutState) -> set[str]:
|
def active_nodes(state: LayoutState) -> set[str]:
|
||||||
|
|
@ -41,7 +41,7 @@ def list_blocks(
|
||||||
ids = set(state.positions.keys())
|
ids = set(state.positions.keys())
|
||||||
m = (mode or "component").strip().lower()
|
m = (mode or "component").strip().lower()
|
||||||
if m == "auto":
|
if m == "auto":
|
||||||
from netx_topology_mcp.layout_ops.partition import resolve_block_mode
|
from netx_api.mcp_remote.topology.layout_ops.partition import resolve_block_mode
|
||||||
|
|
||||||
m = resolve_block_mode(state, "auto")
|
m = resolve_block_mode(state, "auto")
|
||||||
if m == "component":
|
if m == "component":
|
||||||
|
|
@ -58,7 +58,7 @@ def list_blocks(
|
||||||
blocks.append(rest)
|
blocks.append(rest)
|
||||||
return blocks
|
return blocks
|
||||||
if m in {"hub_territory", "leiden", "soft"}:
|
if m in {"hub_territory", "leiden", "soft"}:
|
||||||
from netx_topology_mcp.layout_ops.partition import (
|
from netx_api.mcp_remote.topology.layout_ops.partition import (
|
||||||
partition_soft_blocks,
|
partition_soft_blocks,
|
||||||
soft_blocks_as_sets,
|
soft_blocks_as_sets,
|
||||||
)
|
)
|
||||||
|
|
@ -4,8 +4,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState, OpResult
|
||||||
from netx_topology_mcp.layout_stats import analyze_layout_stats
|
from netx_api.mcp_remote.topology.layout_stats import analyze_layout_stats
|
||||||
|
|
||||||
|
|
||||||
def score_state(
|
def score_state(
|
||||||
|
|
@ -4,8 +4,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
import math
|
import math
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.graph_util import spine_backbone
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import spine_backbone
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def place_side_branches(
|
def place_side_branches(
|
||||||
|
|
@ -7,8 +7,8 @@ from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.dual_units import DualUnit, layout_dual_unit
|
from netx_api.mcp_remote.topology.layout_ops.dual_units import DualUnit, layout_dual_unit
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState
|
||||||
|
|
||||||
|
|
||||||
def _portal_share_counts(units: list[DualUnit]) -> dict[str, int]:
|
def _portal_share_counts(units: list[DualUnit]) -> dict[str, int]:
|
||||||
|
|
@ -271,14 +271,14 @@ def orbit_attach_to_sink(
|
||||||
Scores candidates by (partial crossings, footprint hits, bridge length)
|
Scores candidates by (partial crossings, footprint hits, bridge length)
|
||||||
against the existing sink world — same spirit as compose_orbit hang-block.
|
against the existing sink world — same spirit as compose_orbit hang-block.
|
||||||
"""
|
"""
|
||||||
from netx_topology_mcp.layout_ops.compose_orbit import (
|
from netx_api.mcp_remote.topology.layout_ops.compose_orbit import (
|
||||||
_orbit_candidates_one_portal,
|
_orbit_candidates_one_portal,
|
||||||
_orbit_candidates_orphan,
|
_orbit_candidates_orphan,
|
||||||
_orbit_candidates_two_portal,
|
_orbit_candidates_two_portal,
|
||||||
_pick_best,
|
_pick_best,
|
||||||
_thin_candidates,
|
_thin_candidates,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.compose_views import (
|
from netx_api.mcp_remote.topology.layout_ops.compose_views import (
|
||||||
_rank_shared_pivots,
|
_rank_shared_pivots,
|
||||||
_rigid_align_to_world,
|
_rigid_align_to_world,
|
||||||
)
|
)
|
||||||
|
|
@ -5,14 +5,14 @@ from __future__ import annotations
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from itertools import product
|
from itertools import product
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.graph_util import (
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import (
|
||||||
bbox,
|
bbox,
|
||||||
connected_components,
|
connected_components,
|
||||||
order_ans,
|
order_ans,
|
||||||
spine_backbone,
|
spine_backbone,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def _place_component_tutte(
|
def _place_component_tutte(
|
||||||
|
|
@ -6,11 +6,11 @@ import math
|
||||||
from collections import deque
|
from collections import deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.channels import order_stubs_crossing_aware
|
from netx_api.mcp_remote.topology.layout_ops.channels import order_stubs_crossing_aware
|
||||||
from netx_topology_mcp.layout_ops.hotspots import fix_overlaps_local
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import fix_overlaps_local
|
||||||
from netx_topology_mcp.layout_ops.partition import partition_soft_blocks, pick_hub_seeds
|
from netx_api.mcp_remote.topology.layout_ops.partition import partition_soft_blocks, pick_hub_seeds
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def _longest_path(
|
def _longest_path(
|
||||||
|
|
@ -14,14 +14,14 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings
|
||||||
from netx_topology_mcp.layout_ops.graph_util import (
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import (
|
||||||
bbox,
|
bbox,
|
||||||
chain_order,
|
chain_order,
|
||||||
connected_components,
|
connected_components,
|
||||||
order_ans,
|
order_ans,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
def _nearest_an(
|
def _nearest_an(
|
||||||
n: str, ans: list[str], adj: dict[str, set[str]], names: dict[str, str]
|
n: str, ans: list[str], adj: dict[str, set[str]], names: dict[str, str]
|
||||||
|
|
@ -947,7 +947,7 @@ def _stub_crosses_ring(
|
||||||
order: list[str],
|
order: list[str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""True if stub—portal properly intersects a ring edge (not at portal)."""
|
"""True if stub—portal properly intersects a ring edge (not at portal)."""
|
||||||
from netx_topology_mcp.layout_metrics import segments_properly_intersect
|
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
|
||||||
|
|
||||||
if stub not in pos or portal not in pos or len(order) < 2:
|
if stub not in pos or portal not in pos or len(order) < 2:
|
||||||
return False
|
return False
|
||||||
|
|
@ -1130,7 +1130,7 @@ def _foreign_edge_crossings(
|
||||||
"""Crossings of unit-internal edges by foreign edges (through the unit)."""
|
"""Crossings of unit-internal edges by foreign edges (through the unit)."""
|
||||||
if len(unit_nodes) < 2 or not edges:
|
if len(unit_nodes) < 2 or not edges:
|
||||||
return 0
|
return 0
|
||||||
from netx_topology_mcp.layout_metrics import segments_properly_intersect
|
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
|
||||||
|
|
||||||
internal = [(a, b) for a, b in edges if a in unit_nodes and b in unit_nodes]
|
internal = [(a, b) for a, b in edges if a in unit_nodes and b in unit_nodes]
|
||||||
foreign = [
|
foreign = [
|
||||||
|
|
@ -6,15 +6,15 @@ import math
|
||||||
from collections import defaultdict
|
from collections import defaultdict
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
ICON_SIZE,
|
ICON_SIZE,
|
||||||
REC_CENTER_DX,
|
REC_CENTER_DX,
|
||||||
REC_CENTER_DY,
|
REC_CENTER_DY,
|
||||||
node_footprint,
|
node_footprint,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.graph_util import bbox
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import bbox
|
||||||
from netx_topology_mcp.layout_ops.scope import active_nodes, movable_nodes
|
from netx_api.mcp_remote.topology.layout_ops.scope import active_nodes, movable_nodes
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def select_pins(
|
def select_pins(
|
||||||
|
|
@ -6,14 +6,14 @@ import math
|
||||||
import random
|
import random
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
count_edge_crossings,
|
count_edge_crossings,
|
||||||
crossing_participation,
|
crossing_participation,
|
||||||
crossings_involving_node,
|
crossings_involving_node,
|
||||||
node_footprint,
|
node_footprint,
|
||||||
top_crossing_nodes,
|
top_crossing_nodes,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutParams, LayoutState, OpResult
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutParams, LayoutState, OpResult
|
||||||
|
|
||||||
|
|
||||||
def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]):
|
def _box(nid: str, pos: dict[str, tuple[float, float]], names: dict[str, str]):
|
||||||
|
|
@ -166,13 +166,13 @@ def untangle_crossings(
|
||||||
moved: set[str] = set()
|
moved: set[str] = set()
|
||||||
frozen: set[str] = set(frozen_ids or ())
|
frozen: set[str] = set(frozen_ids or ())
|
||||||
if not frozen and protect_rigid not in (False, "false", "off", "none", "0"):
|
if not frozen and protect_rigid not in (False, "false", "off", "none", "0"):
|
||||||
from netx_topology_mcp.layout_ops.rigid_units import frozen_ids_for_protect
|
from netx_api.mcp_remote.topology.layout_ops.rigid_units import frozen_ids_for_protect
|
||||||
|
|
||||||
frozen = frozen_ids_for_protect(st, protect_rigid)
|
frozen = frozen_ids_for_protect(st, protect_rigid)
|
||||||
faces = None
|
faces = None
|
||||||
pierce0 = 0
|
pierce0 = 0
|
||||||
if protect_rings:
|
if protect_rings:
|
||||||
from netx_topology_mcp.layout_ops.ring_faces import (
|
from netx_api.mcp_remote.topology.layout_ops.ring_faces import (
|
||||||
count_ring_pierces,
|
count_ring_pierces,
|
||||||
extract_ring_faces,
|
extract_ring_faces,
|
||||||
)
|
)
|
||||||
|
|
@ -182,7 +182,7 @@ def untangle_crossings(
|
||||||
|
|
||||||
focus: set[str] = {str(x) for x in (focus_ids or []) if str(x)}
|
focus: set[str] = {str(x) for x in (focus_ids or []) if str(x)}
|
||||||
hit: dict[str, int] = {}
|
hit: dict[str, int] = {}
|
||||||
from netx_topology_mcp.layout_jobs import raise_if_cancelled, report_progress
|
from netx_api.mcp_remote.topology.layout_jobs import raise_if_cancelled, report_progress
|
||||||
|
|
||||||
rounds_total = max(1, int(max_rounds))
|
rounds_total = max(1, int(max_rounds))
|
||||||
for _round in range(rounds_total):
|
for _round in range(rounds_total):
|
||||||
|
|
@ -265,7 +265,7 @@ def untangle_crossings(
|
||||||
if g2 >= cur_c:
|
if g2 >= cur_c:
|
||||||
continue
|
continue
|
||||||
if faces is not None:
|
if faces is not None:
|
||||||
from netx_topology_mcp.layout_ops.ring_faces import count_ring_pierces
|
from netx_api.mcp_remote.topology.layout_ops.ring_faces import count_ring_pierces
|
||||||
|
|
||||||
pierce1 = int(
|
pierce1 = int(
|
||||||
count_ring_pierces(trial, links, faces).get("pierce_crossings")
|
count_ring_pierces(trial, links, faces).get("pierce_crossings")
|
||||||
|
|
@ -287,7 +287,7 @@ def untangle_crossings(
|
||||||
else:
|
else:
|
||||||
hit.pop(node, None)
|
hit.pop(node, None)
|
||||||
if faces is not None:
|
if faces is not None:
|
||||||
from netx_topology_mcp.layout_ops.ring_faces import count_ring_pierces
|
from netx_api.mcp_remote.topology.layout_ops.ring_faces import count_ring_pierces
|
||||||
|
|
||||||
pierce0 = int(
|
pierce0 = int(
|
||||||
count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0
|
count_ring_pierces(pos, links, faces).get("pierce_crossings") or 0
|
||||||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
collapse_links,
|
collapse_links,
|
||||||
segments_properly_intersect,
|
segments_properly_intersect,
|
||||||
top_crossing_edges,
|
top_crossing_edges,
|
||||||
|
|
@ -88,7 +88,7 @@ def list_crossings(
|
||||||
adj[a].add(b)
|
adj[a].add(b)
|
||||||
adj[b].add(a)
|
adj[b].add(a)
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops.orbit_sweep import orbit_lite_suggest
|
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import orbit_lite_suggest
|
||||||
|
|
||||||
pos_xy = {k: (v[0], v[1]) for k, v in pos.items()}
|
pos_xy = {k: (v[0], v[1]) for k, v in pos.items()}
|
||||||
names = {k: v[2] for k, v in pos.items()}
|
names = {k: v[2] for k, v in pos.items()}
|
||||||
|
|
@ -134,7 +134,7 @@ def list_crossings(
|
||||||
# Fallback: when no drag_candidates (all hot nodes are high-degree hubs),
|
# Fallback: when no drag_candidates (all hot nodes are high-degree hubs),
|
||||||
# suggest from edge_clearance.top and crossing top_nodes regardless of degree.
|
# suggest from edge_clearance.top and crossing top_nodes regardless of degree.
|
||||||
if not candidates:
|
if not candidates:
|
||||||
from netx_topology_mcp.layout_metrics import compute_edge_clearance
|
from netx_api.mcp_remote.topology.layout_metrics import compute_edge_clearance
|
||||||
|
|
||||||
ec = compute_edge_clearance(pos_xy, links, names=names, top_n=5)
|
ec = compute_edge_clearance(pos_xy, links, names=names, top_n=5)
|
||||||
for eh in (ec.get("top_edge_hits") or [])[:5]:
|
for eh in (ec.get("top_edge_hits") or [])[:5]:
|
||||||
|
|
@ -9,7 +9,7 @@ from __future__ import annotations
|
||||||
import math
|
import math
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
REC_CENTER_DX,
|
REC_CENTER_DX,
|
||||||
REC_CENTER_DY,
|
REC_CENTER_DY,
|
||||||
analyze_positions,
|
analyze_positions,
|
||||||
|
|
@ -698,12 +698,12 @@ def analyze_layout_stats(
|
||||||
pos[fid] = (x, y)
|
pos[fid] = (x, y)
|
||||||
names[fid] = str(n.get("name") or n.get("label") or fid)
|
names[fid] = str(n.get("name") or n.get("label") or fid)
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import (
|
from netx_api.mcp_remote.topology.layout_metrics import (
|
||||||
collapse_links,
|
collapse_links,
|
||||||
compute_edge_axis,
|
compute_edge_axis,
|
||||||
compute_edge_clearance,
|
compute_edge_clearance,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_topology_quality import (
|
from netx_api.mcp_remote.topology.layout_topology_quality import (
|
||||||
compute_chain_cohesion,
|
compute_chain_cohesion,
|
||||||
compute_topology_quality,
|
compute_topology_quality,
|
||||||
)
|
)
|
||||||
|
|
@ -15,8 +15,8 @@ from collections import defaultdict, deque
|
||||||
from statistics import mean, median
|
from statistics import mean, median
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import collapse_links
|
from netx_api.mcp_remote.topology.layout_metrics import collapse_links
|
||||||
from netx_topology_mcp.layout_ops.graph_util import infer_layer
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import infer_layer
|
||||||
|
|
||||||
|
|
||||||
def _pct(xs: list[int], p: float) -> float:
|
def _pct(xs: list[int], p: float) -> float:
|
||||||
|
|
@ -543,9 +543,9 @@ def analyze_graph_structure(
|
||||||
soft_blocks: dict[str, Any] = {"block_count": 0, "blocks": [], "igraph": False}
|
soft_blocks: dict[str, Any] = {"block_count": 0, "blocks": [], "igraph": False}
|
||||||
dual_units: dict[str, Any] = {"unit_count": 0, "units": []}
|
dual_units: dict[str, Any] = {"unit_count": 0, "units": []}
|
||||||
try:
|
try:
|
||||||
from netx_topology_mcp.layout_ops.dual_units import dual_units_report
|
from netx_api.mcp_remote.topology.layout_ops.dual_units import dual_units_report
|
||||||
from netx_topology_mcp.layout_ops.graph_util import build_state_from_nodes_edges
|
from netx_api.mcp_remote.topology.layout_ops.graph_util import build_state_from_nodes_edges
|
||||||
from netx_topology_mcp.layout_ops.partition import partition_report
|
from netx_api.mcp_remote.topology.layout_ops.partition import partition_report
|
||||||
|
|
||||||
st = build_state_from_nodes_edges(nodes, edges)
|
st = build_state_from_nodes_edges(nodes, edges)
|
||||||
soft_mode = "soft" if advice.get("decompose_soft_blocks") else "hub_territory"
|
soft_mode = "soft" if advice.get("decompose_soft_blocks") else "hub_territory"
|
||||||
|
|
@ -5,59 +5,59 @@ from __future__ import annotations
|
||||||
from dataclasses import replace
|
from dataclasses import replace
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_ops import (
|
from netx_api.mcp_remote.topology.layout_ops import (
|
||||||
LayoutParams,
|
LayoutParams,
|
||||||
build_state_from_nodes_edges,
|
build_state_from_nodes_edges,
|
||||||
positions_for_api,
|
positions_for_api,
|
||||||
run_recipe,
|
run_recipe,
|
||||||
score_state,
|
score_state,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.hotspots import (
|
from netx_api.mcp_remote.topology.layout_ops.hotspots import (
|
||||||
fix_overlaps_local,
|
fix_overlaps_local,
|
||||||
hotspot_scopes,
|
hotspot_scopes,
|
||||||
overlapping_nodes,
|
overlapping_nodes,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.recipe import RECIPES
|
from netx_api.mcp_remote.topology.layout_ops.recipe import RECIPES
|
||||||
from netx_topology_mcp.layout_ops.state import LayoutState
|
from netx_api.mcp_remote.topology.layout_ops.state import LayoutState
|
||||||
from netx_topology_mcp.layout_ops.transforms import normalize_origin
|
from netx_api.mcp_remote.topology.layout_ops.transforms import normalize_origin
|
||||||
from netx_topology_mcp.layout_ops.channels import (
|
from netx_api.mcp_remote.topology.layout_ops.channels import (
|
||||||
straighten_channels_greedy,
|
straighten_channels_greedy,
|
||||||
straighten_params_from_overrides,
|
straighten_params_from_overrides,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.dual_units import (
|
from netx_api.mcp_remote.topology.layout_ops.dual_units import (
|
||||||
dual_unit_params_from_overrides,
|
dual_unit_params_from_overrides,
|
||||||
layout_dual_unit,
|
layout_dual_unit,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.rigid_units import groups_from_membership
|
from netx_api.mcp_remote.topology.layout_ops.rigid_units import groups_from_membership
|
||||||
from netx_topology_mcp.layout_ops.untangle import (
|
from netx_api.mcp_remote.topology.layout_ops.untangle import (
|
||||||
untangle_crossings,
|
untangle_crossings,
|
||||||
untangle_params_from_overrides,
|
untangle_params_from_overrides,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.press_crossings import (
|
from netx_api.mcp_remote.topology.layout_ops.press_crossings import (
|
||||||
park_phantom_nodes,
|
park_phantom_nodes,
|
||||||
polish_crossings,
|
polish_crossings,
|
||||||
press_params_from_overrides,
|
press_params_from_overrides,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.clear_edge_hits import (
|
from netx_api.mcp_remote.topology.layout_ops.clear_edge_hits import (
|
||||||
clear_edge_hits,
|
clear_edge_hits,
|
||||||
clear_edge_params_from_overrides,
|
clear_edge_params_from_overrides,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.compact_bbox import (
|
from netx_api.mcp_remote.topology.layout_ops.compact_bbox import (
|
||||||
compact_bbox,
|
compact_bbox,
|
||||||
compact_bbox_params_from_overrides,
|
compact_bbox_params_from_overrides,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.pull_far_chains import (
|
from netx_api.mcp_remote.topology.layout_ops.pull_far_chains import (
|
||||||
pull_far_chains,
|
pull_far_chains,
|
||||||
pull_far_chains_params_from_overrides,
|
pull_far_chains_params_from_overrides,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.orbit_sweep import (
|
from netx_api.mcp_remote.topology.layout_ops.orbit_sweep import (
|
||||||
apply_orbit_pick,
|
apply_orbit_pick,
|
||||||
orbit_params_from_overrides,
|
orbit_params_from_overrides,
|
||||||
orbit_sweep_node,
|
orbit_sweep_node,
|
||||||
orbit_sweep_round,
|
orbit_sweep_round,
|
||||||
orbit_sweep_until_limit,
|
orbit_sweep_until_limit,
|
||||||
)
|
)
|
||||||
from netx_topology_mcp.layout_ops.level_util import (
|
from netx_api.mcp_remote.topology.layout_ops.level_util import (
|
||||||
apply_level_bands,
|
apply_level_bands,
|
||||||
level_bands_params_from_overrides,
|
level_bands_params_from_overrides,
|
||||||
)
|
)
|
||||||
|
|
@ -385,7 +385,7 @@ def run_layout_on_graph(
|
||||||
# layout; local jumps are capped inside untangle_crossings.
|
# layout; local jumps are capped inside untangle_crossings.
|
||||||
st = normalize_origin(op.state, base_params).state
|
st = normalize_origin(op.state, base_params).state
|
||||||
# Overlap crush must not erase untangle gains.
|
# Overlap crush must not erase untangle gains.
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx
|
||||||
|
|
||||||
x_before_fix = _cx(st.positions, st.links)
|
x_before_fix = _cx(st.positions, st.links)
|
||||||
st2, fix_meta = _ensure_zero_overlap(st, base_params)
|
st2, fix_meta = _ensure_zero_overlap(st, base_params)
|
||||||
|
|
@ -673,7 +673,7 @@ def run_layout_on_graph(
|
||||||
max_eject_degree=int(knobs.get("max_eject_degree") or 5),
|
max_eject_degree=int(knobs.get("max_eject_degree") or 5),
|
||||||
)
|
)
|
||||||
st = normalize_origin(op.state, base_params).state
|
st = normalize_origin(op.state, base_params).state
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx
|
||||||
|
|
||||||
x0 = _cx(st.positions, st.links)
|
x0 = _cx(st.positions, st.links)
|
||||||
st2, fix_meta = _ensure_zero_overlap(st, base_params)
|
st2, fix_meta = _ensure_zero_overlap(st, base_params)
|
||||||
|
|
@ -814,7 +814,7 @@ def run_layout_on_graph(
|
||||||
)
|
)
|
||||||
st = normalize_origin(op.state, base_params).state
|
st = normalize_origin(op.state, base_params).state
|
||||||
# Overlap crush must not erase crossing gains.
|
# Overlap crush must not erase crossing gains.
|
||||||
from netx_topology_mcp.layout_metrics import count_edge_crossings as _cx
|
from netx_api.mcp_remote.topology.layout_metrics import count_edge_crossings as _cx
|
||||||
|
|
||||||
x0 = _cx(st.positions, st.links)
|
x0 = _cx(st.positions, st.links)
|
||||||
st2, fix_meta = _ensure_zero_overlap(st, base_params)
|
st2, fix_meta = _ensure_zero_overlap(st, base_params)
|
||||||
|
|
@ -888,7 +888,7 @@ def run_layout_on_graph(
|
||||||
|
|
||||||
|
|
||||||
def list_layout_catalog() -> dict[str, Any]:
|
def list_layout_catalog() -> dict[str, Any]:
|
||||||
from netx_topology_mcp import NETX_MCP_REV, __version__
|
from netx_api.mcp_remote.topology import NETX_MCP_REV, __version__
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"version": __version__,
|
"version": __version__,
|
||||||
|
|
@ -10,7 +10,7 @@ import math
|
||||||
from collections import defaultdict, deque
|
from collections import defaultdict, deque
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from netx_topology_mcp.layout_metrics import segments_properly_intersect
|
from netx_api.mcp_remote.topology.layout_metrics import segments_properly_intersect
|
||||||
|
|
||||||
# Turn from 180° beyond this → kink (degrees).
|
# Turn from 180° beyond this → kink (degrees).
|
||||||
CHAIN_KINK_DEG = 40.0
|
CHAIN_KINK_DEG = 40.0
|
||||||
|
|
@ -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]
|
[project]
|
||||||
name = "netx-ops"
|
name = "netx-ops"
|
||||||
version = "0.4.19"
|
version = "0.4.19"
|
||||||
description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP"
|
description = "netx operations tool: alarm-centric workflows with REST API and remote MCP"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
license = { text = "MIT" }
|
license = { text = "MIT" }
|
||||||
|
|
@ -29,12 +29,11 @@ dependencies = [
|
||||||
"alembic>=1.13.0",
|
"alembic>=1.13.0",
|
||||||
"psutil>=5.9.0",
|
"psutil>=5.9.0",
|
||||||
"redis>=5.0.0",
|
"redis>=5.0.0",
|
||||||
|
"mcp>=1.0.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
mcp = ["netx-mcp @ file:packages/netx-mcp"]
|
# Remote MCP is built into netx_api (/mcp). Optional extras removed.
|
||||||
mcp-topology = ["netx-topology-mcp @ file:packages/netx-topology-mcp"]
|
|
||||||
mcp-bizmonitor = ["netx-bizmonitor-mcp @ file:packages/netx-bizmonitor-mcp"]
|
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
where = ["."]
|
where = ["."]
|
||||||
|
|
|
||||||
|
|
@ -21,3 +21,4 @@ PyJWT>=2.8.0
|
||||||
alembic>=1.13.0
|
alembic>=1.13.0
|
||||||
psutil>=5.9.0
|
psutil>=5.9.0
|
||||||
redis>=5.0.0
|
redis>=5.0.0
|
||||||
|
mcp>=1.0.0
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
"""通过真实 stdio MCP 留存本地验收证据;不安装插件、不导入 handler。
|
"""通过真实远程 HTTP MCP(/mcp Streamable HTTP)留存本地验收证据。
|
||||||
|
|
||||||
使用已有本机 MCP 配置中的凭据,只在子进程内存中传递,证据自动去敏。
|
使用已有本机 MCP 配置中的凭据,只在进程内存中传递,证据自动去敏。
|
||||||
示例:python scripts/mcp_acceptance.py ops runNmsDiagnostics '{}'
|
示例:python scripts/mcp_acceptance.py ops runNmsDiagnostics '{}'
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
@ -10,18 +10,14 @@ import datetime as dt
|
||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import queue
|
|
||||||
import re
|
import re
|
||||||
import subprocess
|
|
||||||
import sys
|
import sys
|
||||||
import threading
|
|
||||||
import time
|
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
GROUPS = {
|
GROUPS = {
|
||||||
"ops": ("netx", "netx_mcp", "netx-mcp"),
|
"ops": "ops",
|
||||||
"topology": ("netx-topology", "netx_topology_mcp", "netx-topology-mcp"),
|
"topology": "topology",
|
||||||
"biz": ("netx-bizmonitor", "netx_bizmonitor_mcp", "netx-bizmonitor-mcp"),
|
"biz": "bizMonitor",
|
||||||
}
|
}
|
||||||
SENSITIVE = re.compile(r"password|passwd|secret|token|authorization|cookie|private_key", re.I)
|
SENSITIVE = re.compile(r"password|passwd|secret|token|authorization|cookie|private_key", re.I)
|
||||||
|
|
||||||
|
|
@ -32,7 +28,6 @@ def redact(value):
|
||||||
if isinstance(value, list):
|
if isinstance(value, list):
|
||||||
return [redact(x) for x in value]
|
return [redact(x) for x in value]
|
||||||
if isinstance(value, str):
|
if isinstance(value, str):
|
||||||
# MCP 的 JSON 位于 content.text 内;递归处理,避免只净化外层。
|
|
||||||
try:
|
try:
|
||||||
decoded = json.loads(value)
|
decoded = json.loads(value)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
|
|
@ -53,69 +48,100 @@ def payload(response):
|
||||||
return result or response.get("error", {})
|
return result or response.get("error", {})
|
||||||
|
|
||||||
|
|
||||||
class McpSession:
|
def _resolve_base(config: Path) -> tuple[str, str]:
|
||||||
def __init__(self, group, config, evidence, source_root=ROOT):
|
cfg = json.loads(Path(config).read_text(encoding="utf-8-sig"))
|
||||||
source_root = Path(source_root).resolve()
|
servers = cfg.get("mcpServers") or {}
|
||||||
server_id, module, package = GROUPS[group]
|
# Prefer first remote URL entry; fall back to env.
|
||||||
cfg = json.loads(Path(config).read_text(encoding="utf-8-sig"))
|
url = ""
|
||||||
settings = cfg["mcpServers"][server_id].get("env", {})
|
token = os.environ.get("NETX_API_TOKEN") or os.environ.get("NETX_MCP_TOKEN") or ""
|
||||||
env = os.environ.copy()
|
for _name, entry in servers.items():
|
||||||
env.update({k: str(v) for k, v in settings.items()})
|
if not isinstance(entry, dict):
|
||||||
# 只允许本机 netx;不能由配置悄悄指向生产服务。
|
continue
|
||||||
actual_url = (env.get("NETX_API_URL") or env.get("OCLAW_NETX_BASE_URL")
|
candidate = str(entry.get("url") or "").strip()
|
||||||
or "http://127.0.0.1:8890").rstrip("/")
|
if candidate.startswith("http"):
|
||||||
if actual_url not in (
|
url = candidate
|
||||||
"http://127.0.0.1:8890", "http://localhost:8890",
|
headers = entry.get("headers") or {}
|
||||||
"http://127.0.0.1:8891", "http://localhost:8891"
|
auth = str(headers.get("Authorization") or headers.get("authorization") or "")
|
||||||
|
if auth.lower().startswith("bearer "):
|
||||||
|
raw = auth[7:].strip()
|
||||||
|
if raw and not raw.startswith("${"):
|
||||||
|
token = raw
|
||||||
|
break
|
||||||
|
if not url:
|
||||||
|
url = (os.environ.get("NETX_API_URL") or "http://127.0.0.1:8890").rstrip("/") + "/mcp"
|
||||||
|
# Strip query for base; group applied separately.
|
||||||
|
base = url.split("?", 1)[0].rstrip("/")
|
||||||
|
allowed = {
|
||||||
|
"http://127.0.0.1:8890/mcp",
|
||||||
|
"http://localhost:8890/mcp",
|
||||||
|
"http://127.0.0.1:8891/mcp",
|
||||||
|
"http://localhost:8891/mcp",
|
||||||
|
}
|
||||||
|
if base not in allowed:
|
||||||
|
raise ValueError("验收仅允许本机 8890/8891 的 /mcp")
|
||||||
|
if not token:
|
||||||
|
for p in (
|
||||||
|
Path(os.environ.get("NETX_AUTH_MCP_TOKEN_FILE") or ""),
|
||||||
|
ROOT / "data" / "auth" / "mcp_token",
|
||||||
):
|
):
|
||||||
raise ValueError("验收仅允许本机 8890 API 或隔离样例 8891 API")
|
if p.is_file():
|
||||||
env["NETX_API_URL"] = actual_url
|
token = p.read_text(encoding="utf-8").strip()
|
||||||
env.pop("OCLAW_NETX_BASE_URL", None)
|
if token:
|
||||||
env["PYTHONPATH"] = str(source_root / "packages" / package / "src")
|
break
|
||||||
env["PYTHONUTF8"] = "1"
|
if not token:
|
||||||
self.group, self.evidence, self.seq = group, Path(evidence), 0
|
raise ValueError("缺少 Bearer token(NETX_API_TOKEN 或 data/auth/mcp_token)")
|
||||||
self.stderr = []
|
return base, token
|
||||||
self.proc = subprocess.Popen(
|
|
||||||
[sys.executable, "-m", module], cwd=source_root, env=env,
|
|
||||||
stdin=subprocess.PIPE, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
|
class McpSession:
|
||||||
encoding="utf-8", bufsize=1,
|
def __init__(self, group: str, config: str, evidence: str):
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
self.group = GROUPS[group]
|
||||||
|
self.evidence = Path(evidence)
|
||||||
|
self.seq = 0
|
||||||
|
base, token = _resolve_base(Path(config))
|
||||||
|
self.url = f"{base}?groups={self.group}"
|
||||||
|
self._client = httpx.Client(
|
||||||
|
timeout=180.0,
|
||||||
|
trust_env=False, # Windows system proxy breaks loopback /mcp framing
|
||||||
|
headers={
|
||||||
|
"Authorization": f"Bearer {token}",
|
||||||
|
"Accept": "application/json, text/event-stream",
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"MCP-Protocol-Version": "2024-11-05",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
self.rpc(
|
||||||
|
"initialize",
|
||||||
|
{
|
||||||
|
"protocolVersion": "2024-11-05",
|
||||||
|
"capabilities": {},
|
||||||
|
"clientInfo": {"name": "netx-mcp-acceptance", "version": "1"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
# notifications/initialized — no response expected
|
||||||
|
self._client.post(
|
||||||
|
self.url,
|
||||||
|
json={"jsonrpc": "2.0", "method": "notifications/initialized"},
|
||||||
)
|
)
|
||||||
self.lines = queue.Queue()
|
|
||||||
threading.Thread(target=self._read_stdout, daemon=True).start()
|
|
||||||
threading.Thread(target=self._read_stderr, daemon=True).start()
|
|
||||||
self.rpc("initialize", {"protocolVersion": "2024-11-05", "capabilities": {},
|
|
||||||
"clientInfo": {"name": "netx-mcp-acceptance", "version": "1"}})
|
|
||||||
self.proc.stdin.write(json.dumps({"jsonrpc": "2.0", "method": "notifications/initialized"}) + "\n")
|
|
||||||
self.proc.stdin.flush()
|
|
||||||
|
|
||||||
def _read_stdout(self):
|
def rpc(self, method, params=None):
|
||||||
for line in self.proc.stdout:
|
import time
|
||||||
self.lines.put(line)
|
|
||||||
|
|
||||||
def _read_stderr(self):
|
|
||||||
for line in self.proc.stderr:
|
|
||||||
self.stderr.append(line)
|
|
||||||
|
|
||||||
def rpc(self, method, params=None, timeout=180):
|
|
||||||
self.seq += 1
|
self.seq += 1
|
||||||
request = {"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params or {}}
|
request = {"jsonrpc": "2.0", "id": self.seq, "method": method, "params": params or {}}
|
||||||
start = time.monotonic()
|
start = time.monotonic()
|
||||||
self.proc.stdin.write(json.dumps(request, ensure_ascii=False) + "\n")
|
r = self._client.post(self.url, json=request)
|
||||||
self.proc.stdin.flush()
|
r.raise_for_status()
|
||||||
deadline = start + timeout
|
response = r.json()
|
||||||
while True:
|
record = {
|
||||||
try:
|
"at": dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).isoformat(),
|
||||||
line = self.lines.get(timeout=max(0.01, deadline - time.monotonic()))
|
"group": self.group,
|
||||||
except queue.Empty as exc:
|
"request": request,
|
||||||
raise TimeoutError(f"{self.group} {method} 超时;stderr={''.join(self.stderr)[-500:]}") from exc
|
"response": response,
|
||||||
response = json.loads(line)
|
"elapsed_ms": round((time.monotonic() - start) * 1000),
|
||||||
if response.get("id") == self.seq:
|
}
|
||||||
break
|
|
||||||
if "id" in response:
|
|
||||||
raise RuntimeError("服务器对通知回包或返回了错误的请求 id")
|
|
||||||
record = {"at": dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).isoformat(),
|
|
||||||
"group": self.group, "request": request, "response": response,
|
|
||||||
"elapsed_ms": round((time.monotonic() - start) * 1000)}
|
|
||||||
self.evidence.parent.mkdir(parents=True, exist_ok=True)
|
self.evidence.parent.mkdir(parents=True, exist_ok=True)
|
||||||
with self.evidence.open("a", encoding="utf-8") as f:
|
with self.evidence.open("a", encoding="utf-8") as f:
|
||||||
f.write(json.dumps(redact(record), ensure_ascii=False) + "\n")
|
f.write(json.dumps(redact(record), ensure_ascii=False) + "\n")
|
||||||
|
|
@ -132,12 +158,7 @@ class McpSession:
|
||||||
return self.rpc("tools/call", {"name": name, "arguments": args})
|
return self.rpc("tools/call", {"name": name, "arguments": args})
|
||||||
|
|
||||||
def close(self):
|
def close(self):
|
||||||
self.proc.stdin.close()
|
self._client.close()
|
||||||
try:
|
|
||||||
self.proc.wait(timeout=8)
|
|
||||||
except subprocess.TimeoutExpired:
|
|
||||||
self.proc.terminate()
|
|
||||||
self.proc.wait(timeout=5)
|
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
|
|
@ -146,10 +167,9 @@ def main():
|
||||||
parser.add_argument("tool", help="工具名,或 tools/list")
|
parser.add_argument("tool", help="工具名,或 tools/list")
|
||||||
parser.add_argument("arguments", nargs="?", default="{}")
|
parser.add_argument("arguments", nargs="?", default="{}")
|
||||||
parser.add_argument("--config", default=str(ROOT / "mcp.all.json"))
|
parser.add_argument("--config", default=str(ROOT / "mcp.all.json"))
|
||||||
parser.add_argument("--source-root", default=str(ROOT), help="被验收包所在 worktree 根目录")
|
|
||||||
parser.add_argument("--evidence", default=str(ROOT / "artifacts/mcp-evidence.jsonl"))
|
parser.add_argument("--evidence", default=str(ROOT / "artifacts/mcp-evidence.jsonl"))
|
||||||
opts = parser.parse_args()
|
opts = parser.parse_args()
|
||||||
session = McpSession(opts.group, opts.config, opts.evidence, opts.source_root)
|
session = McpSession(opts.group, opts.config, opts.evidence)
|
||||||
try:
|
try:
|
||||||
if opts.tool == "tools/list":
|
if opts.tool == "tools/list":
|
||||||
response = session.rpc("tools/list")
|
response = session.rpc("tools/list")
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue