mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 06:30:48 +08:00
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:
parent
42d5f9c5a7
commit
166d2289c3
11 changed files with 1057 additions and 25 deletions
28
docs/MCP.md
28
docs/MCP.md
|
|
@ -12,10 +12,10 @@ MCP 宿主 → HTTP http://127.0.0.1:8890/mcp → netx_api.mcp_remote(进
|
||||||
|
|
||||||
| groups | 工具数 | 说明 |
|
| groups | 工具数 | 说明 |
|
||||||
|--------|--------|------|
|
|--------|--------|------|
|
||||||
| `ops` | 15 | 告警 / 网元 / 只读 CLI |
|
| `ops` | 15+1 | 告警 / 网元 / 只读 CLI;另有 meta 工具 `getNetxSkill` |
|
||||||
| `topology` | 16 | 拓扑树 / 画布 / Fabric / 布局 |
|
| `topology` | 16+1 | 拓扑树 / 画布 / Fabric / 布局;另有 `getNetxSkill` |
|
||||||
| `bizMonitor` | 8 | 割接与业务状态只读 |
|
| `bizMonitor` | 8+1 | 割接与业务状态只读;另有 `getNetxSkill` |
|
||||||
| (省略) | 39 | 当前 token 有权限的全部工具 |
|
| (省略) | 40 | 39 个能力工具 + `getNetxSkill`(有可见 skill 时) |
|
||||||
|
|
||||||
示例:`/mcp?groups=ops,bizMonitor`
|
示例:`/mcp?groups=ops,bizMonitor`
|
||||||
|
|
||||||
|
|
@ -58,7 +58,27 @@ http://127.0.0.1:8890/mcp?groups=ops
|
||||||
|
|
||||||
无状态模式下不主动推送 `notifications/tools/list_changed`;客户端应在需要时重新 `tools/list`。
|
无状态模式下不主动推送 `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_TOPOLOGY.md](./MCP_TOPOLOGY.md) — topology 组工具说明
|
||||||
- [MCP_BIZMONITOR.md](./MCP_BIZMONITOR.md) — bizMonitor 组工具说明
|
- [MCP_BIZMONITOR.md](./MCP_BIZMONITOR.md) — bizMonitor 组工具说明
|
||||||
|
- [`skills/README.md`](../skills/README.md) — skill 真源说明
|
||||||
|
|
|
||||||
|
|
@ -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)
|
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,
|
scopes: frozenset[str] | list[str] | None,
|
||||||
groups: frozenset[str] | None = None,
|
groups: frozenset[str] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> 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()
|
wanted_groups = groups or frozenset()
|
||||||
if scopes is None:
|
if scopes is None:
|
||||||
scoped = list(ALL_TOOLS)
|
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]
|
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:
|
def to_mcp_tool(tool: dict[str, Any]) -> Tool:
|
||||||
ann = tool.get("annotations") or {}
|
ann = tool.get("annotations") or {}
|
||||||
return Tool(
|
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]:
|
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:
|
if handler is None:
|
||||||
raise ValueError(f"unknown tool: {name}")
|
raise ValueError(f"unknown tool: {name}")
|
||||||
return handler(name, args)
|
return handler(name, args)
|
||||||
|
|
|
||||||
|
|
@ -7,16 +7,37 @@ from importlib.metadata import version as pkg_version
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from mcp.server import Server, ServerRequestContext
|
from mcp.server import Server, ServerRequestContext
|
||||||
|
from mcp.shared.exceptions import MCPError
|
||||||
from mcp.types import (
|
from mcp.types import (
|
||||||
CallToolRequestParams,
|
CallToolRequestParams,
|
||||||
CallToolResult,
|
CallToolResult,
|
||||||
|
GetPromptRequestParams,
|
||||||
|
GetPromptResult,
|
||||||
|
INVALID_PARAMS,
|
||||||
|
ListPromptsResult,
|
||||||
|
ListResourcesResult,
|
||||||
ListToolsResult,
|
ListToolsResult,
|
||||||
PaginatedRequestParams,
|
PaginatedRequestParams,
|
||||||
|
Prompt,
|
||||||
|
PromptMessage,
|
||||||
|
ReadResourceRequestParams,
|
||||||
|
ReadResourceResult,
|
||||||
|
Resource,
|
||||||
TextContent,
|
TextContent,
|
||||||
|
TextResourceContents,
|
||||||
)
|
)
|
||||||
|
|
||||||
from .context import get_request_context
|
from .context import get_request_context
|
||||||
from .registry import TOOL_REQUIRED_SCOPE, call_tool, to_mcp_tool, tools_for_request
|
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:
|
def netx_version() -> str:
|
||||||
|
|
@ -103,19 +124,115 @@ async def _on_call_tool(
|
||||||
return CallToolResult(content=texts, is_error=is_error)
|
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]:
|
def build_mcp_server() -> Server[Any]:
|
||||||
return Server(
|
server: Server[Any] = Server(
|
||||||
"netx",
|
"netx",
|
||||||
version=netx_version(),
|
version=netx_version(),
|
||||||
instructions=(
|
instructions=_BASE_INSTRUCTIONS_STATIC,
|
||||||
"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_list_tools=_on_list_tools,
|
||||||
on_call_tool=_on_call_tool,
|
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.
|
# Singleton used by mount; streamable_http_app() must be called once so session_manager exists.
|
||||||
mcp_server = build_mcp_server()
|
mcp_server = build_mcp_server()
|
||||||
|
|
|
||||||
391
netx_api/mcp_remote/skills.py
Normal file
391
netx_api/mcp_remote/skills.py
Normal 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"
|
||||||
|
|
@ -84,7 +84,7 @@ if (-not (Test-Path $winswShip)) {
|
||||||
}
|
}
|
||||||
|
|
||||||
# Flat layout = same as repo so scripts\start_netx.ps1 works unchanged.
|
# 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
|
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")) {
|
foreach ($f in @("pyproject.toml", "requirements.txt", "alembic.ini", "README.md", "LICENSE", "CONTRIBUTING.md", "SECURITY.md")) {
|
||||||
|
|
|
||||||
|
|
@ -73,7 +73,7 @@ find "${STAGE}/python" -name '*.pyc' -delete
|
||||||
|
|
||||||
# ------------------------------------------------------------ app files
|
# ------------------------------------------------------------ app files
|
||||||
echo "==> 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
|
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
|
if [[ -f "/src/${f}" ]]; then cp "/src/${f}" "${STAGE}/"; fi
|
||||||
done
|
done
|
||||||
|
|
|
||||||
|
|
@ -176,8 +176,8 @@ def test_upstream_failures_are_tool_errors(failure):
|
||||||
def test_scope_and_group_filter_for_bizmonitor():
|
def test_scope_and_group_filter_for_bizmonitor():
|
||||||
assert tools_for_request(frozenset({"ne:read"}), frozenset({"bizMonitor"})) == []
|
assert tools_for_request(frozenset({"ne:read"}), frozenset({"bizMonitor"})) == []
|
||||||
tools = tools_for_request(frozenset({"biz-monitor:read"}), frozenset({"bizMonitor"}))
|
tools = tools_for_request(frozenset({"biz-monitor:read"}), frozenset({"bizMonitor"}))
|
||||||
assert len(tools) == 8
|
assert len(tools) == 9 # 8 biz + getNetxSkill
|
||||||
assert all(t["_group"] == "bizMonitor" for t in tools)
|
assert {t["_group"] for t in tools} == {"bizMonitor", "meta"}
|
||||||
assert all(t["annotations"]["readOnlyHint"] for t in tools)
|
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():
|
def test_bizmonitor_group_has_eight_tools():
|
||||||
tools = tools_for_request(None, frozenset({"bizMonitor"}))
|
tools = tools_for_request(None, frozenset({"bizMonitor"}))
|
||||||
assert len(tools) == 8
|
assert len(tools) == 9 # 8 biz + getNetxSkill
|
||||||
assert {t["name"] for t in tools} == {t["name"] for t in HTTP_MCP_TOOLS}
|
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):
|
def test_token_priority_env_then_explicit_file_then_cwd(monkeypatch, tmp_path):
|
||||||
|
|
|
||||||
|
|
@ -92,11 +92,12 @@ def test_mcp_http_auth_list_groups_and_rbac():
|
||||||
)
|
)
|
||||||
assert listed.status_code == 200
|
assert listed.status_code == 200
|
||||||
tools = listed.json()["result"]["tools"]
|
tools = listed.json()["result"]["tools"]
|
||||||
assert len(tools) == 39
|
assert len(tools) == 40 # 39 capability + getNetxSkill
|
||||||
names = {t["name"] for t in tools}
|
names = {t["name"] for t in tools}
|
||||||
assert "queryNmsAlarms" in names
|
assert "queryNmsAlarms" in names
|
||||||
assert "getTopologyTree" in names
|
assert "getTopologyTree" in names
|
||||||
assert "listBizMonitors" in names
|
assert "listBizMonitors" in names
|
||||||
|
assert "getNetxSkill" in names
|
||||||
assert all("_meta" in t and t["_meta"]["netx"]["group"] for t in tools)
|
assert all("_meta" in t and t["_meta"]["netx"]["group"] for t in tools)
|
||||||
|
|
||||||
ops = client.post(
|
ops = client.post(
|
||||||
|
|
@ -106,8 +107,11 @@ def test_mcp_http_auth_list_groups_and_rbac():
|
||||||
)
|
)
|
||||||
assert ops.status_code == 200
|
assert ops.status_code == 200
|
||||||
ops_tools = ops.json()["result"]["tools"]
|
ops_tools = ops.json()["result"]["tools"]
|
||||||
assert len(ops_tools) == 15
|
assert len(ops_tools) == 16 # 15 ops + getNetxSkill
|
||||||
assert all(t["_meta"]["netx"]["group"] == "ops" for t in ops_tools)
|
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"])
|
raw_ro = _token(scopes=["ne:read", "alarms:read"])
|
||||||
headers_ro = {**_HDR, "authorization": f"Bearer {raw_ro}"}
|
headers_ro = {**_HDR, "authorization": f"Bearer {raw_ro}"}
|
||||||
|
|
|
||||||
|
|
@ -212,11 +212,13 @@ def test_tools_for_scopes_filters_by_granted() -> None:
|
||||||
|
|
||||||
def test_registry_groups_and_scope_filter() -> None:
|
def test_registry_groups_and_scope_filter() -> None:
|
||||||
ops = tools_for_request(None, frozenset({"ops"}))
|
ops = tools_for_request(None, frozenset({"ops"}))
|
||||||
assert len(ops) == 15
|
assert len(ops) == 16 # 15 ops + getNetxSkill
|
||||||
assert all(t["_group"] == "ops" for t in ops)
|
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"}))
|
limited = tools_for_request(frozenset({"ne:read"}), frozenset({"ops"}))
|
||||||
names = {t["name"] for t in limited}
|
names = {t["name"] for t in limited}
|
||||||
assert "listManagedNe" in names
|
assert "listManagedNe" in names
|
||||||
|
assert "getNetxSkill" in names
|
||||||
assert "execManagedNe" not in names
|
assert "execManagedNe" not in names
|
||||||
assert "queryNmsAlarms" not in names
|
assert "queryNmsAlarms" not in names
|
||||||
|
|
||||||
|
|
|
||||||
473
tests/mcp_remote/test_mcp_skills.py
Normal file
473
tests/mcp_remote/test_mcp_skills.py
Normal 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)
|
||||||
|
|
@ -515,7 +515,7 @@ def test_neighborhood_returns_links_not_ports() -> None:
|
||||||
|
|
||||||
def test_topology_group_lists_sixteen_tools() -> None:
|
def test_topology_group_lists_sixteen_tools() -> None:
|
||||||
tools = tools_for_request(None, frozenset({"topology"}))
|
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}
|
names = {t["name"] for t in tools}
|
||||||
assert "suggestSinkHubs" in names
|
assert "suggestSinkHubs" in names
|
||||||
assert "classifyTopologyFabricNodes" in names
|
assert "classifyTopologyFabricNodes" in names
|
||||||
|
|
@ -527,9 +527,11 @@ def test_topology_group_lists_sixteen_tools() -> None:
|
||||||
assert "layoutTopologyView" in names
|
assert "layoutTopologyView" in names
|
||||||
assert "sinkTopologyDualUnits" in names
|
assert "sinkTopologyDualUnits" in names
|
||||||
assert "copyTopologyViewNodes" in names
|
assert "copyTopologyViewNodes" in names
|
||||||
|
assert "getNetxSkill" in names
|
||||||
read_only = tools_for_request(frozenset({"ne:read"}), frozenset({"topology"}))
|
read_only = tools_for_request(frozenset({"ne:read"}), frozenset({"topology"}))
|
||||||
read_names = {t["name"] for t in read_only}
|
read_names = {t["name"] for t in read_only}
|
||||||
assert "getTopologyView" in read_names
|
assert "getTopologyView" in read_names
|
||||||
|
assert "getNetxSkill" in read_names
|
||||||
assert "layoutTopologyView" not in read_names
|
assert "layoutTopologyView" not in read_names
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue