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
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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue