diff --git a/packages/netx-mcp/src/netx_mcp/server.py b/packages/netx-mcp/src/netx_mcp/server.py index 2448267..66d9f17 100644 --- a/packages/netx-mcp/src/netx_mcp/server.py +++ b/packages/netx-mcp/src/netx_mcp/server.py @@ -40,14 +40,22 @@ def _err(rid: Any, code: int, message: str) -> None: sys.stdout.flush() +_UNSET = object() + + def _fetch_scopes() -> list[str] | None: """Return granted scopes from /v1/auth/me, or None if the call fails (show all tools).""" try: - data = http_json("GET", "/v1/auth/me") - scopes = data.get("scopes") if isinstance(data, dict) else None + envelope = http_json("GET", "/v1/auth/me") + 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): 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): return [str(s) for s in user["scopes"]] except Exception: @@ -56,11 +64,11 @@ def _fetch_scopes() -> list[str] | 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: nonlocal cached_scopes - if cached_scopes is object(): + if cached_scopes is _UNSET: cached_scopes = _fetch_scopes() return cached_scopes # type: ignore[return-value] diff --git a/packages/netx-mcp/tests/test_mcp_http.py b/packages/netx-mcp/tests/test_mcp_http.py index 11a6f2e..ae7729c 100644 --- a/packages/netx-mcp/tests/test_mcp_http.py +++ b/packages/netx-mcp/tests/test_mcp_http.py @@ -9,12 +9,13 @@ from unittest.mock import patch 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: 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 "queryUmeAlarmsRaw" 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" +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: proc = subprocess.Popen( [sys.executable, "-m", "netx_mcp"], @@ -88,6 +111,8 @@ def test_stdio_initialize_and_tools_list() -> None: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", + errors="replace", ) assert proc.stdin and proc.stdout 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() list_line = proc.stdout.readline() list_resp = json.loads(list_line) + assert "error" not in list_resp, list_resp tools = list_resp["result"]["tools"] - assert len(tools) == 13 + assert len(tools) == 14 proc.terminate() proc.wait(timeout=5) diff --git a/tests/test_mcp_http.py b/tests/test_mcp_http.py index 38b3314..e1a138d 100644 --- a/tests/test_mcp_http.py +++ b/tests/test_mcp_http.py @@ -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: 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 "queryUmeAlarmsRaw" in names assert "execManagedNe" in names @@ -67,6 +67,8 @@ def test_stdio_initialize_and_tools_list() -> None: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", + errors="replace", ) assert proc.stdin and proc.stdout 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() list_line = proc.stdout.readline() list_resp = json.loads(list_line) + assert "error" not in list_resp, list_resp tools = list_resp["result"]["tools"] - assert len(tools) == 13 + assert len(tools) == 14 proc.terminate() proc.wait(timeout=5) @@ -95,6 +98,8 @@ def test_legacy_netx_api_mcp_module_still_works() -> None: stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + encoding="utf-8", + errors="replace", cwd=str(__import__("pathlib").Path(__file__).resolve().parents[1]), ) assert proc.stdin and proc.stdout