mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 02:50:48 +08:00
feat(mcp): tell skills which tools the token cannot call; cache skill descriptions
Also make the HTTP-error test independent of the developer .env token. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
166d2289c3
commit
8f9a64a266
5 changed files with 101 additions and 15 deletions
|
|
@ -69,7 +69,9 @@ http://127.0.0.1:8890/mcp?groups=ops
|
||||||
| `prompts/list` · `prompts/get` | 每个可见 skill 一个 prompt(名=skill 名);Claude Code 显示为 `/mcp__netx__<skill名>` |
|
| `prompts/list` · `prompts/get` | 每个可见 skill 一个 prompt(名=skill 名);Claude Code 显示为 `/mcp__netx__<skill名>` |
|
||||||
| `initialize.instructions` | 在原有说明后追加当前可见 skill 目录,并提示先调 `getNetxSkill` |
|
| `initialize.instructions` | 在原有说明后追加当前可见 skill 目录,并提示先调 `getNetxSkill` |
|
||||||
|
|
||||||
可见规则:该组下当前 token 至少有一个可见工具,且遵守 `?groups=`。改 skill 文件后无需重启 netx(每次请求读盘)。
|
可见规则:该组下当前 token 至少有一个可见工具,且遵守 `?groups=`。改 skill 文件后无需重启 netx(每次请求读盘,description 按文件修改时间缓存)。
|
||||||
|
|
||||||
|
skill 原文不随 token 改写。当 token 只有该组部分工具的权限时:`getNetxSkill` 和 prompts 会在末尾附上"当前 token 无权使用以下工具"清单;`resources/list` 里 SKILL.md 条目的 `_meta.netx.unavailableTools` 也给出同一份清单(DSH 插件据此在 skill 末尾追加"不可用工具"提示)。
|
||||||
|
|
||||||
### Cursor / Claude Code 怎么读
|
### Cursor / Claude Code 怎么读
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,7 @@ async def _on_list_resources(
|
||||||
scopes, groups = _scopes_and_groups()
|
scopes, groups = _scopes_and_groups()
|
||||||
resources: list[Resource] = []
|
resources: list[Resource] = []
|
||||||
for sf in list_visible_skill_files(scopes, groups):
|
for sf in list_visible_skill_files(scopes, groups):
|
||||||
d = resource_dict(sf)
|
d = resource_dict(sf, scopes=scopes)
|
||||||
resources.append(
|
resources.append(
|
||||||
Resource(
|
Resource(
|
||||||
uri=d["uri"],
|
uri=d["uri"],
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from pathlib import Path
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from .registry import (
|
from .registry import (
|
||||||
|
ALL_TOOLS,
|
||||||
GROUP_BIZ,
|
GROUP_BIZ,
|
||||||
GROUP_OPS,
|
GROUP_OPS,
|
||||||
GROUP_TOPOLOGY,
|
GROUP_TOPOLOGY,
|
||||||
|
|
@ -74,6 +75,26 @@ def _is_text_file(path: Path) -> bool:
|
||||||
return path.is_file() and path.suffix.lower() in _TEXT_SUFFIXES
|
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:
|
def _parse_description(text: str) -> str:
|
||||||
m = _FRONTMATTER_RE.match(text)
|
m = _FRONTMATTER_RE.match(text)
|
||||||
if not m:
|
if not m:
|
||||||
|
|
@ -170,12 +191,7 @@ def list_visible_skills(
|
||||||
key = (f.group, f.skill)
|
key = (f.group, f.skill)
|
||||||
if key in by_key and not f.entry:
|
if key in by_key and not f.entry:
|
||||||
continue
|
continue
|
||||||
desc = ""
|
desc = _cached_description(f) if f.entry else ""
|
||||||
if f.entry:
|
|
||||||
try:
|
|
||||||
desc = _parse_description(read_skill_text(f))
|
|
||||||
except OSError:
|
|
||||||
desc = ""
|
|
||||||
by_key[key] = (f.skill, f.group, desc)
|
by_key[key] = (f.skill, f.group, desc)
|
||||||
order = {GROUP_OPS: 0, GROUP_TOPOLOGY: 1, GROUP_BIZ: 2}
|
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()))
|
return sorted(by_key.values(), key=lambda x: (order.get(x[1], 9), x[0].lower()))
|
||||||
|
|
@ -219,13 +235,39 @@ def resolve_skill_file(
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def resource_dict(skill_file: SkillFile, *, description: str | None = None) -> dict[str, Any]:
|
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
|
desc = description
|
||||||
if desc is None and skill_file.entry:
|
if desc is None and skill_file.entry:
|
||||||
try:
|
desc = _cached_description(skill_file)
|
||||||
desc = _parse_description(read_skill_text(skill_file))
|
|
||||||
except OSError:
|
|
||||||
desc = ""
|
|
||||||
out: dict[str, Any] = {
|
out: dict[str, Any] = {
|
||||||
"uri": skill_file.uri,
|
"uri": skill_file.uri,
|
||||||
"name": skill_file.rel_path,
|
"name": skill_file.rel_path,
|
||||||
|
|
@ -235,6 +277,11 @@ def resource_dict(skill_file: SkillFile, *, description: str | None = None) -> d
|
||||||
"group": skill_file.group,
|
"group": skill_file.group,
|
||||||
"skill": skill_file.skill,
|
"skill": skill_file.skill,
|
||||||
"entry": skill_file.entry,
|
"entry": skill_file.entry,
|
||||||
|
**(
|
||||||
|
{"unavailableTools": unavailable_tools(skill_file.group, scopes)}
|
||||||
|
if skill_file.entry
|
||||||
|
else {}
|
||||||
|
),
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
@ -315,7 +362,7 @@ def should_advertise_get_netx_skill(
|
||||||
*,
|
*,
|
||||||
root: Path | None = None,
|
root: Path | None = None,
|
||||||
) -> bool:
|
) -> bool:
|
||||||
return bool(list_visible_skills(scopes, groups, root=root))
|
return any(f.entry for f in list_visible_skill_files(scopes, groups, root=root))
|
||||||
|
|
||||||
|
|
||||||
def available_skill_names(
|
def available_skill_names(
|
||||||
|
|
@ -371,6 +418,7 @@ def call_get_netx_skill(
|
||||||
text = text.rstrip() + "\n\n---\n其他文件:\n" + "\n".join(f"- {s}" for s in siblings) + "\n"
|
text = text.rstrip() + "\n\n---\n其他文件:\n" + "\n".join(f"- {s}" for s in siblings) + "\n"
|
||||||
else:
|
else:
|
||||||
text = text.rstrip() + "\n\n---\n其他文件:(无)\n"
|
text = text.rstrip() + "\n\n---\n其他文件:(无)\n"
|
||||||
|
text += unavailable_note(skill_file.group, scopes)
|
||||||
return {"content": [{"type": "text", "text": text}], "isError": False}
|
return {"content": [{"type": "text", "text": text}], "isError": False}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -388,4 +436,8 @@ def prompt_skill_md_text(
|
||||||
body = read_skill_text(skill_file)
|
body = read_skill_text(skill_file)
|
||||||
except OSError:
|
except OSError:
|
||||||
return None
|
return None
|
||||||
return body.rstrip() + "\n\n其他文件用 getNetxSkill 读取。\n"
|
return (
|
||||||
|
body.rstrip()
|
||||||
|
+ "\n\n其他文件用 getNetxSkill 读取。\n"
|
||||||
|
+ unavailable_note(skill_file.group, scopes)
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -463,6 +463,8 @@ def test_http_errors_preserve_status_and_bound_detail(method: str, status: int)
|
||||||
client = httpx.Client(transport=transport, base_url="http://example.test")
|
client = httpx.Client(transport=transport, base_url="http://example.test")
|
||||||
with (
|
with (
|
||||||
patch("netx_api.mcp_remote.asgi_client.get_app", return_value=None),
|
patch("netx_api.mcp_remote.asgi_client.get_app", return_value=None),
|
||||||
|
# Independent of the developer's .env: the fallback path needs an outbound token.
|
||||||
|
patch("netx_api.mcp_remote.asgi_client.api_token", return_value="test-token"),
|
||||||
patch("netx_api.mcp_remote.asgi_client.httpx.Client", return_value=client),
|
patch("netx_api.mcp_remote.asgi_client.httpx.Client", return_value=client),
|
||||||
):
|
):
|
||||||
result = http_json("GET", "/v1/ume/alarms") if method == "GET" else http_post_json("/v1/managed-ne/exec", {})
|
result = http_json("GET", "/v1/ume/alarms") if method == "GET" else http_post_json("/v1/managed-ne/exec", {})
|
||||||
|
|
|
||||||
|
|
@ -116,6 +116,7 @@ def test_resources_list_and_read_via_http():
|
||||||
"group": "ops",
|
"group": "ops",
|
||||||
"skill": "netx-ops",
|
"skill": "netx-ops",
|
||||||
"entry": True,
|
"entry": True,
|
||||||
|
"unavailableTools": [],
|
||||||
}
|
}
|
||||||
ref = next(r for r in resources if r["uri"].endswith("/reference.md"))
|
ref = next(r for r in resources if r["uri"].endswith("/reference.md"))
|
||||||
assert "description" not in ref or not ref.get("description")
|
assert "description" not in ref or not ref.get("description")
|
||||||
|
|
@ -471,3 +472,32 @@ def test_mcp_tcp_resources_list(live_skills_port: int):
|
||||||
uris = {x["uri"] for x in data["result"]["resources"]}
|
uris = {x["uri"] for x in data["result"]["resources"]}
|
||||||
assert "netx://skills/ops/netx-ops/SKILL.md" in uris
|
assert "netx://skills/ops/netx-ops/SKILL.md" in uris
|
||||||
assert all("biz-state" not in u for u in uris)
|
assert all("biz-state" not in u for u in uris)
|
||||||
|
|
||||||
|
|
||||||
|
def test_skill_lists_tools_the_token_cannot_call():
|
||||||
|
from netx_api.mcp_remote.skills import (
|
||||||
|
call_get_netx_skill,
|
||||||
|
prompt_skill_md_text,
|
||||||
|
resource_dict,
|
||||||
|
unavailable_tools,
|
||||||
|
)
|
||||||
|
|
||||||
|
limited = frozenset({"ne:read"})
|
||||||
|
missing = unavailable_tools("ops", limited)
|
||||||
|
assert "execManagedNe" in missing
|
||||||
|
assert "listManagedNe" not in missing
|
||||||
|
assert unavailable_tools("ops", None) == []
|
||||||
|
|
||||||
|
out = call_get_netx_skill({"name": "netx-ops"}, limited)
|
||||||
|
text = out["content"][0]["text"]
|
||||||
|
assert out["isError"] is False
|
||||||
|
assert "当前 token 无权使用以下工具" in text and "execManagedNe" in text
|
||||||
|
|
||||||
|
assert "execManagedNe" in (prompt_skill_md_text("netx-ops", limited) or "")
|
||||||
|
|
||||||
|
entry = next(f for f in list_visible_skill_files(limited) if f.skill == "netx-ops" and f.entry)
|
||||||
|
meta = resource_dict(entry, scopes=limited)["_meta"]["netx"]
|
||||||
|
assert "execManagedNe" in meta["unavailableTools"]
|
||||||
|
|
||||||
|
full = call_get_netx_skill({"name": "netx-ops"}, None)["content"][0]["text"]
|
||||||
|
assert "当前 token 无权使用以下工具" not in full
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue