mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
Fix MCP scopes cache sentinel that broke tools/list.
Reuse a stable _UNSET sentinel and unwrap http_json envelopes so auth scope filtering works. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
d2fb33ec0d
commit
66f26b738d
3 changed files with 49 additions and 10 deletions
|
|
@ -40,14 +40,22 @@ def _err(rid: Any, code: int, message: str) -> None:
|
||||||
sys.stdout.flush()
|
sys.stdout.flush()
|
||||||
|
|
||||||
|
|
||||||
|
_UNSET = object()
|
||||||
|
|
||||||
|
|
||||||
def _fetch_scopes() -> list[str] | None:
|
def _fetch_scopes() -> list[str] | None:
|
||||||
"""Return granted scopes from /v1/auth/me, or None if the call fails (show all tools)."""
|
"""Return granted scopes from /v1/auth/me, or None if the call fails (show all tools)."""
|
||||||
try:
|
try:
|
||||||
data = http_json("GET", "/v1/auth/me")
|
envelope = http_json("GET", "/v1/auth/me")
|
||||||
scopes = data.get("scopes") if isinstance(data, dict) else None
|
if not isinstance(envelope, dict) or not envelope.get("ok"):
|
||||||
|
return None
|
||||||
|
data = envelope.get("data")
|
||||||
|
if not isinstance(data, dict):
|
||||||
|
return None
|
||||||
|
scopes = data.get("scopes")
|
||||||
if isinstance(scopes, list):
|
if isinstance(scopes, list):
|
||||||
return [str(s) for s in scopes]
|
return [str(s) for s in scopes]
|
||||||
user = data.get("user") if isinstance(data, dict) else None
|
user = data.get("user")
|
||||||
if isinstance(user, dict) and isinstance(user.get("scopes"), list):
|
if isinstance(user, dict) and isinstance(user.get("scopes"), list):
|
||||||
return [str(s) for s in user["scopes"]]
|
return [str(s) for s in user["scopes"]]
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -56,11 +64,11 @@ def _fetch_scopes() -> list[str] | None:
|
||||||
|
|
||||||
|
|
||||||
def run_stdio_loop() -> None:
|
def run_stdio_loop() -> None:
|
||||||
cached_scopes: list[str] | None | object = object()
|
cached_scopes: list[str] | None | object = _UNSET
|
||||||
|
|
||||||
def scopes() -> list[str] | None:
|
def scopes() -> list[str] | None:
|
||||||
nonlocal cached_scopes
|
nonlocal cached_scopes
|
||||||
if cached_scopes is object():
|
if cached_scopes is _UNSET:
|
||||||
cached_scopes = _fetch_scopes()
|
cached_scopes = _fetch_scopes()
|
||||||
return cached_scopes # type: ignore[return-value]
|
return cached_scopes # type: ignore[return-value]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -9,12 +9,13 @@ from unittest.mock import patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool
|
from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool, tools_for_scopes
|
||||||
|
from netx_mcp.server import _fetch_scopes
|
||||||
|
|
||||||
|
|
||||||
def test_http_mcp_tool_list_has_expected_tools() -> None:
|
def test_http_mcp_tool_list_has_expected_tools() -> None:
|
||||||
names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS]
|
names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS]
|
||||||
assert len(names) == 13
|
assert len(names) == 14
|
||||||
assert "queryUmeAlarms" in names
|
assert "queryUmeAlarms" in names
|
||||||
assert "queryUmeAlarmsRaw" in names
|
assert "queryUmeAlarmsRaw" in names
|
||||||
assert "execManagedNe" in names
|
assert "execManagedNe" in names
|
||||||
|
|
@ -81,6 +82,28 @@ def test_call_exec_managed_ne_respects_max_commands_env(monkeypatch: pytest.Monk
|
||||||
assert payload.get("error_code") == "too_many_commands"
|
assert payload.get("error_code") == "too_many_commands"
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_scopes_unwraps_http_json_envelope() -> None:
|
||||||
|
with patch("netx_mcp.server.http_json") as mock_http:
|
||||||
|
mock_http.return_value = {
|
||||||
|
"ok": True,
|
||||||
|
"data": {"scopes": ["ne:read", "alarms:read"], "user": {"username": "mcp"}},
|
||||||
|
}
|
||||||
|
assert _fetch_scopes() == ["ne:read", "alarms:read"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_fetch_scopes_returns_none_on_http_failure() -> None:
|
||||||
|
with patch("netx_mcp.server.http_json") as mock_http:
|
||||||
|
mock_http.return_value = {"ok": False, "error": "netx_http_401"}
|
||||||
|
assert _fetch_scopes() is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_tools_for_scopes_filters_by_granted() -> None:
|
||||||
|
names = {str(t.get("name") or "") for t in tools_for_scopes(["ne:read"])}
|
||||||
|
assert "listManagedNe" in names
|
||||||
|
assert "queryUmeAlarms" not in names
|
||||||
|
assert tools_for_scopes(None) == list(HTTP_MCP_TOOLS)
|
||||||
|
|
||||||
|
|
||||||
def test_stdio_initialize_and_tools_list() -> None:
|
def test_stdio_initialize_and_tools_list() -> None:
|
||||||
proc = subprocess.Popen(
|
proc = subprocess.Popen(
|
||||||
[sys.executable, "-m", "netx_mcp"],
|
[sys.executable, "-m", "netx_mcp"],
|
||||||
|
|
@ -88,6 +111,8 @@ def test_stdio_initialize_and_tools_list() -> None:
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
)
|
)
|
||||||
assert proc.stdin and proc.stdout
|
assert proc.stdin and proc.stdout
|
||||||
init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n"
|
init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n"
|
||||||
|
|
@ -102,8 +127,9 @@ def test_stdio_initialize_and_tools_list() -> None:
|
||||||
proc.stdin.flush()
|
proc.stdin.flush()
|
||||||
list_line = proc.stdout.readline()
|
list_line = proc.stdout.readline()
|
||||||
list_resp = json.loads(list_line)
|
list_resp = json.loads(list_line)
|
||||||
|
assert "error" not in list_resp, list_resp
|
||||||
tools = list_resp["result"]["tools"]
|
tools = list_resp["result"]["tools"]
|
||||||
assert len(tools) == 13
|
assert len(tools) == 14
|
||||||
|
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=5)
|
proc.wait(timeout=5)
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ from netx_mcp.http_tools import HTTP_MCP_TOOLS, call_http_tool
|
||||||
|
|
||||||
def test_http_mcp_tool_list_has_thirteen_tools() -> None:
|
def test_http_mcp_tool_list_has_thirteen_tools() -> None:
|
||||||
names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS]
|
names = [str(t.get("name") or "") for t in HTTP_MCP_TOOLS]
|
||||||
assert len(names) == 13
|
assert len(names) == 14
|
||||||
assert "queryUmeAlarms" in names
|
assert "queryUmeAlarms" in names
|
||||||
assert "queryUmeAlarmsRaw" in names
|
assert "queryUmeAlarmsRaw" in names
|
||||||
assert "execManagedNe" in names
|
assert "execManagedNe" in names
|
||||||
|
|
@ -67,6 +67,8 @@ def test_stdio_initialize_and_tools_list() -> None:
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
)
|
)
|
||||||
assert proc.stdin and proc.stdout
|
assert proc.stdin and proc.stdout
|
||||||
init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n"
|
init_req = json.dumps({"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {}}) + "\n"
|
||||||
|
|
@ -81,8 +83,9 @@ def test_stdio_initialize_and_tools_list() -> None:
|
||||||
proc.stdin.flush()
|
proc.stdin.flush()
|
||||||
list_line = proc.stdout.readline()
|
list_line = proc.stdout.readline()
|
||||||
list_resp = json.loads(list_line)
|
list_resp = json.loads(list_line)
|
||||||
|
assert "error" not in list_resp, list_resp
|
||||||
tools = list_resp["result"]["tools"]
|
tools = list_resp["result"]["tools"]
|
||||||
assert len(tools) == 13
|
assert len(tools) == 14
|
||||||
|
|
||||||
proc.terminate()
|
proc.terminate()
|
||||||
proc.wait(timeout=5)
|
proc.wait(timeout=5)
|
||||||
|
|
@ -95,6 +98,8 @@ def test_legacy_netx_api_mcp_module_still_works() -> None:
|
||||||
stdout=subprocess.PIPE,
|
stdout=subprocess.PIPE,
|
||||||
stderr=subprocess.PIPE,
|
stderr=subprocess.PIPE,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
|
errors="replace",
|
||||||
cwd=str(__import__("pathlib").Path(__file__).resolve().parents[1]),
|
cwd=str(__import__("pathlib").Path(__file__).resolve().parents[1]),
|
||||||
)
|
)
|
||||||
assert proc.stdin and proc.stdout
|
assert proc.stdin and proc.stdout
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue