netx/tests/mcp_remote/test_mcp_skills.py
oliver 166d2289c3 feat(mcp): publish ops skills via /mcp resources, prompts and getNetxSkill
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:14:39 +08:00

473 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""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)