From 166d2289c384b306da7be27eb4be4e890a6ea19c Mon Sep 17 00:00:00 2001 From: oliver Date: Fri, 16 Oct 2026 23:14:39 +0800 Subject: [PATCH] feat(mcp): publish ops skills via /mcp resources, prompts and getNetxSkill Co-Authored-By: Claude Opus 5.5 --- docs/MCP.md | 28 +- netx_api/mcp_remote/registry.py | 28 +- netx_api/mcp_remote/server.py | 129 ++++- netx_api/mcp_remote/skills.py | 391 +++++++++++++++ packaging/build_release.ps1 | 2 +- packaging/linux/build_in_container.sh | 2 +- tests/mcp_remote/test_mcp_bizmonitor.py | 9 +- tests/mcp_remote/test_mcp_endpoint.py | 10 +- tests/mcp_remote/test_mcp_http.py | 6 +- tests/mcp_remote/test_mcp_skills.py | 473 ++++++++++++++++++ .../mcp_remote/topology/test_mcp_topology.py | 4 +- 11 files changed, 1057 insertions(+), 25 deletions(-) create mode 100644 netx_api/mcp_remote/skills.py create mode 100644 tests/mcp_remote/test_mcp_skills.py diff --git a/docs/MCP.md b/docs/MCP.md index 83138e4..14110f2 100644 --- a/docs/MCP.md +++ b/docs/MCP.md @@ -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/<组目录>//<相对路径>`,如 `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__` | +| `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 真源说明 diff --git a/netx_api/mcp_remote/registry.py b/netx_api/mcp_remote/registry.py index 1964152..210639b 100644 --- a/netx_api/mcp_remote/registry.py +++ b/netx_api/mcp_remote/registry.py @@ -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) diff --git a/netx_api/mcp_remote/server.py b/netx_api/mcp_remote/server.py index 28f4309..cdff91a 100644 --- a/netx_api/mcp_remote/server.py +++ b/netx_api/mcp_remote/server.py @@ -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() diff --git a/netx_api/mcp_remote/skills.py b/netx_api/mcp_remote/skills.py new file mode 100644 index 0000000..ac9666c --- /dev/null +++ b/netx_api/mcp_remote/skills.py @@ -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" diff --git a/packaging/build_release.ps1 b/packaging/build_release.ps1 index 443f5f4..d6dab48 100644 --- a/packaging/build_release.ps1 +++ b/packaging/build_release.ps1 @@ -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")) { diff --git a/packaging/linux/build_in_container.sh b/packaging/linux/build_in_container.sh index 8988722..33e33b9 100755 --- a/packaging/linux/build_in_container.sh +++ b/packaging/linux/build_in_container.sh @@ -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 diff --git a/tests/mcp_remote/test_mcp_bizmonitor.py b/tests/mcp_remote/test_mcp_bizmonitor.py index ac8175d..a160d29 100644 --- a/tests/mcp_remote/test_mcp_bizmonitor.py +++ b/tests/mcp_remote/test_mcp_bizmonitor.py @@ -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): diff --git a/tests/mcp_remote/test_mcp_endpoint.py b/tests/mcp_remote/test_mcp_endpoint.py index 9e280a3..a0ff297 100644 --- a/tests/mcp_remote/test_mcp_endpoint.py +++ b/tests/mcp_remote/test_mcp_endpoint.py @@ -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}"} diff --git a/tests/mcp_remote/test_mcp_http.py b/tests/mcp_remote/test_mcp_http.py index 4f19ea4..1315ea2 100644 --- a/tests/mcp_remote/test_mcp_http.py +++ b/tests/mcp_remote/test_mcp_http.py @@ -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 diff --git a/tests/mcp_remote/test_mcp_skills.py b/tests/mcp_remote/test_mcp_skills.py new file mode 100644 index 0000000..18ad60a --- /dev/null +++ b/tests/mcp_remote/test_mcp_skills.py @@ -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) diff --git a/tests/mcp_remote/topology/test_mcp_topology.py b/tests/mcp_remote/topology/test_mcp_topology.py index f5ae186..30598d8 100644 --- a/tests/mcp_remote/topology/test_mcp_topology.py +++ b/tests/mcp_remote/topology/test_mcp_topology.py @@ -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