mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 00:23:19 +08:00
Also make the HTTP-error test independent of the developer .env token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
443 lines
14 KiB
Python
443 lines
14 KiB
Python
"""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 (
|
||
ALL_TOOLS,
|
||
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
|
||
|
||
|
||
# abs_path -> (mtime_ns, size, description); SKILL.md is re-parsed only when it changes.
|
||
_DESC_CACHE: dict[Path, tuple[int, int, str]] = {}
|
||
|
||
|
||
def _cached_description(skill_file: "SkillFile") -> str:
|
||
try:
|
||
st = skill_file.abs_path.stat()
|
||
except OSError:
|
||
return ""
|
||
hit = _DESC_CACHE.get(skill_file.abs_path)
|
||
if hit and hit[0] == st.st_mtime_ns and hit[1] == st.st_size:
|
||
return hit[2]
|
||
try:
|
||
desc = _parse_description(read_skill_text(skill_file))
|
||
except OSError:
|
||
desc = ""
|
||
_DESC_CACHE[skill_file.abs_path] = (st.st_mtime_ns, st.st_size, desc)
|
||
return desc
|
||
|
||
|
||
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 = _cached_description(f) if f.entry else ""
|
||
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 unavailable_tools(
|
||
group: str,
|
||
scopes: frozenset[str] | list[str] | None,
|
||
) -> list[str]:
|
||
"""Tools of ``group`` the skill may mention but this token cannot call."""
|
||
granted = {str(t["name"]) for t in capability_tools_for_request(scopes, frozenset({group}))}
|
||
return [
|
||
str(t["name"])
|
||
for t in ALL_TOOLS
|
||
if t.get("_group") == group and str(t["name"]) not in granted
|
||
]
|
||
|
||
|
||
def unavailable_note(group: str, scopes: frozenset[str] | list[str] | None) -> str:
|
||
names = unavailable_tools(group, scopes)
|
||
if not names:
|
||
return ""
|
||
return (
|
||
"\n---\n当前 token 无权使用以下工具,本 skill 中涉及它们的步骤请跳过,不要调用:"
|
||
+ ", ".join(names)
|
||
+ "\n"
|
||
)
|
||
|
||
|
||
def resource_dict(
|
||
skill_file: SkillFile,
|
||
*,
|
||
description: str | None = None,
|
||
scopes: frozenset[str] | list[str] | None = None,
|
||
) -> dict[str, Any]:
|
||
desc = description
|
||
if desc is None and skill_file.entry:
|
||
desc = _cached_description(skill_file)
|
||
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,
|
||
**(
|
||
{"unavailableTools": unavailable_tools(skill_file.group, scopes)}
|
||
if skill_file.entry
|
||
else {}
|
||
),
|
||
}
|
||
},
|
||
}
|
||
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 any(f.entry for f in list_visible_skill_files(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"
|
||
text += unavailable_note(skill_file.group, scopes)
|
||
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"
|
||
+ unavailable_note(skill_file.group, scopes)
|
||
)
|