feat(mcp): publish ops skills via /mcp resources, prompts and getNetxSkill

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
oliver 2026-10-16 23:14:39 +08:00
parent 42d5f9c5a7
commit 166d2289c3
11 changed files with 1057 additions and 25 deletions

View file

@ -12,10 +12,10 @@ MCP 宿主 → HTTP http://127.0.0.1:8890/mcp → netx_api.mcp_remote(进
| groups | 工具数 | 说明 |
|--------|--------|------|
| `ops` | 15 | 告警 / 网元 / 只读 CLI |
| `topology` | 16 | 拓扑树 / 画布 / Fabric / 布局 |
| `bizMonitor` | 8 | 割接与业务状态只读 |
| (省略) | 39 | 当前 token 有权限的全部工具 |
| `ops` | 15+1 | 告警 / 网元 / 只读 CLI;另有 meta 工具 `getNetxSkill` |
| `topology` | 16+1 | 拓扑树 / 画布 / Fabric / 布局;另有 `getNetxSkill` |
| `bizMonitor` | 8+1 | 割接与业务状态只读;另有 `getNetxSkill` |
| (省略) | 40 | 39 个能力工具 + `getNetxSkill`(有可见 skill 时) |
示例:`/mcp?groups=ops,bizMonitor`
@ -58,7 +58,27 @@ http://127.0.0.1:8890/mcp?groups=ops
无状态模式下不主动推送 `notifications/tools/list_changed`;客户端应在需要时重新 `tools/list`。
## Skills(运维手册资源)
运维 skill 真源在仓库 `skills/`(`ops` / `topology` / `biz-monitor`;不含开发者用的 `biz-state`)。通过 `/mcp` 与工具同一鉴权、同一 `?groups=` 过滤发布:
| 通道 | 说明 |
|------|------|
| `resources/list` · `resources/read` | URI:`netx://skills/<组目录>/<skill名>/<相对路径>`,如 `netx://skills/ops/netx-ops/SKILL.md` |
| 工具 `getNetxSkill` | 参数 `name`(必填)、`file`(可选,默认 `SKILL.md`);`_meta.netx.group=meta` |
| `prompts/list` · `prompts/get` | 每个可见 skill 一个 prompt(名=skill 名);Claude Code 显示为 `/mcp__netx__<skill名>` |
| `initialize.instructions` | 在原有说明后追加当前可见 skill 目录,并提示先调 `getNetxSkill` |
可见规则:该组下当前 token 至少有一个可见工具,且遵守 `?groups=`。改 skill 文件后无需重启 netx(每次请求读盘)。
### Cursor / Claude Code 怎么读
1. **Cursor**:在 MCP 资源面板打开 `netx://skills/...`,或让 Agent 调用 `getNetxSkill`。
2. **Claude Code**:`/mcp__netx__netx-ops` 等 prompt,或对 URI 做 resources/read;也可直接 `getNetxSkill`。
3. 仅挂某一组时加查询参数,例如 `http://127.0.0.1:8890/mcp?groups=ops`。
## 相关文档
- [MCP_TOPOLOGY.md](./MCP_TOPOLOGY.md) — topology 组工具说明
- [MCP_BIZMONITOR.md](./MCP_BIZMONITOR.md) — bizMonitor 组工具说明
- [`skills/README.md`](../skills/README.md) — skill 真源说明

View file

@ -99,11 +99,11 @@ def parse_groups_param(raw: str | None) -> frozenset[str]:
return frozenset(p for p in parts if p in VALID_GROUPS)
def tools_for_request(
def capability_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)."""
"""Filter capability tools by scopes and optional groups. scopes=None → all (offline)."""
wanted_groups = groups or frozenset()
if scopes is None:
scoped = list(ALL_TOOLS)
@ -122,6 +122,19 @@ def tools_for_request(
return [t for t in scoped if t.get("_group") in wanted_groups]
def tools_for_request(
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
) -> list[dict[str, Any]]:
"""Capability tools plus getNetxSkill when ≥1 skill is visible (ignores ?groups= for meta)."""
from .skills import get_netx_skill_tool_dict, should_advertise_get_netx_skill
scoped = capability_tools_for_request(scopes, groups)
if should_advertise_get_netx_skill(scopes, groups):
return scoped + [get_netx_skill_tool_dict()]
return scoped
def to_mcp_tool(tool: dict[str, Any]) -> Tool:
ann = tool.get("annotations") or {}
return Tool(
@ -140,7 +153,16 @@ def to_mcp_tool(tool: dict[str, Any]) -> Tool:
def call_tool(name: str, args: dict[str, Any]) -> dict[str, Any]:
handler = _HANDLERS.get(str(name or "").strip())
from .skills import GET_NETX_SKILL_NAME, call_get_netx_skill
from .context import get_request_context
key = str(name or "").strip()
if key == GET_NETX_SKILL_NAME:
ctx = get_request_context()
scopes = None if ctx is None else ctx.scopes
groups = frozenset() if ctx is None else ctx.groups
return call_get_netx_skill(args, scopes, groups)
handler = _HANDLERS.get(key)
if handler is None:
raise ValueError(f"unknown tool: {name}")
return handler(name, args)

View file

@ -7,16 +7,37 @@ from importlib.metadata import version as pkg_version
from typing import Any
from mcp.server import Server, ServerRequestContext
from mcp.shared.exceptions import MCPError
from mcp.types import (
CallToolRequestParams,
CallToolResult,
GetPromptRequestParams,
GetPromptResult,
INVALID_PARAMS,
ListPromptsResult,
ListResourcesResult,
ListToolsResult,
PaginatedRequestParams,
Prompt,
PromptMessage,
ReadResourceRequestParams,
ReadResourceResult,
Resource,
TextContent,
TextResourceContents,
)
from .context import get_request_context
from .registry import TOOL_REQUIRED_SCOPE, call_tool, to_mcp_tool, tools_for_request
from .skills import (
build_instructions,
list_visible_skill_files,
list_visible_skills,
prompt_skill_md_text,
read_skill_text,
resolve_uri,
resource_dict,
)
def netx_version() -> str:
@ -103,19 +124,115 @@ async def _on_call_tool(
return CallToolResult(content=texts, is_error=is_error)
async def _on_list_resources(
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> ListResourcesResult:
scopes, groups = _scopes_and_groups()
resources: list[Resource] = []
for sf in list_visible_skill_files(scopes, groups):
d = resource_dict(sf)
resources.append(
Resource(
uri=d["uri"],
name=d["name"],
mimeType=d.get("mimeType"),
description=d.get("description"),
meta=d.get("_meta"),
)
)
return ListResourcesResult(resources=resources)
async def _on_read_resource(
ctx: ServerRequestContext,
params: ReadResourceRequestParams,
) -> ReadResourceResult:
scopes, groups = _scopes_and_groups()
uri = str(params.uri or "")
sf = resolve_uri(uri, scopes, groups)
if sf is None:
raise MCPError(INVALID_PARAMS, "resource not found or not authorized")
try:
text = read_skill_text(sf)
except OSError:
raise MCPError(INVALID_PARAMS, "resource not found or not authorized") from None
return ReadResourceResult(
contents=[
TextResourceContents(
uri=sf.uri,
mimeType="text/markdown",
text=text,
)
]
)
async def _on_list_prompts(
ctx: ServerRequestContext,
params: PaginatedRequestParams | None,
) -> ListPromptsResult:
scopes, groups = _scopes_and_groups()
prompts = [
Prompt(name=name, description=desc or None)
for name, _group, desc in list_visible_skills(scopes, groups)
]
return ListPromptsResult(prompts=prompts)
async def _on_get_prompt(
ctx: ServerRequestContext,
params: GetPromptRequestParams,
) -> GetPromptResult:
scopes, groups = _scopes_and_groups()
name = str(params.name or "").strip()
text = prompt_skill_md_text(name, scopes, groups)
if text is None:
raise MCPError(INVALID_PARAMS, "prompt not found or not authorized")
skills = {n: d for n, _g, d in list_visible_skills(scopes, groups)}
return GetPromptResult(
description=skills.get(name) or None,
messages=[
PromptMessage(
role="user",
content=TextContent(type="text", text=text),
)
],
)
def build_mcp_server() -> Server[Any]:
return Server(
server: Server[Any] = 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."
),
instructions=_BASE_INSTRUCTIONS_STATIC,
on_list_tools=_on_list_tools,
on_call_tool=_on_call_tool,
on_list_resources=_on_list_resources,
on_read_resource=_on_read_resource,
on_list_prompts=_on_list_prompts,
on_get_prompt=_on_get_prompt,
)
# Server.instructions is fixed at construct time; override init options so
# each initialize sees a catalog filtered by the current token / ?groups=.
_orig_create = server.create_initialization_options
def _create_initialization_options(*args: Any, **kwargs: Any) -> Any:
opts = _orig_create(*args, **kwargs)
scopes, groups = _scopes_and_groups()
return opts.model_copy(update={"instructions": build_instructions(scopes, groups)})
server.create_initialization_options = _create_initialization_options # type: ignore[method-assign]
return server
_BASE_INSTRUCTIONS_STATIC = (
"netx remote MCP: ops / topology / bizMonitor tools. "
"Filter with ?groups=ops,topology,bizMonitor. "
"Authorize with Bearer MCP token or user JWT."
)
# Singleton used by mount; streamable_http_app() must be called once so session_manager exists.
mcp_server = build_mcp_server()

View file

@ -0,0 +1,391 @@
"""Publish netx ops/topology/biz-monitor skills via MCP resources / prompts / getNetxSkill."""
from __future__ import annotations
import os
import re
from dataclasses import dataclass
from pathlib import Path
from typing import Any
from .registry import (
GROUP_BIZ,
GROUP_OPS,
GROUP_TOPOLOGY,
VALID_GROUPS,
capability_tools_for_request,
)
# MCP group name → skills/ subdirectory (explicit; biz-state is intentionally absent).
GROUP_DIR: dict[str, str] = {
GROUP_OPS: "ops",
GROUP_TOPOLOGY: "topology",
GROUP_BIZ: "biz-monitor",
}
DIR_TO_GROUP: dict[str, str] = {v: k for k, v in GROUP_DIR.items()}
URI_PREFIX = "netx://skills/"
_TEXT_SUFFIXES = frozenset({".md", ".txt", ".json", ".yaml", ".yml", ".toml"})
_FRONTMATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n?", re.DOTALL)
_DESC_RE = re.compile(
r"^description:\s*(?:>-?\s*)?(?:\|[+-]?\s*)?(.*?)(?=\n[a-zA-Z_][\w-]*:|\Z)",
re.DOTALL | re.MULTILINE,
)
GET_NETX_SKILL_NAME = "getNetxSkill"
_BASE_INSTRUCTIONS = (
"netx remote MCP: ops / topology / bizMonitor tools. "
"Filter with ?groups=ops,topology,bizMonitor. "
"Authorize with Bearer MCP token or user JWT."
)
_CATALOG_HINT = (
"做这类任务前先调用 getNetxSkill 读取对应 skill,也可以读 netx://skills/... 资源。"
)
_INSTRUCTIONS_MAX = 1500
@dataclass(frozen=True)
class SkillFile:
group: str
group_dir: str
skill: str
rel_path: str # posix relative path inside the skill folder
abs_path: Path
@property
def uri(self) -> str:
return f"{URI_PREFIX}{self.group_dir}/{self.skill}/{self.rel_path}"
@property
def entry(self) -> bool:
return self.rel_path == "SKILL.md"
def skills_root() -> Path:
"""Locate skills directory: NETX_SKILLS_ROOT, else repo/package root ``skills/``."""
env = str(os.environ.get("NETX_SKILLS_ROOT") or "").strip()
if env:
return Path(env).expanduser().resolve()
# netx_api/mcp_remote/skills.py → parents[2] = package/repo root
return Path(__file__).resolve().parents[2] / "skills"
def _is_text_file(path: Path) -> bool:
return path.is_file() and path.suffix.lower() in _TEXT_SUFFIXES
def _parse_description(text: str) -> str:
m = _FRONTMATTER_RE.match(text)
if not m:
return ""
block = m.group(1)
dm = _DESC_RE.search(block)
if not dm:
return ""
raw = dm.group(1).strip()
# Fold YAML folded/literal block lines into a single paragraph.
lines = [ln.strip() for ln in raw.splitlines() if ln.strip()]
return " ".join(lines).strip().strip("\"'")
def _first_sentence(description: str) -> str:
text = (description or "").strip()
if not text:
return ""
for sep in ("。", ". ", "!", "!", "?", "?"):
if sep in text:
part = text.split(sep, 1)[0].strip()
if sep == "。":
return part + "。"
if sep.startswith("."):
return part + "."
return part + sep[0]
return text
def scan_skill_files(*, root: Path | None = None) -> list[SkillFile]:
"""Read disk every call. Only mapped group dirs; no path traversal."""
base = root if root is not None else skills_root()
out: list[SkillFile] = []
if not base.is_dir():
return out
for group, group_dir in GROUP_DIR.items():
group_path = base / group_dir
if not group_path.is_dir():
continue
for skill_dir in sorted(group_path.iterdir(), key=lambda p: p.name.lower()):
if not skill_dir.is_dir() or skill_dir.name.startswith("."):
continue
skill_name = skill_dir.name
for path in sorted(skill_dir.rglob("*")):
if not _is_text_file(path):
continue
try:
rel = path.relative_to(skill_dir).as_posix()
except ValueError:
continue
if ".." in rel.split("/"):
continue
out.append(
SkillFile(
group=group,
group_dir=group_dir,
skill=skill_name,
rel_path=rel,
abs_path=path.resolve(),
)
)
return out
def visible_skill_groups(
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
) -> frozenset[str]:
"""A skill group is visible iff the request has ≥1 visible tool in that group."""
tools = capability_tools_for_request(scopes, groups)
return frozenset(str(t.get("_group")) for t in tools if t.get("_group") in VALID_GROUPS)
def list_visible_skill_files(
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
*,
root: Path | None = None,
) -> list[SkillFile]:
allowed = visible_skill_groups(scopes, groups)
return [f for f in scan_skill_files(root=root) if f.group in allowed]
def list_visible_skills(
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
*,
root: Path | None = None,
) -> list[tuple[str, str, str]]:
"""Unique skills as (name, group, description) ordered by group then name."""
files = list_visible_skill_files(scopes, groups, root=root)
by_key: dict[tuple[str, str], tuple[str, str, str]] = {}
for f in files:
key = (f.group, f.skill)
if key in by_key and not f.entry:
continue
desc = ""
if f.entry:
try:
desc = _parse_description(read_skill_text(f))
except OSError:
desc = ""
by_key[key] = (f.skill, f.group, desc)
order = {GROUP_OPS: 0, GROUP_TOPOLOGY: 1, GROUP_BIZ: 2}
return sorted(by_key.values(), key=lambda x: (order.get(x[1], 9), x[0].lower()))
def read_skill_text(skill_file: SkillFile) -> str:
raw = skill_file.abs_path.read_text(encoding="utf-8")
return raw.replace("\r\n", "\n").replace("\r", "\n")
def resolve_uri(
uri: str,
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
*,
root: Path | None = None,
) -> SkillFile | None:
"""Resolve only against the visible list (no arbitrary path join)."""
target = str(uri or "").strip()
for f in list_visible_skill_files(scopes, groups, root=root):
if f.uri == target:
return f
return None
def resolve_skill_file(
skill_name: str,
rel_path: str,
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
*,
root: Path | None = None,
) -> SkillFile | None:
name = str(skill_name or "").strip()
rel = (str(rel_path or "").strip() or "SKILL.md").replace("\\", "/")
if not name or ".." in rel.split("/") or rel.startswith("/"):
return None
for f in list_visible_skill_files(scopes, groups, root=root):
if f.skill == name and f.rel_path == rel:
return f
return None
def resource_dict(skill_file: SkillFile, *, description: str | None = None) -> dict[str, Any]:
desc = description
if desc is None and skill_file.entry:
try:
desc = _parse_description(read_skill_text(skill_file))
except OSError:
desc = ""
out: dict[str, Any] = {
"uri": skill_file.uri,
"name": skill_file.rel_path,
"mimeType": "text/markdown",
"_meta": {
"netx": {
"group": skill_file.group,
"skill": skill_file.skill,
"entry": skill_file.entry,
}
},
}
if skill_file.entry and desc:
out["description"] = desc
return out
def build_instructions(
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
*,
root: Path | None = None,
) -> str:
"""Dynamic skill catalog appended to base instructions (≤1500 chars)."""
skills = list_visible_skills(scopes, groups, root=root)
lines = [_BASE_INSTRUCTIONS, ""]
if skills:
lines.append("Skills:")
for name, group, desc in skills:
lines.append(f"- {name}({group}):{_first_sentence(desc) or name}")
lines.append(_CATALOG_HINT)
else:
lines.append("Skills: (当前 token / groups 下无可见 skill)")
text = "\n".join(lines)
if len(text) <= _INSTRUCTIONS_MAX:
return text
# Truncate catalog lines, keep base + hint.
head = _BASE_INSTRUCTIONS + "\n\nSkills:\n"
tail = "\n" + _CATALOG_HINT
budget = _INSTRUCTIONS_MAX - len(head) - len(tail)
body_lines: list[str] = []
used = 0
for name, group, desc in skills:
line = f"- {name}({group}):{_first_sentence(desc) or name}"
need = len(line) + (1 if body_lines else 0)
if used + need > budget:
break
body_lines.append(line)
used += need
return head + "\n".join(body_lines) + tail
def get_netx_skill_tool_dict() -> dict[str, Any]:
return {
"name": GET_NETX_SKILL_NAME,
"description": (
"读取 netx 运维 skill 文件原文(默认 SKILL.md)。"
"做 ops / topology / bizMonitor 类任务前先调用。"
),
"inputSchema": {
"type": "object",
"properties": {
"name": {
"type": "string",
"description": "skill 名,例如 netx-ops",
},
"file": {
"type": "string",
"description": "相对路径,默认 SKILL.md",
},
},
"required": ["name"],
},
"annotations": {
"readOnlyHint": True,
"idempotentHint": True,
"destructiveHint": False,
},
"_meta": {"netx": {"group": "meta"}},
"_group": "meta",
}
def should_advertise_get_netx_skill(
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
*,
root: Path | None = None,
) -> bool:
return bool(list_visible_skills(scopes, groups, root=root))
def available_skill_names(
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
*,
root: Path | None = None,
) -> list[str]:
return [name for name, _g, _d in list_visible_skills(scopes, groups, root=root)]
def call_get_netx_skill(
args: dict[str, Any],
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
*,
root: Path | None = None,
) -> dict[str, Any]:
name = str((args or {}).get("name") or "").strip()
rel = str((args or {}).get("file") or "SKILL.md").strip() or "SKILL.md"
available = available_skill_names(scopes, groups, root=root)
hint = "当前可用 skill:" + (", ".join(available) if available else "(无)")
if not name:
return {
"content": [{"type": "text", "text": f"缺少 name 参数。{hint}"}],
"isError": True,
}
skill_file = resolve_skill_file(name, rel, scopes, groups, root=root)
if skill_file is None:
return {
"content": [
{
"type": "text",
"text": f"skill 不可见或不存在:name={name!r} file={rel!r}。{hint}",
}
],
"isError": True,
}
try:
text = read_skill_text(skill_file)
except OSError:
return {
"content": [{"type": "text", "text": f"读取失败。{hint}"}],
"isError": True,
}
if skill_file.entry:
siblings = [
f.rel_path
for f in list_visible_skill_files(scopes, groups, root=root)
if f.skill == skill_file.skill and f.rel_path != "SKILL.md"
]
if siblings:
text = text.rstrip() + "\n\n---\n其他文件:\n" + "\n".join(f"- {s}" for s in siblings) + "\n"
else:
text = text.rstrip() + "\n\n---\n其他文件:(无)\n"
return {"content": [{"type": "text", "text": text}], "isError": False}
def prompt_skill_md_text(
skill_name: str,
scopes: frozenset[str] | list[str] | None,
groups: frozenset[str] | None = None,
*,
root: Path | None = None,
) -> str | None:
skill_file = resolve_skill_file(skill_name, "SKILL.md", scopes, groups, root=root)
if skill_file is None:
return None
try:
body = read_skill_text(skill_file)
except OSError:
return None
return body.rstrip() + "\n\n其他文件用 getNetxSkill 读取。\n"

View file

@ -84,7 +84,7 @@ if (-not (Test-Path $winswShip)) {
}
# Flat layout = same as repo so scripts\start_netx.ps1 works unchanged.
foreach ($d in @("netx_api", "alembic", "scripts")) {
foreach ($d in @("netx_api", "alembic", "scripts", "skills")) {
Copy-Item -Path (Join-Path $repo $d) -Destination (Join-Path $stage $d) -Recurse -Force
}
foreach ($f in @("pyproject.toml", "requirements.txt", "alembic.ini", "README.md", "LICENSE", "CONTRIBUTING.md", "SECURITY.md")) {

View file

@ -73,7 +73,7 @@ find "${STAGE}/python" -name '*.pyc' -delete
# ------------------------------------------------------------ app files
echo "==> app files"
cp -r /src/netx_api /src/alembic "${STAGE}/"
cp -r /src/netx_api /src/alembic /src/skills "${STAGE}/"
for f in pyproject.toml requirements.txt alembic.ini README.md LICENSE SECURITY.md; do
if [[ -f "/src/${f}" ]]; then cp "/src/${f}" "${STAGE}/"; fi
done

View file

@ -176,8 +176,8 @@ def test_upstream_failures_are_tool_errors(failure):
def test_scope_and_group_filter_for_bizmonitor():
assert tools_for_request(frozenset({"ne:read"}), frozenset({"bizMonitor"})) == []
tools = tools_for_request(frozenset({"biz-monitor:read"}), frozenset({"bizMonitor"}))
assert len(tools) == 8
assert all(t["_group"] == "bizMonitor" for t in tools)
assert len(tools) == 9 # 8 biz + getNetxSkill
assert {t["_group"] for t in tools} == {"bizMonitor", "meta"}
assert all(t["annotations"]["readOnlyHint"] for t in tools)
@ -219,8 +219,9 @@ def test_missing_token_returns_empty_api_token(monkeypatch, tmp_path):
def test_bizmonitor_group_has_eight_tools():
tools = tools_for_request(None, frozenset({"bizMonitor"}))
assert len(tools) == 8
assert {t["name"] for t in tools} == {t["name"] for t in HTTP_MCP_TOOLS}
assert len(tools) == 9 # 8 biz + getNetxSkill
names = {t["name"] for t in tools}
assert names == {t["name"] for t in HTTP_MCP_TOOLS} | {"getNetxSkill"}
def test_token_priority_env_then_explicit_file_then_cwd(monkeypatch, tmp_path):

View file

@ -92,11 +92,12 @@ def test_mcp_http_auth_list_groups_and_rbac():
)
assert listed.status_code == 200
tools = listed.json()["result"]["tools"]
assert len(tools) == 39
assert len(tools) == 40 # 39 capability + getNetxSkill
names = {t["name"] for t in tools}
assert "queryNmsAlarms" in names
assert "getTopologyTree" in names
assert "listBizMonitors" in names
assert "getNetxSkill" in names
assert all("_meta" in t and t["_meta"]["netx"]["group"] for t in tools)
ops = client.post(
@ -106,8 +107,11 @@ def test_mcp_http_auth_list_groups_and_rbac():
)
assert ops.status_code == 200
ops_tools = ops.json()["result"]["tools"]
assert len(ops_tools) == 15
assert all(t["_meta"]["netx"]["group"] == "ops" for t in ops_tools)
assert len(ops_tools) == 16 # 15 ops + getNetxSkill
assert {t["name"] for t in ops_tools} >= {"queryNmsAlarms", "getNetxSkill"}
assert all(
t["_meta"]["netx"]["group"] in ("ops", "meta") for t in ops_tools
)
raw_ro = _token(scopes=["ne:read", "alarms:read"])
headers_ro = {**_HDR, "authorization": f"Bearer {raw_ro}"}

View file

@ -212,11 +212,13 @@ def test_tools_for_scopes_filters_by_granted() -> None:
def test_registry_groups_and_scope_filter() -> None:
ops = tools_for_request(None, frozenset({"ops"}))
assert len(ops) == 15
assert all(t["_group"] == "ops" for t in ops)
assert len(ops) == 16 # 15 ops + getNetxSkill
assert {t["_group"] for t in ops} == {"ops", "meta"}
assert "getNetxSkill" in {t["name"] for t in ops}
limited = tools_for_request(frozenset({"ne:read"}), frozenset({"ops"}))
names = {t["name"] for t in limited}
assert "listManagedNe" in names
assert "getNetxSkill" in names
assert "execManagedNe" not in names
assert "queryNmsAlarms" not in names

View file

@ -0,0 +1,473 @@
"""MCP skills: resources / getNetxSkill / prompts / instructions / filtering."""
from __future__ import annotations
import os
import socket
import subprocess
import sys
import tempfile
import time
import uuid
from pathlib import Path
from unittest.mock import patch
import httpx
import pytest
from starlette.testclient import TestClient
from netx_api.auth_service import create_api_token, get_user_by_username
from netx_api.config import settings
from netx_api.db import SessionLocal
from netx_api.main import app
from netx_api.mcp_remote.registry import tools_for_request
from netx_api.mcp_remote.skills import (
build_instructions,
call_get_netx_skill,
list_visible_skill_files,
scan_skill_files,
)
_HDR = {
"accept": "application/json, text/event-stream",
"content-type": "application/json",
"mcp-protocol-version": "2024-11-05",
}
ROOT = Path(__file__).resolve().parents[2]
PY = Path(os.environ.get("NETX_TEST_PYTHON") or sys.executable)
def _token(*, scopes: list[str] | None = None) -> str:
db = SessionLocal()
try:
user = get_user_by_username(db, str(settings.bootstrap_admin_username or "admin"))
assert user is not None
_row, raw = create_api_token(
db,
user=user,
name=f"mcp-skills-{uuid.uuid4().hex[:12]}",
scopes=scopes,
enforce_limit=False,
)
db.commit()
return raw
finally:
db.close()
def _mcp_post(client: TestClient, path: str, payload: dict, headers: dict) -> dict:
r = client.post(path, json=payload, headers=headers)
assert r.status_code == 200, r.text[:400]
body = r.json()
assert "error" not in body, body
return body["result"]
def test_scan_excludes_biz_state():
files = scan_skill_files()
uris = [f.uri for f in files]
assert any(u.endswith("/netx-ops/SKILL.md") for u in uris)
assert any("/biz-monitor/" in u for u in uris)
assert all("biz-state" not in u for u in uris)
def test_resources_list_and_read_via_http():
with (
patch("netx_api.mcp_remote.mount.settings.auth_enabled", True),
patch("netx_api.auth_middleware.settings.auth_enabled", True),
TestClient(app) as client,
):
raw = _token(scopes=None)
headers = {**_HDR, "authorization": f"Bearer {raw}"}
_mcp_post(
client,
"/mcp",
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "skills", "version": "0"},
},
},
headers,
)
listed = _mcp_post(
client,
"/mcp",
{"jsonrpc": "2.0", "id": 2, "method": "resources/list", "params": {}},
headers,
)
resources = listed["resources"]
uris = {r["uri"] for r in resources}
assert "netx://skills/ops/netx-ops/SKILL.md" in uris
assert "netx://skills/ops/netx-ops/reference.md" in uris
assert "netx://skills/topology/netx-topology/SKILL.md" in uris
assert "netx://skills/biz-monitor/netx-biz-monitor/SKILL.md" in uris
assert all("biz-state" not in u for u in uris)
entry = next(r for r in resources if r["uri"].endswith("/netx-ops/SKILL.md"))
assert entry["name"] == "SKILL.md"
assert entry["mimeType"] == "text/markdown"
assert entry["description"]
assert entry["_meta"]["netx"] == {
"group": "ops",
"skill": "netx-ops",
"entry": True,
}
ref = next(r for r in resources if r["uri"].endswith("/reference.md"))
assert "description" not in ref or not ref.get("description")
assert ref["_meta"]["netx"]["entry"] is False
read = _mcp_post(
client,
"/mcp",
{
"jsonrpc": "2.0",
"id": 3,
"method": "resources/read",
"params": {"uri": "netx://skills/ops/netx-ops/SKILL.md"},
},
headers,
)
text = read["contents"][0]["text"]
assert text.startswith("---")
assert "\r" not in text
assert "netx-ops" in text
def test_instructions_contain_skill_catalog():
with (
patch("netx_api.mcp_remote.mount.settings.auth_enabled", True),
patch("netx_api.auth_middleware.settings.auth_enabled", True),
TestClient(app) as client,
):
raw = _token(scopes=None)
headers = {**_HDR, "authorization": f"Bearer {raw}"}
init = _mcp_post(
client,
"/mcp",
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "skills", "version": "0"},
},
},
headers,
)
instr = init["instructions"]
assert "netx-ops(ops)" in instr
assert "getNetxSkill" in instr
assert "netx://skills/" in instr
assert len(instr) <= 1500
caps = init["capabilities"]
assert "resources" in caps
assert caps["resources"].get("subscribe") in (None, False)
assert "prompts" in caps
def test_scope_filters_biz_skill():
files = list_visible_skill_files(frozenset({"ne:read", "alarms:read"}), frozenset())
groups = {f.group for f in files}
assert "ops" in groups
assert "bizMonitor" not in groups
assert all("biz-monitor" not in f.uri for f in files)
def test_groups_query_filters_skills():
with (
patch("netx_api.mcp_remote.mount.settings.auth_enabled", True),
patch("netx_api.auth_middleware.settings.auth_enabled", True),
TestClient(app) as client,
):
raw = _token(scopes=None)
headers = {**_HDR, "authorization": f"Bearer {raw}"}
_mcp_post(
client,
"/mcp?groups=ops",
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "skills", "version": "0"},
},
},
headers,
)
listed = _mcp_post(
client,
"/mcp?groups=ops",
{"jsonrpc": "2.0", "id": 2, "method": "resources/list", "params": {}},
headers,
)
uris = {r["uri"] for r in listed["resources"]}
assert uris
assert all("/ops/" in u for u in uris)
assert all("/topology/" not in u for u in uris)
assert all("/biz-monitor/" not in u for u in uris)
def test_unauthorized_or_missing_uri_errors():
with (
patch("netx_api.mcp_remote.mount.settings.auth_enabled", True),
patch("netx_api.auth_middleware.settings.auth_enabled", True),
TestClient(app) as client,
):
raw = _token(scopes=["ne:read", "alarms:read"])
headers = {**_HDR, "authorization": f"Bearer {raw}"}
_mcp_post(
client,
"/mcp",
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "skills", "version": "0"},
},
},
headers,
)
for uri in (
"netx://skills/biz-monitor/netx-biz-monitor/SKILL.md",
"netx://skills/ops/netx-ops/../../biz-state/netx-biz-state/SKILL.md",
"netx://skills/nope/x/SKILL.md",
):
r = client.post(
"/mcp",
json={
"jsonrpc": "2.0",
"id": 9,
"method": "resources/read",
"params": {"uri": uri},
},
headers=headers,
)
assert r.status_code == 200
err = r.json().get("error") or {}
assert err, r.text[:300]
msg = str(err.get("message") or "")
assert "not found" in msg.lower() or "not authorized" in msg.lower()
# Must not leak filesystem paths
assert "D:\\" not in msg
assert "/skills/" not in msg or "netx://skills/" in msg
def test_get_netx_skill_tool():
out = call_get_netx_skill({"name": "netx-ops"}, None, frozenset())
assert out.get("isError") is False
text = out["content"][0]["text"]
assert text.startswith("---")
assert "其他文件" in text
assert "reference.md" in text
ref = call_get_netx_skill(
{"name": "netx-ops", "file": "reference.md"}, None, frozenset()
)
assert ref.get("isError") is False
assert "速查" in ref["content"][0]["text"]
denied = call_get_netx_skill(
{"name": "netx-biz-monitor"},
frozenset({"ne:read"}),
frozenset(),
)
assert denied.get("isError") is True
assert "可用 skill" in denied["content"][0]["text"]
assert "netx-ops" in denied["content"][0]["text"] or "(无)" in denied["content"][0]["text"]
def test_get_netx_skill_hidden_when_no_skills_visible():
tools = tools_for_request(frozenset({"ne:read"}), frozenset({"bizMonitor"}))
assert tools == []
assert "getNetxSkill" not in {t["name"] for t in tools}
def test_prompts_list_and_get():
with (
patch("netx_api.mcp_remote.mount.settings.auth_enabled", True),
patch("netx_api.auth_middleware.settings.auth_enabled", True),
TestClient(app) as client,
):
raw = _token(scopes=None)
headers = {**_HDR, "authorization": f"Bearer {raw}"}
_mcp_post(
client,
"/mcp",
{
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "skills", "version": "0"},
},
},
headers,
)
listed = _mcp_post(
client,
"/mcp",
{"jsonrpc": "2.0", "id": 2, "method": "prompts/list", "params": {}},
headers,
)
names = {p["name"] for p in listed["prompts"]}
assert names == {"netx-ops", "netx-topology", "netx-biz-monitor"}
got = _mcp_post(
client,
"/mcp",
{
"jsonrpc": "2.0",
"id": 3,
"method": "prompts/get",
"params": {"name": "netx-ops"},
},
headers,
)
msg = got["messages"][0]
assert msg["role"] == "user"
assert "其他文件用 getNetxSkill 读取" in msg["content"]["text"]
def test_build_instructions_dynamic():
full = build_instructions(None, frozenset())
assert "netx-ops(ops)" in full
ops_only = build_instructions(None, frozenset({"ops"}))
assert "netx-ops(ops)" in ops_only
assert "netx-topology" not in ops_only
assert len(full) <= 1500
def _free_port() -> int:
s = socket.socket()
s.bind(("127.0.0.1", 0))
port = int(s.getsockname()[1])
s.close()
return port
def _wait_health(port: int, timeout: float = 45.0) -> None:
deadline = time.time() + timeout
url = f"http://127.0.0.1:{port}/health"
last = ""
while time.time() < deadline:
try:
r = httpx.get(url, timeout=1.0, trust_env=False)
if r.status_code == 200:
return
last = f"status={r.status_code}"
except Exception as exc:
last = str(exc)
time.sleep(0.2)
raise TimeoutError(f"health not ready on {port}: {last}")
@pytest.fixture(scope="module")
def live_skills_port():
port = _free_port()
tmp = Path(tempfile.mkdtemp(prefix="netx-mcp-skills-tcp-"))
env = os.environ.copy()
env.update(
{
"NETX_DATABASE_URL": f"sqlite:///{(tmp / 'netx.sqlite').as_posix()}",
"NETX_AUTH_SECRET_FILE": str(tmp / "jwt_secret"),
"NETX_AUTH_MCP_TOKEN_FILE": str(tmp / "mcp_token"),
"NETX_CREDENTIAL_SECRET_FILE": str(tmp / "cred_secret"),
"NETX_NE_COLLECTION_DATA_DIR": str(tmp / "ne_collections"),
"NETX_BIZ_STATE_SPOOL_DIR": str(tmp / "biz_spool"),
"NETX_NE_EXEC_JOB_DIR": str(tmp / "ne_exec_jobs"),
"NETX_WEBCRT_DATA_DIR": str(tmp / "webcrt"),
"NETX_SCHEDULER_HEARTBEAT_PATH": str(tmp / "scheduler_heartbeat.json"),
"NETX_DOCS_ENABLED": "false",
"NETX_AUTH_ENABLED": "false",
"NETX_ALLOW_INSECURE_DEFAULTS": "true",
"NETX_RUN_INLINE_SCHEDULERS": "false",
"PYTHONUTF8": "1",
"NO_PROXY": "127.0.0.1,localhost",
"no_proxy": "127.0.0.1,localhost",
}
)
proc = subprocess.Popen(
[
str(PY),
"-m",
"uvicorn",
"netx_api.main:app",
"--host",
"127.0.0.1",
"--port",
str(port),
"--http",
"h11",
],
cwd=str(ROOT),
env=env,
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
try:
_wait_health(port)
yield port
finally:
proc.terminate()
try:
proc.wait(timeout=8)
except subprocess.TimeoutExpired:
proc.kill()
proc.wait(timeout=5)
def test_mcp_tcp_resources_list(live_skills_port: int):
"""Real TCP: Sangfor LSP rewrites /mcp; must verify resources/list on the wire."""
init_body = {
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2024-11-05",
"capabilities": {},
"clientInfo": {"name": "skills-tcp", "version": "0"},
},
}
r = httpx.post(
f"http://127.0.0.1:{live_skills_port}/mcp",
json=init_body,
headers={
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": "2024-11-05",
},
timeout=30.0,
trust_env=False,
)
assert r.status_code == 200, r.text[:300]
assert "resources" in r.json()["result"]["capabilities"]
assert "Skills:" in (r.json()["result"].get("instructions") or "")
listed = httpx.post(
f"http://127.0.0.1:{live_skills_port}/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "resources/list", "params": {}},
headers={
"Accept": "application/json, text/event-stream",
"MCP-Protocol-Version": "2024-11-05",
},
timeout=30.0,
trust_env=False,
)
assert listed.status_code == 200, listed.text[:300]
data = listed.json()
assert "error" not in data, data
uris = {x["uri"] for x in data["result"]["resources"]}
assert "netx://skills/ops/netx-ops/SKILL.md" in uris
assert all("biz-state" not in u for u in uris)

View file

@ -515,7 +515,7 @@ def test_neighborhood_returns_links_not_ports() -> None:
def test_topology_group_lists_sixteen_tools() -> None:
tools = tools_for_request(None, frozenset({"topology"}))
assert len(tools) == 16
assert len(tools) == 17 # 16 topology + getNetxSkill
names = {t["name"] for t in tools}
assert "suggestSinkHubs" in names
assert "classifyTopologyFabricNodes" in names
@ -527,9 +527,11 @@ def test_topology_group_lists_sixteen_tools() -> None:
assert "layoutTopologyView" in names
assert "sinkTopologyDualUnits" in names
assert "copyTopologyViewNodes" in names
assert "getNetxSkill" in names
read_only = tools_for_request(frozenset({"ne:read"}), frozenset({"topology"}))
read_names = {t["name"] for t in read_only}
assert "getTopologyView" in read_names
assert "getNetxSkill" in read_names
assert "layoutTopologyView" not in read_names